From 1c2898870cd7644046d8367e8b50470e6d59dc9c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 18 Mar 2025 14:23:37 +0200 Subject: [PATCH 001/581] bridge: remove fallback status package --- bridge/status/deprecated.go | 83 ------------------------------------- 1 file changed, 83 deletions(-) delete mode 100644 bridge/status/deprecated.go diff --git a/bridge/status/deprecated.go b/bridge/status/deprecated.go deleted file mode 100644 index 1b3f24a4..00000000 --- a/bridge/status/deprecated.go +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -// Deprecated: use bridgev2/status -package status - -import ( - "maunium.net/go/mautrix/bridgev2/status" -) - -// Deprecated: use bridgev2/status -type ( - BridgeStateEvent = status.BridgeStateEvent - BridgeStateErrorCode = status.BridgeStateErrorCode - BridgeStateErrorMap = status.BridgeStateErrorMap - BridgeState = status.BridgeState - RemoteProfile = status.RemoteProfile - GlobalBridgeState = status.GlobalBridgeState - BridgeStateFiller = status.BridgeStateFiller - StandaloneCustomBridgeStateFiller = status.StandaloneCustomBridgeStateFiller - CustomBridgeStateFiller = status.CustomBridgeStateFiller - MessageCheckpointStep = status.MessageCheckpointStep - MessageCheckpointStatus = status.MessageCheckpointStatus - MessageCheckpointReportedBy = status.MessageCheckpointReportedBy - MessageCheckpoint = status.MessageCheckpoint - CheckpointsJSON = status.CheckpointsJSON - LocalBridgeAccountState = status.LocalBridgeAccountState - LocalBridgeDeviceState = status.LocalBridgeDeviceState -) - -// Deprecated: use bridgev2/status -const ( - StateStarting = status.StateStarting - StateUnconfigured = status.StateUnconfigured - StateRunning = status.StateRunning - StateBridgeUnreachable = status.StateBridgeUnreachable - - StateConnecting = status.StateConnecting - StateBackfilling = status.StateBackfilling - StateConnected = status.StateConnected - StateTransientDisconnect = status.StateTransientDisconnect - StateBadCredentials = status.StateBadCredentials - StateUnknownError = status.StateUnknownError - StateLoggedOut = status.StateLoggedOut - - MsgStepClient = status.MsgStepClient - MsgStepHomeserver = status.MsgStepHomeserver - MsgStepBridge = status.MsgStepBridge - MsgStepDecrypted = status.MsgStepDecrypted - MsgStepRemote = status.MsgStepRemote - MsgStepCommand = status.MsgStepCommand - - MsgStatusSuccess = status.MsgStatusSuccess - MsgStatusWillRetry = status.MsgStatusWillRetry - MsgStatusPermFailure = status.MsgStatusPermFailure - MsgStatusUnsupported = status.MsgStatusUnsupported - MsgStatusTimeout = status.MsgStatusTimeout - MsgStatusDelivered = status.MsgStatusDelivered - MsgStatusDeliveryFailed = status.MsgStatusDeliveryFailed - - MsgReportedByAsmux = status.MsgReportedByAsmux - MsgReportedByBridge = status.MsgReportedByBridge - MsgReportedByHungry = status.MsgReportedByHungry - - LocalBridgeAccountStateSetup = status.LocalBridgeAccountStateSetup - LocalBridgeAccountStateDeleted = status.LocalBridgeAccountStateDeleted - - LocalBridgeDeviceStateSetup = status.LocalBridgeDeviceStateSetup - LocalBridgeDeviceStateLoggedOut = status.LocalBridgeDeviceStateLoggedOut - LocalBridgeDeviceStateError = status.LocalBridgeDeviceStateError - LocalBridgeDeviceStateDeleted = status.LocalBridgeDeviceStateDeleted -) - -// Deprecated: use bridgev2/status -var ( - CheckpointTypes = status.CheckpointTypes - NewMessageCheckpoint = status.NewMessageCheckpoint - ReasonToCheckpointStatus = status.ReasonToCheckpointStatus - BridgeStateHumanErrors = status.BridgeStateHumanErrors -) From 03618fcc891f17f1ce179cf7a08350f24d448dbf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 20 Mar 2025 13:40:08 +0200 Subject: [PATCH 002/581] bridgev2: add support for timeouts on pending messages --- bridgev2/errors.go | 3 ++ bridgev2/networkinterface.go | 14 +++++++ bridgev2/portal.go | 72 +++++++++++++++++++++++++++++++----- 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/bridgev2/errors.go b/bridgev2/errors.go index 0e948184..c023dcdf 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -12,6 +12,7 @@ import ( "net/http" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" ) // ErrIgnoringRemoteEvent can be returned by [RemoteMessage.ConvertMessage] or [RemoteEdit.ConvertEdit] @@ -64,6 +65,8 @@ var ( ErrMediaConvertFailed error = WrapErrorInStatus(errors.New("failed to convert media")).WithMessage("failed to convert media").WithIsCertain(true).WithSendNotice(true) ErrMembershipNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group membership")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) ErrPowerLevelsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group power levels")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) + ErrRemoteEchoTimeout = WrapErrorInStatus(errors.New("remote echo timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) + ErrRemoteAckTimeout = WrapErrorInStatus(errors.New("remote ack timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) ) // Common login interface errors diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 487f1ea6..29ee9fc9 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -302,6 +302,14 @@ type MatrixMessageResponse struct { PostSave func(context.Context, *database.Message) } +type OutgoingTimeoutConfig struct { + CheckInterval time.Duration + NoEchoTimeout time.Duration + NoEchoMessage string + NoAckTimeout time.Duration + NoAckMessage string +} + type NetworkGeneralCapabilities struct { // Does the network connector support disappearing messages? // This flag enables the message disappearing loop in the bridge. @@ -309,6 +317,10 @@ type NetworkGeneralCapabilities struct { // Should the bridge re-request user info on incoming messages even if the ghost already has info? // By default, info is only requested for ghosts with no name, and other updating is left to events. AggressiveUpdateInfo bool + // If the bridge uses the pending message mechanism ([MatrixMessage.AddPendingToSave]) + // to handle asynchronous message responses, this field can be set to enable + // automatic timeout errors in case the asynchronous response never arrives. + OutgoingMessageTimeouts *OutgoingTimeoutConfig } // NetworkAPI is an interface representing a remote network client for a single user login. @@ -1145,6 +1157,8 @@ type MatrixMessage struct { MatrixEventBase[*event.MessageEventContent] ThreadRoot *database.Message ReplyTo *database.Message + + pendingSaves []*outgoingMessage } type MatrixEdit struct { diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 409a9c10..5e4328e7 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -59,10 +59,12 @@ type portalEvent interface { } type outgoingMessage struct { - db *database.Message - evt *event.Event - ignore bool - handle func(RemoteMessage, *database.Message) (bool, error) + db *database.Message + evt *event.Event + ignore bool + handle func(RemoteMessage, *database.Message) (bool, error) + ackedAt time.Time + timeouted bool } type Portal struct { @@ -76,7 +78,7 @@ type Portal struct { currentlyTypingLogins map[id.UserID]*UserLogin currentlyTypingLock sync.Mutex - outgoingMessages map[networkid.TransactionID]outgoingMessage + outgoingMessages map[networkid.TransactionID]*outgoingMessage outgoingMessagesLock sync.Mutex lastCapUpdate time.Time @@ -113,7 +115,7 @@ func (br *Bridge) loadPortal(ctx context.Context, dbPortal *database.Portal, que Bridge: br, currentlyTypingLogins: make(map[id.UserID]*UserLogin), - outgoingMessages: make(map[networkid.TransactionID]outgoingMessage), + outgoingMessages: make(map[networkid.TransactionID]*outgoingMessage), } br.portalsByKey[portal.PortalKey] = portal if portal.MXID != "" { @@ -296,6 +298,11 @@ func (portal *Portal) queueEvent(ctx context.Context, evt portalEvent) { } func (portal *Portal) eventLoop() { + if cfg := portal.Bridge.Network.GetCapabilities().OutgoingMessageTimeouts; cfg != nil { + ctx, cancel := context.WithCancel(portal.Log.WithContext(context.Background())) + go portal.pendingMessageTimeoutLoop(ctx, cfg) + defer cancel() + } i := 0 for rawEvt := range portal.events { i++ @@ -957,7 +964,11 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin return } message := wrappedMsgEvt.fillDBMessage(resp.DB) - if !resp.Pending { + if resp.Pending { + for _, save := range wrappedMsgEvt.pendingSaves { + save.ackedAt = time.Now() + } + } else { if resp.DB == nil { log.Error().Msg("Network connector didn't return a message to save") } else { @@ -1003,7 +1014,7 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin // See also: [MatrixMessage.AddPendingToSave] func (evt *MatrixMessage) AddPendingToIgnore(txnID networkid.TransactionID) { evt.Portal.outgoingMessagesLock.Lock() - evt.Portal.outgoingMessages[txnID] = outgoingMessage{ + evt.Portal.outgoingMessages[txnID] = &outgoingMessage{ ignore: true, } evt.Portal.outgoingMessagesLock.Unlock() @@ -1017,12 +1028,14 @@ func (evt *MatrixMessage) AddPendingToIgnore(txnID networkid.TransactionID) { // // The provided function will be called when the message is encountered. func (evt *MatrixMessage) AddPendingToSave(message *database.Message, txnID networkid.TransactionID, handleEcho RemoteEchoHandler) { - evt.Portal.outgoingMessagesLock.Lock() - evt.Portal.outgoingMessages[txnID] = outgoingMessage{ + pending := &outgoingMessage{ db: evt.fillDBMessage(message), evt: evt.Event, handle: handleEcho, } + evt.Portal.outgoingMessagesLock.Lock() + evt.Portal.outgoingMessages[txnID] = pending + evt.pendingSaves = append(evt.pendingSaves, pending) evt.Portal.outgoingMessagesLock.Unlock() } @@ -1030,6 +1043,12 @@ func (evt *MatrixMessage) AddPendingToSave(message *database.Message, txnID netw // This should only be called if sending the message fails. func (evt *MatrixMessage) RemovePending(txnID networkid.TransactionID) { evt.Portal.outgoingMessagesLock.Lock() + pendingSave := evt.Portal.outgoingMessages[txnID] + if pendingSave != nil { + evt.pendingSaves = slices.DeleteFunc(evt.pendingSaves, func(save *outgoingMessage) bool { + return save == pendingSave + }) + } delete(evt.Portal.outgoingMessages, txnID) evt.Portal.outgoingMessagesLock.Unlock() } @@ -1063,6 +1082,35 @@ func (evt *MatrixMessage) fillDBMessage(message *database.Message) *database.Mes return message } +func (portal *Portal) pendingMessageTimeoutLoop(ctx context.Context, cfg *OutgoingTimeoutConfig) { + ticker := time.NewTicker(cfg.CheckInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + portal.checkPendingMessages(ctx, cfg) + case <-ctx.Done(): + return + } + } +} + +func (portal *Portal) checkPendingMessages(ctx context.Context, cfg *OutgoingTimeoutConfig) { + portal.outgoingMessagesLock.Lock() + defer portal.outgoingMessagesLock.Unlock() + for _, msg := range portal.outgoingMessages { + if msg.evt != nil && !msg.timeouted { + if cfg.NoEchoTimeout > 0 && !msg.ackedAt.IsZero() && time.Since(msg.ackedAt) > cfg.NoEchoTimeout { + msg.timeouted = true + portal.sendErrorStatus(ctx, msg.evt, ErrRemoteEchoTimeout.WithMessage(cfg.NoEchoMessage)) + } else if cfg.NoAckTimeout > 0 && time.Since(msg.db.Timestamp) > cfg.NoAckTimeout { + msg.timeouted = true + portal.sendErrorStatus(ctx, msg.evt, ErrRemoteAckTimeout.WithMessage(cfg.NoAckMessage)) + } + } + } +} + func (portal *Portal) handleMatrixEdit(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, content *event.MessageEventContent, caps *event.RoomFeatures) { log := zerolog.Ctx(ctx) editTargetID := content.RelatesTo.GetReplaceID() @@ -3881,6 +3929,10 @@ func (portal *Portal) unlockedDeleteCache() { if portal.MXID != "" { delete(portal.Bridge.portalsByMXID, portal.MXID) } + if portal.events != nil { + // TODO there's a small risk of this racing with a queueEvent call + close(portal.events) + } } func (portal *Portal) Save(ctx context.Context) error { From 4a0aed30e85030bb39650482c15ba971f5b0533e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 20 Mar 2025 15:34:40 +0200 Subject: [PATCH 003/581] brdigev2/bridgestate: add option to send status updates to management room --- bridgev2/bridgeconfig/config.go | 1 + bridgev2/bridgeconfig/upgrade.go | 1 + bridgev2/bridgestate.go | 78 +++++++++++++++++----- bridgev2/matrix/mxmain/example-config.yaml | 4 ++ bridgev2/status/bridgestate.go | 26 ++------ 5 files changed, 72 insertions(+), 38 deletions(-) diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index 156fb772..937d9441 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -65,6 +65,7 @@ type BridgeConfig struct { SplitPortals bool `yaml:"split_portals"` ResendBridgeInfo bool `yaml:"resend_bridge_info"` NoBridgeInfoStateKey bool `yaml:"no_bridge_info_state_key"` + BridgeStatusNotices string `yaml:"bridge_status_notices"` BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"` BridgeNotices bool `yaml:"bridge_notices"` TagOnlyOnCreate bool `yaml:"tag_only_on_create"` diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index 07477ef1..95370681 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -31,6 +31,7 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.Bool, "bridge", "split_portals") helper.Copy(up.Bool, "bridge", "resend_bridge_info") helper.Copy(up.Bool, "bridge", "no_bridge_info_state_key") + helper.Copy(up.Str|up.Null, "bridge", "bridge_status_notices") helper.Copy(up.Bool, "bridge", "bridge_matrix_leave") helper.Copy(up.Bool, "bridge", "bridge_notices") helper.Copy(up.Bool, "bridge", "tag_only_on_create") diff --git a/bridgev2/bridgestate.go b/bridgev2/bridgestate.go index 7863dcba..61f988ad 100644 --- a/bridgev2/bridgestate.go +++ b/bridgev2/bridgestate.go @@ -8,20 +8,24 @@ package bridgev2 import ( "context" + "fmt" "runtime/debug" "time" "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" ) type BridgeStateQueue struct { prevUnsent *status.BridgeState prevSent *status.BridgeState + errorSent bool ch chan status.BridgeState bridge *Bridge - user status.StandaloneCustomBridgeStateFiller + login *UserLogin } func (br *Bridge) SendGlobalBridgeState(state status.BridgeState) { @@ -41,11 +45,11 @@ func (br *Bridge) SendGlobalBridgeState(state status.BridgeState) { } } -func (br *Bridge) NewBridgeStateQueue(user status.StandaloneCustomBridgeStateFiller) *BridgeStateQueue { +func (br *Bridge) NewBridgeStateQueue(login *UserLogin) *BridgeStateQueue { bsq := &BridgeStateQueue{ ch: make(chan status.BridgeState, 10), bridge: br, - user: user, + login: login, } go bsq.loop() return bsq @@ -59,7 +63,7 @@ func (bsq *BridgeStateQueue) loop() { defer func() { err := recover() if err != nil { - bsq.bridge.Log.Error(). + bsq.login.Log.Error(). Bytes(zerolog.ErrorStackFieldName, debug.Stack()). Any(zerolog.ErrorFieldName, err). Msg("Panic in bridge state loop") @@ -70,22 +74,62 @@ func (bsq *BridgeStateQueue) loop() { } } +func (bsq *BridgeStateQueue) sendNotice(ctx context.Context, state status.BridgeState) { + noticeConfig := bsq.bridge.Config.BridgeStatusNotices + isError := state.StateEvent == status.StateBadCredentials || state.StateEvent == status.StateUnknownError + sendNotice := noticeConfig == "all" || (noticeConfig == "errors" && + (isError || (bsq.errorSent && state.StateEvent == status.StateConnected))) + if !sendNotice { + return + } + managementRoom, err := bsq.login.User.GetManagementRoom(ctx) + if err != nil { + bsq.login.Log.Err(err).Msg("Failed to get management room") + return + } + message := fmt.Sprintf("State update for %s: `%s`", bsq.login.RemoteName, state.StateEvent) + if state.Error != "" { + message += fmt.Sprintf(" (`%s`)", state.Error) + } + if state.Message != "" { + message += fmt.Sprintf(": %s", state.Message) + } + content := format.RenderMarkdown(message, true, false) + if !isError { + content.MsgType = event.MsgNotice + } + _, err = bsq.bridge.Bot.SendMessage(ctx, managementRoom, event.EventMessage, &event.Content{ + Parsed: content, + Raw: map[string]any{ + "fi.mau.bridge_state": state, + }, + }, nil) + if err != nil { + bsq.login.Log.Err(err).Msg("Failed to send bridge state notice") + } else { + bsq.errorSent = isError + } +} + func (bsq *BridgeStateQueue) immediateSendBridgeState(state status.BridgeState) { + if bsq.prevSent != nil && bsq.prevSent.ShouldDeduplicate(&state) { + bsq.login.Log.Debug(). + Str("state_event", string(state.StateEvent)). + Msg("Not sending bridge state as it's a duplicate") + return + } + + ctx := bsq.login.Log.WithContext(context.Background()) + bsq.sendNotice(ctx, state) + retryIn := 2 for { - if bsq.prevSent != nil && bsq.prevSent.ShouldDeduplicate(&state) { - bsq.bridge.Log.Debug(). - Str("state_event", string(state.StateEvent)). - Msg("Not sending bridge state as it's a duplicate") - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) err := bsq.bridge.Matrix.SendBridgeStatus(ctx, &state) cancel() if err != nil { - bsq.bridge.Log.Warn().Err(err). + bsq.login.Log.Warn().Err(err). Int("retry_in_seconds", retryIn). Msg("Failed to update bridge state") time.Sleep(time.Duration(retryIn) * time.Second) @@ -95,7 +139,7 @@ func (bsq *BridgeStateQueue) immediateSendBridgeState(state status.BridgeState) } } else { bsq.prevSent = &state - bsq.bridge.Log.Debug(). + bsq.login.Log.Debug(). Any("bridge_state", state). Msg("Sent new bridge state") return @@ -108,11 +152,11 @@ func (bsq *BridgeStateQueue) Send(state status.BridgeState) { return } - state = state.Fill(bsq.user) + state = state.Fill(bsq.login) bsq.prevUnsent = &state if len(bsq.ch) >= 8 { - bsq.bridge.Log.Warn().Msg("Bridge state queue is nearly full, discarding an item") + bsq.login.Log.Warn().Msg("Bridge state queue is nearly full, discarding an item") select { case <-bsq.ch: default: @@ -121,7 +165,7 @@ func (bsq *BridgeStateQueue) Send(state status.BridgeState) { select { case bsq.ch <- state: default: - bsq.bridge.Log.Error().Msg("Bridge state queue is full, dropped new state") + bsq.login.Log.Error().Msg("Bridge state queue is full, dropped new state") } } diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 86838ff1..1d4e18cf 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -21,6 +21,10 @@ bridge: # Should `m.bridge` events be sent without a state key? # By default, the bridge uses a unique key that won't conflict with other bridges. no_bridge_info_state_key: false + # Should bridge connection status be sent to the management room as `m.notice` events? + # These contain the same data that can be posted to an external HTTP server using homeserver -> status_endpoint. + # Allowed values: none, errors, all + bridge_status_notices: errors # Should leaving Matrix rooms be bridged as leaving groups on the remote network? bridge_matrix_leave: false diff --git a/bridgev2/status/bridgestate.go b/bridgev2/status/bridgestate.go index 73410df6..cb862110 100644 --- a/bridgev2/status/bridgestate.go +++ b/bridgev2/status/bridgestate.go @@ -12,6 +12,7 @@ import ( "encoding/json" "fmt" "io" + "maps" "net/http" "reflect" "time" @@ -19,7 +20,6 @@ import ( "github.com/tidwall/sjson" "go.mau.fi/util/jsontime" "go.mau.fi/util/ptr" - "golang.org/x/exp/maps" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" @@ -125,31 +125,15 @@ type GlobalBridgeState struct { } type BridgeStateFiller interface { - GetMXID() id.UserID - GetRemoteID() string - GetRemoteName() string -} - -type StandaloneCustomBridgeStateFiller interface { FillBridgeState(BridgeState) BridgeState } -type CustomBridgeStateFiller interface { - BridgeStateFiller - StandaloneCustomBridgeStateFiller -} +// Deprecated: use BridgeStateFiller instead +type StandaloneCustomBridgeStateFiller = BridgeStateFiller -func (pong BridgeState) Fill(user any) BridgeState { +func (pong BridgeState) Fill(user BridgeStateFiller) BridgeState { if user != nil { - if std, ok := user.(BridgeStateFiller); ok { - pong.UserID = std.GetMXID() - pong.RemoteID = std.GetRemoteID() - pong.RemoteName = std.GetRemoteName() - } - - if custom, ok := user.(StandaloneCustomBridgeStateFiller); ok { - pong = custom.FillBridgeState(pong) - } + pong = user.FillBridgeState(pong) } pong.Timestamp = jsontime.UnixNow() From 06f200da0d1099ec9b59c50ceb754380d8a16674 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 20 Mar 2025 15:39:36 +0200 Subject: [PATCH 004/581] bridgev2: clear management room on leave. Fixes #355 --- bridgev2/queue.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bridgev2/queue.go b/bridgev2/queue.go index 38895953..2981bdce 100644 --- a/bridgev2/queue.go +++ b/bridgev2/queue.go @@ -119,6 +119,17 @@ func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) { if evt.Type == event.StateMember && evt.GetStateKey() == br.Bot.GetMXID().String() && evt.Content.AsMember().Membership == event.MembershipInvite && sender != nil { br.handleBotInvite(ctx, evt, sender) return + } else if sender != nil && evt.RoomID == sender.ManagementRoom { + if evt.Type == event.StateMember && evt.Content.AsMember().Membership == event.MembershipLeave && (evt.GetStateKey() == br.Bot.GetMXID().String() || evt.GetStateKey() == sender.MXID.String()) { + sender.ManagementRoom = "" + err := br.DB.User.Update(ctx, sender.User) + if err != nil { + log.Err(err).Msg("Failed to clear user's management room in database") + } else { + log.Debug().Msg("Cleared user's management room due to leave event") + } + } + return } portal, err := br.GetPortalByMXID(ctx, evt.RoomID) if err != nil { From d3ca9472cb13e9b36d66885fd4176a89faea08f1 Mon Sep 17 00:00:00 2001 From: SpiritCroc Date: Mon, 31 Mar 2025 09:53:01 +0200 Subject: [PATCH 005/581] event: add Beeper transcription event definitions (#364) --- event/beeper.go | 12 ++++++++++++ event/content.go | 1 + event/relations.go | 9 +++++---- event/type.go | 4 +++- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/event/beeper.go b/event/beeper.go index 74b44a09..19c6253e 100644 --- a/event/beeper.go +++ b/event/beeper.go @@ -57,6 +57,18 @@ type BeeperMessageStatusEventContent struct { DeliveredToUsers *[]id.UserID `json:"delivered_to_users,omitempty"` } +type BeeperRelatesTo struct { + EventID id.EventID `json:"event_id,omitempty"` + RoomID id.RoomID `json:"room_id,omitempty"` + Type RelationType `json:"rel_type,omitempty"` +} + +type BeeperTranscriptionEventContent struct { + Text []ExtensibleText `json:"m.text,omitempty"` + Model string `json:"com.beeper.transcription.model,omitempty"` + RelatesTo BeeperRelatesTo `json:"com.beeper.relates_to,omitempty"` +} + type BeeperRetryMetadata struct { OriginalEventID id.EventID `json:"original_event_id"` RetryCount int `json:"retry_count"` diff --git a/event/content.go b/event/content.go index b8e130db..2347898e 100644 --- a/event/content.go +++ b/event/content.go @@ -60,6 +60,7 @@ var TypeMap = map[Type]reflect.Type{ EventUnstablePollResponse: reflect.TypeOf(PollResponseEventContent{}), BeeperMessageStatus: reflect.TypeOf(BeeperMessageStatusEventContent{}), + BeeperTranscription: reflect.TypeOf(BeeperTranscriptionEventContent{}), AccountDataRoomTags: reflect.TypeOf(TagEventContent{}), AccountDataDirectChats: reflect.TypeOf(DirectChatsEventContent{}), diff --git a/event/relations.go b/event/relations.go index ea40cc06..30cf6c20 100644 --- a/event/relations.go +++ b/event/relations.go @@ -15,10 +15,11 @@ import ( type RelationType string const ( - RelReplace RelationType = "m.replace" - RelReference RelationType = "m.reference" - RelAnnotation RelationType = "m.annotation" - RelThread RelationType = "m.thread" + RelReplace RelationType = "m.replace" + RelReference RelationType = "m.reference" + RelAnnotation RelationType = "m.annotation" + RelThread RelationType = "m.thread" + RelBeeperTranscription RelationType = "com.beeper.transcription" ) type RelatesTo struct { diff --git a/event/type.go b/event/type.go index 41d7c47b..591d598d 100644 --- a/event/type.go +++ b/event/type.go @@ -126,7 +126,8 @@ func (et *Type) GuessClass() TypeClass { InRoomVerificationStart.Type, InRoomVerificationReady.Type, InRoomVerificationAccept.Type, InRoomVerificationKey.Type, InRoomVerificationMAC.Type, InRoomVerificationCancel.Type, CallInvite.Type, CallCandidates.Type, CallAnswer.Type, CallReject.Type, CallSelectAnswer.Type, - CallNegotiate.Type, CallHangup.Type, BeeperMessageStatus.Type, EventUnstablePollStart.Type, EventUnstablePollResponse.Type: + CallNegotiate.Type, CallHangup.Type, BeeperMessageStatus.Type, EventUnstablePollStart.Type, EventUnstablePollResponse.Type, + BeeperTranscription.Type: return MessageEventType case ToDeviceRoomKey.Type, ToDeviceRoomKeyRequest.Type, ToDeviceForwardedRoomKey.Type, ToDeviceRoomKeyWithheld.Type, ToDeviceBeeperRoomKeyAck.Type: @@ -233,6 +234,7 @@ var ( CallHangup = Type{"m.call.hangup", MessageEventType} BeeperMessageStatus = Type{"com.beeper.message_send_status", MessageEventType} + BeeperTranscription = Type{"com.beeper.transcription", MessageEventType} EventUnstablePollStart = Type{Type: "org.matrix.msc3381.poll.start", Class: MessageEventType} EventUnstablePollResponse = Type{Type: "org.matrix.msc3381.poll.response", Class: MessageEventType} From 93b9509135e7533d9ef50fa78c5af468c2419acc Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 2 Apr 2025 12:03:07 +0300 Subject: [PATCH 006/581] bridgev2/portal: send typing stop after message --- bridgev2/portal.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 5e4328e7..d8262d0a 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -20,6 +20,7 @@ import ( "github.com/rs/zerolog" "go.mau.fi/util/exfmt" "go.mau.fi/util/exslices" + "go.mau.fi/util/exsync" "go.mau.fi/util/ptr" "go.mau.fi/util/variationselector" "golang.org/x/exp/maps" @@ -77,6 +78,7 @@ type Portal struct { currentlyTyping []id.UserID currentlyTypingLogins map[id.UserID]*UserLogin currentlyTypingLock sync.Mutex + currentlyTypingGhosts *exsync.Set[id.UserID] outgoingMessages map[networkid.TransactionID]*outgoingMessage outgoingMessagesLock sync.Mutex @@ -115,6 +117,7 @@ func (br *Bridge) loadPortal(ctx context.Context, dbPortal *database.Portal, que Bridge: br, currentlyTypingLogins: make(map[id.UserID]*UserLogin), + currentlyTypingGhosts: exsync.NewSet[id.UserID](), outgoingMessages: make(map[networkid.TransactionID]*outgoingMessage), } br.portalsByKey[portal.PortalKey] = portal @@ -2099,6 +2102,9 @@ func (portal *Portal) handleRemoteMessage(ctx context.Context, source *UserLogin return } portal.sendConvertedMessage(ctx, evt.GetID(), intent, evt.GetSender().Sender, converted, ts, getStreamOrder(evt), nil) + if portal.currentlyTypingGhosts.Pop(intent.GetMXID()) { + intent.MarkTyping(ctx, portal.MXID, TypingTypeText, 0) + } } func (portal *Portal) sendRemoteErrorNotice(ctx context.Context, intent MatrixAPI, err error, ts time.Time, evtTypeName string) { @@ -2161,6 +2167,9 @@ func (portal *Portal) handleRemoteEdit(ctx context.Context, source *UserLogin, e return } portal.sendConvertedEdit(ctx, existing[0].ID, evt.GetSender().Sender, converted, intent, ts, getStreamOrder(evt)) + if portal.currentlyTypingGhosts.Pop(intent.GetMXID()) { + intent.MarkTyping(ctx, portal.MXID, TypingTypeText, 0) + } } func (portal *Portal) sendConvertedEdit( @@ -2709,10 +2718,16 @@ func (portal *Portal) handleRemoteTyping(ctx context.Context, source *UserLogin, typingType = typedEvt.GetTypingType() } intent := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventTyping) - err := intent.MarkTyping(ctx, portal.MXID, typingType, evt.GetTimeout()) + timeout := evt.GetTimeout() + err := intent.MarkTyping(ctx, portal.MXID, typingType, timeout) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to bridge typing event") } + if timeout == 0 { + portal.currentlyTypingGhosts.Remove(intent.GetMXID()) + } else { + portal.currentlyTypingGhosts.Add(intent.GetMXID()) + } } func (portal *Portal) handleRemoteChatInfoChange(ctx context.Context, source *UserLogin, evt RemoteChatInfoChange) { From 74a02366d778385dd8a75bea6a6b5325bf4481f7 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Wed, 2 Apr 2025 09:10:35 -0600 Subject: [PATCH 007/581] bridgev2/legacymigrate: add post-migrate hook Signed-off-by: Sumner Evans --- bridgev2/matrix/mxmain/legacymigrate.go | 15 +++++++++++---- bridgev2/matrix/mxmain/main.go | 4 ++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/bridgev2/matrix/mxmain/legacymigrate.go b/bridgev2/matrix/mxmain/legacymigrate.go index d33dd8cd..8b25e210 100644 --- a/bridgev2/matrix/mxmain/legacymigrate.go +++ b/bridgev2/matrix/mxmain/legacymigrate.go @@ -226,11 +226,18 @@ func (br *BridgeMain) PostMigrate(ctx context.Context) error { Object("portal_key", portal.PortalKey). Str("room_type", string(portal.RoomType)). Msg("Migrating portal") - switch portal.RoomType { - case database.RoomTypeDM: - err = br.postMigrateDMPortal(ctx, portal) + if br.PostMigratePortal != nil { + err = br.PostMigratePortal(ctx, portal) if err != nil { - return fmt.Errorf("failed to update DM portal %s: %w", portal.MXID, err) + return fmt.Errorf("failed to run post-migrate portal hook for %s: %w", portal.MXID, err) + } + } else { + switch portal.RoomType { + case database.RoomTypeDM: + err = br.postMigrateDMPortal(ctx, portal) + if err != nil { + return fmt.Errorf("failed to update DM portal %s: %w", portal.MXID, err) + } } } _, err = br.Matrix.Bot.SendStateEvent(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.ElementFunctionalMembersContent{ diff --git a/bridgev2/matrix/mxmain/main.go b/bridgev2/matrix/mxmain/main.go index dab0b914..63334ba5 100644 --- a/bridgev2/matrix/mxmain/main.go +++ b/bridgev2/matrix/mxmain/main.go @@ -67,6 +67,10 @@ type BridgeMain struct { PostInit func() PostStart func() + // PostMigratePortal is a function that will be called during a legacy + // migration for each portal. + PostMigratePortal func(context.Context, *bridgev2.Portal) error + // Connector is the network connector for the bridge. Connector bridgev2.NetworkConnector From 49648897875fe3e44cee08661eba69958108c642 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 4 Apr 2025 08:37:49 -0600 Subject: [PATCH 008/581] bridgev2/legacymigrate: don't error if post migrate hook fails Signed-off-by: Sumner Evans --- bridgev2/matrix/mxmain/legacymigrate.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/bridgev2/matrix/mxmain/legacymigrate.go b/bridgev2/matrix/mxmain/legacymigrate.go index 8b25e210..c8eb820b 100644 --- a/bridgev2/matrix/mxmain/legacymigrate.go +++ b/bridgev2/matrix/mxmain/legacymigrate.go @@ -208,28 +208,31 @@ func (br *BridgeMain) postMigrateDMPortal(ctx context.Context, portal *bridgev2. } func (br *BridgeMain) PostMigrate(ctx context.Context) error { + log := br.Log.With().Str("action", "post-migrate").Logger() wasMigrated, err := br.DB.TableExists(ctx, "database_was_migrated") if err != nil { return fmt.Errorf("failed to check if database_was_migrated table exists: %w", err) } else if !wasMigrated { return nil } - zerolog.Ctx(ctx).Info().Msg("Doing post-migration updates to Matrix rooms") + log.Info().Msg("Doing post-migration updates to Matrix rooms") portals, err := br.Bridge.GetAllPortalsWithMXID(ctx) if err != nil { return fmt.Errorf("failed to get all portals: %w", err) } for _, portal := range portals { - zerolog.Ctx(ctx).Debug(). + log := log.With(). Stringer("room_id", portal.MXID). Object("portal_key", portal.PortalKey). Str("room_type", string(portal.RoomType)). - Msg("Migrating portal") + Logger() + log.Debug().Msg("Migrating portal") if br.PostMigratePortal != nil { err = br.PostMigratePortal(ctx, portal) if err != nil { - return fmt.Errorf("failed to run post-migrate portal hook for %s: %w", portal.MXID, err) + log.Err(err).Msg("Failed to run post-migrate portal hook") + continue } } else { switch portal.RoomType { @@ -244,7 +247,7 @@ func (br *BridgeMain) PostMigrate(ctx context.Context) error { ServiceMembers: []id.UserID{br.Matrix.Bot.UserID}, }) if err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Stringer("room_id", portal.MXID).Msg("Failed to set service members") + log.Warn().Err(err).Stringer("room_id", portal.MXID).Msg("Failed to set service members") } } @@ -252,6 +255,6 @@ func (br *BridgeMain) PostMigrate(ctx context.Context) error { if err != nil { return fmt.Errorf("failed to drop database_was_migrated table: %w", err) } - zerolog.Ctx(ctx).Info().Msg("Post-migration updates complete") + log.Info().Msg("Post-migration updates complete") return nil } From e675a3c09c3894d994f8d6fdcdcaed024de5d33a Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sun, 6 Apr 2025 00:41:16 +0100 Subject: [PATCH 009/581] client: add `allowed_room_ids` to room summary response (#367) --- responses.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/responses.go b/responses.go index 93a780ef..ee7f4703 100644 --- a/responses.go +++ b/responses.go @@ -221,9 +221,10 @@ type RespMutualRooms struct { type RespRoomSummary struct { PublicRoomInfo - Membership event.Membership `json:"membership,omitempty"` - RoomVersion event.RoomVersion `json:"room_version,omitempty"` - Encryption id.Algorithm `json:"encryption,omitempty"` + Membership event.Membership `json:"membership,omitempty"` + RoomVersion event.RoomVersion `json:"room_version,omitempty"` + Encryption id.Algorithm `json:"encryption,omitempty"` + AllowedRoomIDs []id.RoomID `json:"allowed_room_ids,omitempty"` UnstableRoomVersion event.RoomVersion `json:"im.nheko.summary.room_version,omitempty"` UnstableRoomVersionOld event.RoomVersion `json:"im.nheko.summary.version,omitempty"` From 0fcb552c27a8898a8a178c92a504becad596d264 Mon Sep 17 00:00:00 2001 From: Adam Van Ymeren Date: Wed, 9 Apr 2025 07:46:03 -0700 Subject: [PATCH 010/581] bridgev2: make Bridge.Start() take a context (#368) --- bridgev2/bridge.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index bdc8480d..aef86196 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -107,8 +107,8 @@ func (e DBUpgradeError) Unwrap() error { return e.Err } -func (br *Bridge) Start() error { - ctx := br.Log.WithContext(context.Background()) +func (br *Bridge) Start(ctx context.Context) error { + ctx = br.Log.WithContext(ctx) err := br.StartConnectors(ctx) if err != nil { return err From 826089e020fb838951df813138d89ab47b07b6b1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 11 Apr 2025 17:08:52 +0300 Subject: [PATCH 011/581] id: make user id parsing more efficient --- id/userid.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/id/userid.go b/id/userid.go index 1e1f3b29..59136013 100644 --- a/id/userid.go +++ b/id/userid.go @@ -43,10 +43,10 @@ func ParseCommonIdentifier[Stringish ~string](identifier Stringish) (sigil byte, } sigil = identifier[0] strIdentifier := string(identifier) - if strings.ContainsRune(strIdentifier, ':') { - parts := strings.SplitN(strIdentifier, ":", 2) - localpart = parts[0][1:] - homeserver = parts[1] + colonIdx := strings.IndexByte(strIdentifier, ':') + if colonIdx > 0 { + localpart = strIdentifier[1:colonIdx] + homeserver = strIdentifier[colonIdx+1:] } else { localpart = strIdentifier[1:] } From cf801729af425fa373fa1a2ff62a4fd470fce7f9 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 13 Apr 2025 02:59:13 +0300 Subject: [PATCH 012/581] bridgev2/commands: implement MarkRead --- bridgev2/commands/event.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bridgev2/commands/event.go b/bridgev2/commands/event.go index 78ed94bb..88ba9698 100644 --- a/bridgev2/commands/event.go +++ b/bridgev2/commands/event.go @@ -10,6 +10,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/rs/zerolog" @@ -92,9 +93,8 @@ func (ce *Event) Redact(req ...mautrix.ReqRedact) { // MarkRead marks the command event as read. func (ce *Event) MarkRead() { - // TODO - //err := ce.Bot.SendReceipt(ce.Ctx, ce.RoomID, ce.EventID, event.ReceiptTypeRead, nil) - //if err != nil { - // ce.Log.Err(err).Msg("Failed to mark command as read") - //} + err := ce.Bot.MarkRead(ce.Ctx, ce.RoomID, ce.EventID, time.Now()) + if err != nil { + ce.Log.Err(err).Msg("Failed to mark command as read") + } } From 0f06c9ce31cd3043d12bc7af12ab40f63496efaf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 13 Apr 2025 02:59:26 +0300 Subject: [PATCH 013/581] event/content: add SetThread method --- event/relations.go | 4 ++++ event/reply.go | 24 +++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/event/relations.go b/event/relations.go index 30cf6c20..e855a7e6 100644 --- a/event/relations.go +++ b/event/relations.go @@ -101,6 +101,10 @@ func (rel *RelatesTo) SetReplace(mxid id.EventID) *RelatesTo { } func (rel *RelatesTo) SetReplyTo(mxid id.EventID) *RelatesTo { + if rel.Type != RelThread { + rel.Type = "" + rel.EventID = "" + } rel.InReplyTo = &InReplyTo{EventID: mxid} rel.IsFallingBack = false return rel diff --git a/event/reply.go b/event/reply.go index 1a88c619..9ae1c110 100644 --- a/event/reply.go +++ b/event/reply.go @@ -47,5 +47,27 @@ func (content *MessageEventContent) GetReplyTo() id.EventID { } func (content *MessageEventContent) SetReply(inReplyTo *Event) { - content.RelatesTo = (&RelatesTo{}).SetReplyTo(inReplyTo.ID) + if content.RelatesTo == nil { + content.RelatesTo = &RelatesTo{} + } + content.RelatesTo.SetReplyTo(inReplyTo.ID) + if content.Mentions == nil { + content.Mentions = &Mentions{} + } + content.Mentions.Add(inReplyTo.Sender) +} + +func (content *MessageEventContent) SetThread(inReplyTo *Event) { + root := inReplyTo.ID + relatable, ok := inReplyTo.Content.Parsed.(Relatable) + if ok { + targetRoot := relatable.OptionalGetRelatesTo().GetThreadParent() + if targetRoot != "" { + root = targetRoot + } + } + if content.RelatesTo == nil { + content.RelatesTo = &RelatesTo{} + } + content.RelatesTo.SetThread(root, inReplyTo.ID) } From 7c1b0c5968943efe5f6f92a7bd36a174316ba5ed Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 13 Apr 2025 02:59:35 +0300 Subject: [PATCH 014/581] format: add EscapeMarkdown --- format/markdown.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/format/markdown.go b/format/markdown.go index d099ba00..a1c93162 100644 --- a/format/markdown.go +++ b/format/markdown.go @@ -8,6 +8,7 @@ package format import ( "fmt" + "regexp" "strings" "github.com/yuin/goldmark" @@ -39,6 +40,15 @@ func UnwrapSingleParagraph(html string) string { return html } +var mdEscapeRegex = regexp.MustCompile("([\\\\`*_[\\]])") + +func EscapeMarkdown(text string) string { + text = mdEscapeRegex.ReplaceAllString(text, "\\$1") + text = strings.ReplaceAll(text, ">", ">") + text = strings.ReplaceAll(text, "<", "<") + return text +} + func RenderMarkdownCustom(text string, renderer goldmark.Markdown) event.MessageEventContent { var buf strings.Builder err := renderer.Convert([]byte(text), &buf) From 56e2adbf831402f5f8530871972b69604b91e127 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 13 Apr 2025 02:59:49 +0300 Subject: [PATCH 015/581] commands: add generic command processing framework for bots --- commands/event.go | 124 ++++++++++++++++++++++++++++++++ commands/prevalidate.go | 86 ++++++++++++++++++++++ commands/processor.go | 156 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 366 insertions(+) create mode 100644 commands/event.go create mode 100644 commands/prevalidate.go create mode 100644 commands/processor.go diff --git a/commands/event.go b/commands/event.go new file mode 100644 index 00000000..baf9ecda --- /dev/null +++ b/commands/event.go @@ -0,0 +1,124 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "context" + "fmt" + "strings" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" +) + +// Event contains the data of a single command event. +// It also provides some helper methods for responding to the command. +type Event[MetaType any] struct { + *event.Event + // RawInput is the entire message before splitting into command and arguments. + RawInput string + // Command is the lowercased first word of the message. + Command string + // Args are the rest of the message split by whitespace ([strings.Fields]). + Args []string + // RawArgs is the same as args, but without the splitting by whitespace. + RawArgs string + + Ctx context.Context + Proc *Processor[MetaType] + Handler *Handler[MetaType] + Meta MetaType +} + +var IDHTMLParser = &format.HTMLParser{ + PillConverter: func(displayname, mxid, eventID string, ctx format.Context) string { + if len(mxid) == 0 { + return displayname + } + if eventID != "" { + return fmt.Sprintf("https://matrix.to/#/%s/%s", mxid, eventID) + } + return mxid + }, + ItalicConverter: func(s string, c format.Context) string { + return fmt.Sprintf("*%s*", s) + }, + Newline: "\n", +} + +// ParseEvent parses a message into a command event struct. +func ParseEvent[MetaType any](ctx context.Context, evt *event.Event) *Event[MetaType] { + content := evt.Content.Parsed.(*event.MessageEventContent) + text := content.Body + if content.Format == event.FormatHTML { + text = IDHTMLParser.Parse(content.FormattedBody, format.NewContext(ctx)) + } + parts := strings.Fields(text) + return &Event[MetaType]{ + Event: evt, + RawInput: text, + Command: strings.ToLower(parts[0]), + Args: parts[1:], + RawArgs: strings.TrimLeft(strings.TrimPrefix(text, parts[0]), " "), + Ctx: ctx, + } +} + +type ReplyOpts struct { + AllowHTML bool + AllowMarkdown bool + Reply bool + Thread bool + SendAsText bool +} + +func (evt *Event[MetaType]) Reply(msg string, args ...any) { + if len(args) > 0 { + msg = fmt.Sprintf(msg, args...) + } + evt.Respond(msg, ReplyOpts{AllowMarkdown: true, Reply: true}) +} + +func (evt *Event[MetaType]) Respond(msg string, opts ReplyOpts) { + content := format.RenderMarkdown(msg, opts.AllowMarkdown, opts.AllowHTML) + if opts.Thread { + content.SetThread(evt.Event) + } + if opts.Reply { + content.SetReply(evt.Event) + } + if !opts.SendAsText { + content.MsgType = event.MsgNotice + } + _, err := evt.Proc.Client.SendMessageEvent(evt.Ctx, evt.RoomID, event.EventMessage, content) + if err != nil { + zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reply") + } +} + +func (evt *Event[MetaType]) React(emoji string) { + _, err := evt.Proc.Client.SendReaction(evt.Ctx, evt.RoomID, evt.ID, emoji) + if err != nil { + zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reaction") + } +} + +func (evt *Event[MetaType]) Redact() { + _, err := evt.Proc.Client.RedactEvent(evt.Ctx, evt.RoomID, evt.ID) + if err != nil { + zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to redact command") + } +} + +func (evt *Event[MetaType]) MarkRead() { + err := evt.Proc.Client.MarkRead(evt.Ctx, evt.RoomID, evt.ID) + if err != nil { + zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send read receipt") + } +} diff --git a/commands/prevalidate.go b/commands/prevalidate.go new file mode 100644 index 00000000..95bbcc97 --- /dev/null +++ b/commands/prevalidate.go @@ -0,0 +1,86 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "strings" +) + +// A PreValidator contains a function that takes an Event and returns true if the event should be processed further. +// +// The [PreValidator] field in [Processor] is called before the handler of the command is checked. +// It can be used to modify the command or arguments, or to skip the command entirely. +// +// The primary use case is removing a static command prefix, such as requiring all commands start with `!`. +type PreValidator[MetaType any] interface { + Validate(*Event[MetaType]) bool +} + +// FuncPreValidator is a simple function that implements the PreValidator interface. +type FuncPreValidator[MetaType any] func(*Event[MetaType]) bool + +func (f FuncPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool { + return f(ce) +} + +// AllPreValidator can be used to combine multiple PreValidators, such that +// all of them must return true for the command to be processed further. +type AllPreValidator[MetaType any] []PreValidator[MetaType] + +func (f AllPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool { + for _, validator := range f { + if !validator.Validate(ce) { + return false + } + } + return true +} + +// AnyPreValidator can be used to combine multiple PreValidators, such that +// at least one of them must return true for the command to be processed further. +type AnyPreValidator[MetaType any] []PreValidator[MetaType] + +func (f AnyPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool { + for _, validator := range f { + if validator.Validate(ce) { + return true + } + } + return false +} + +// ValidatePrefixCommand checks that the first word in the input is exactly the given string, +// and if so, removes it from the command and sets the command to the next word. +// +// For example, `ValidateCommandPrefix("!mybot")` would only allow commands in the form `!mybot foo`, +// where `foo` would be used to look up the command handler. +func ValidatePrefixCommand[MetaType any](prefix string) PreValidator[MetaType] { + return FuncPreValidator[MetaType](func(ce *Event[MetaType]) bool { + if ce.Command == prefix && len(ce.Args) > 0 { + ce.Command = strings.ToLower(ce.Args[0]) + ce.RawArgs = strings.TrimLeft(strings.TrimPrefix(ce.RawArgs, ce.Args[0]), " ") + ce.Args = ce.Args[1:] + return true + } + return false + }) +} + +// ValidatePrefixSubstring checks that the command starts with the given prefix, +// and if so, removes it from the command. +// +// For example, `ValidatePrefixSubstring("!")` would only allow commands in the form `!foo`, +// where `foo` would be used to look up the command handler. +func ValidatePrefixSubstring[MetaType any](prefix string) PreValidator[MetaType] { + return FuncPreValidator[MetaType](func(ce *Event[MetaType]) bool { + if strings.HasPrefix(ce.Command, prefix) { + ce.Command = ce.Command[len(prefix):] + return true + } + return false + }) +} diff --git a/commands/processor.go b/commands/processor.go new file mode 100644 index 00000000..d4a29690 --- /dev/null +++ b/commands/processor.go @@ -0,0 +1,156 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "context" + "fmt" + "runtime/debug" + "strings" + "sync" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" +) + +// Processor implements boilerplate code for splitting messages into a command and arguments, +// and finding the appropriate handler for the command. +type Processor[MetaType any] struct { + Client *mautrix.Client + LogArgs bool + PreValidator PreValidator[MetaType] + Meta MetaType + commands map[string]*Handler[MetaType] + aliases map[string]string + lock sync.RWMutex +} + +type Handler[MetaType any] struct { + Func func(ce *Event[MetaType]) + + // Name is the primary name of the command. It must be lowercase. + Name string + // Aliases are alternative names for the command. They must be lowercase. + Aliases []string +} + +// UnknownCommandName is the name of the fallback handler which is used if no other handler is found. +// If even the unknown command handler is not found, the command is ignored. +const UnknownCommandName = "unknown-command" + +func NewProcessor[MetaType any](cli *mautrix.Client) *Processor[MetaType] { + proc := &Processor[MetaType]{ + Client: cli, + PreValidator: ValidatePrefixSubstring[MetaType]("!"), + commands: make(map[string]*Handler[MetaType]), + aliases: make(map[string]string), + } + proc.Register(&Handler[MetaType]{ + Name: UnknownCommandName, + Func: func(ce *Event[MetaType]) { + ce.Reply("Unknown command") + }, + }) + return proc +} + +// Register registers the given command handlers. +func (proc *Processor[MetaType]) Register(handlers ...*Handler[MetaType]) { + proc.lock.Lock() + defer proc.lock.Unlock() + for _, handler := range handlers { + proc.registerOne(handler) + } +} + +func (proc *Processor[MetaType]) registerOne(handler *Handler[MetaType]) { + if strings.ToLower(handler.Name) != handler.Name { + panic(fmt.Errorf("command %q is not lowercase", handler.Name)) + } + proc.commands[handler.Name] = handler + for _, alias := range handler.Aliases { + if strings.ToLower(alias) != alias { + panic(fmt.Errorf("alias %q is not lowercase", alias)) + } + proc.aliases[alias] = handler.Name + } +} + +func (proc *Processor[MetaType]) Unregister(handlers ...*Handler[MetaType]) { + proc.lock.Lock() + defer proc.lock.Unlock() + for _, handler := range handlers { + proc.unregisterOne(handler) + } +} + +func (proc *Processor[MetaType]) unregisterOne(handler *Handler[MetaType]) { + delete(proc.commands, handler.Name) + for _, alias := range handler.Aliases { + if proc.aliases[alias] == handler.Name { + delete(proc.aliases, alias) + } + } +} + +func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) { + log := *zerolog.Ctx(ctx) + defer func() { + panicErr := recover() + if panicErr != nil { + logEvt := log.Error(). + Bytes(zerolog.ErrorStackFieldName, debug.Stack()) + if realErr, ok := panicErr.(error); ok { + logEvt = logEvt.Err(realErr) + } else { + logEvt = logEvt.Any(zerolog.ErrorFieldName, panicErr) + } + logEvt.Msg("Panic in command handler") + _, err := proc.Client.SendReaction(ctx, evt.RoomID, evt.ID, "💥") + if err != nil { + log.Err(err).Msg("Failed to send reaction after panic") + } + } + }() + parsed := ParseEvent[MetaType](ctx, evt) + if !proc.PreValidator.Validate(parsed) { + return + } + + realCommand := parsed.Command + proc.lock.RLock() + alias, ok := proc.aliases[realCommand] + if ok { + realCommand = alias + } + handler, ok := proc.commands[realCommand] + if !ok { + handler, ok = proc.commands[UnknownCommandName] + } + proc.lock.RUnlock() + if !ok { + return + } + + logWith := log.With(). + Str("command", realCommand). + Stringer("sender", evt.Sender). + Stringer("room_id", evt.RoomID) + if proc.LogArgs { + logWith = logWith.Strs("args", parsed.Args) + } + log = logWith.Logger() + parsed.Ctx = log.WithContext(ctx) + parsed.Handler = handler + parsed.Proc = proc + parsed.Meta = proc.Meta + + log.Debug().Msg("Processing command") + handler.Func(parsed) +} From 60e14d7dffa4ff82e3abaa2a5f38e4a87da0865e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 13 Apr 2025 22:45:51 +0300 Subject: [PATCH 016/581] format: parse task list html --- format/htmlparser.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/format/htmlparser.go b/format/htmlparser.go index 25543926..e50a578e 100644 --- a/format/htmlparser.go +++ b/format/htmlparser.go @@ -348,6 +348,8 @@ func (parser *HTMLParser) tagToString(node *html.Node, ctx Context) string { return parser.imgToString(node, ctx) case "hr": return parser.HorizontalLine + case "input": + return parser.inputToString(node, ctx) case "pre": var preStr, language string if node.FirstChild != nil && node.FirstChild.Type == html.ElementNode && node.FirstChild.Data == "code" { @@ -371,6 +373,17 @@ func (parser *HTMLParser) tagToString(node *html.Node, ctx Context) string { } } +func (parser *HTMLParser) inputToString(node *html.Node, ctx Context) string { + if len(ctx.TagStack) > 1 && ctx.TagStack[len(ctx.TagStack)-2] == "li" { + _, checked := parser.maybeGetAttribute(node, "checked") + if checked { + return "[x]" + } + return "[ ]" + } + return parser.nodeToTagAwareString(node.FirstChild, ctx) +} + func (parser *HTMLParser) singleNodeToString(node *html.Node, ctx Context) TaggedString { switch node.Type { case html.TextNode: From 99ff0c0964e4aeb9e58004193590e8ba782b3c78 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 14 Apr 2025 23:08:11 +0300 Subject: [PATCH 017/581] crypto/decryptmegolm: add option to ignore failing to parse content after decryption --- crypto/decryptmegolm.go | 2 +- crypto/machine.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crypto/decryptmegolm.go b/crypto/decryptmegolm.go index 11ab0f49..47279474 100644 --- a/crypto/decryptmegolm.go +++ b/crypto/decryptmegolm.go @@ -160,7 +160,7 @@ func (mach *OlmMachine) DecryptMegolmEvent(ctx context.Context, evt *event.Event if err != nil { if errors.Is(err, event.ErrUnsupportedContentType) { log.Warn().Msg("Unsupported event type in encrypted event") - } else { + } else if !mach.IgnorePostDecryptionParseErrors { return nil, fmt.Errorf("failed to parse content of megolm payload event: %w", err) } } diff --git a/crypto/machine.go b/crypto/machine.go index cacc73b6..e2af298b 100644 --- a/crypto/machine.go +++ b/crypto/machine.go @@ -44,6 +44,8 @@ type OlmMachine struct { // Don't mark outbound Olm sessions as shared for devices they were initially sent to. DisableSharedGroupSessionTracking bool + IgnorePostDecryptionParseErrors bool + SendKeysMinTrust id.TrustState ShareKeysMinTrust id.TrustState From 95a7e940d598ef333e231c766864bdc6a974b9ec Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 15 Apr 2025 11:06:02 +0300 Subject: [PATCH 018/581] bridgev2: don't keep cache lock while waiting for stop --- bridgev2/bridge.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index aef86196..db6371b2 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -327,8 +327,8 @@ func (br *Bridge) stop(isRunOnce bool) { for _, login := range br.userLoginsByID { go login.Disconnect(wg.Done) } - wg.Wait() br.cacheLock.Unlock() + wg.Wait() } if stopNet, ok := br.Network.(StoppableNetwork); ok { stopNet.Stop() From aae91f67b44dd29894e00f203ee78819237bb948 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 15 Apr 2025 12:11:18 +0300 Subject: [PATCH 019/581] bridgev2: split stopping matrix connector Also fix stopping the websocket in the default Matrix connector --- bridgev2/bridge.go | 3 ++- bridgev2/matrix/connector.go | 26 +++++++++++++++++++++++++- bridgev2/matrixinterface.go | 1 + 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index db6371b2..24ceaf6b 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -319,7 +319,7 @@ func (br *Bridge) Stop() { func (br *Bridge) stop(isRunOnce bool) { br.Log.Info().Msg("Shutting down bridge") br.stopBackfillQueue.Set() - br.Matrix.Stop() + br.Matrix.PreStop() if !isRunOnce { br.cacheLock.Lock() var wg sync.WaitGroup @@ -330,6 +330,7 @@ func (br *Bridge) stop(isRunOnce bool) { br.cacheLock.Unlock() wg.Wait() } + br.Matrix.Stop() if stopNet, ok := br.Network.(StoppableNetwork); ok { stopNet.Stop() } diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 4a930135..f56eece3 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -233,13 +233,37 @@ func (br *Connector) GetCapabilities() *bridgev2.MatrixCapabilities { return br.Capabilities } -func (br *Connector) Stop() { +func sendStopSignal(ch chan struct{}) { + if ch != nil { + select { + case ch <- struct{}{}: + default: + } + } +} + +func (br *Connector) PreStop() { br.stopping = true br.AS.Stop() + if stopWebsocket := br.AS.StopWebsocket; stopWebsocket != nil { + stopWebsocket(appservice.ErrWebsocketManualStop) + } + sendStopSignal(br.wsStopPinger) + sendStopSignal(br.wsShortCircuitReconnectBackoff) +} + +func (br *Connector) Stop() { br.EventProcessor.Stop() if br.Crypto != nil { br.Crypto.Stop() } + if wsStopChan := br.wsStopped; wsStopChan != nil { + select { + case <-wsStopChan: + case <-time.After(4 * time.Second): + br.Log.Warn().Msg("Timed out waiting for websocket to close") + } + } } var MinSpecVersion = mautrix.SpecV14 diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index 2665956c..4ccba353 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -31,6 +31,7 @@ type MatrixCapabilities struct { type MatrixConnector interface { Init(*Bridge) Start(ctx context.Context) error + PreStop() Stop() GetCapabilities() *MatrixCapabilities From 89b41900e49b308a6e37862e3a39f83646d0c787 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 15 Apr 2025 14:35:47 +0300 Subject: [PATCH 020/581] bridgev2/userlogin: stop using deprecated alias --- bridgev2/userlogin.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bridgev2/userlogin.go b/bridgev2/userlogin.go index d07acce5..bf8f3bc6 100644 --- a/bridgev2/userlogin.go +++ b/bridgev2/userlogin.go @@ -491,14 +491,14 @@ func (ul *UserLogin) MarkAsPreferredIn(ctx context.Context, portal *Portal) erro return ul.Bridge.DB.UserPortal.MarkAsPreferred(ctx, ul.UserLogin, portal.PortalKey) } -var _ status.StandaloneCustomBridgeStateFiller = (*UserLogin)(nil) +var _ status.BridgeStateFiller = (*UserLogin)(nil) func (ul *UserLogin) FillBridgeState(state status.BridgeState) status.BridgeState { state.UserID = ul.UserMXID state.RemoteID = string(ul.ID) state.RemoteName = ul.RemoteName state.RemoteProfile = &ul.RemoteProfile - filler, ok := ul.Client.(status.StandaloneCustomBridgeStateFiller) + filler, ok := ul.Client.(status.BridgeStateFiller) if ok { return filler.FillBridgeState(state) } From 7cb13f8fd3df1609eee379f71f69c92bd5f6addb Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 15 Apr 2025 14:43:47 +0300 Subject: [PATCH 021/581] bridgev2/status: add user_action field for bridge states --- bridgev2/bridgestate.go | 4 +++- bridgev2/status/bridgestate.go | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/bridgev2/bridgestate.go b/bridgev2/bridgestate.go index 61f988ad..148b522c 100644 --- a/bridgev2/bridgestate.go +++ b/bridgev2/bridgestate.go @@ -76,7 +76,9 @@ func (bsq *BridgeStateQueue) loop() { func (bsq *BridgeStateQueue) sendNotice(ctx context.Context, state status.BridgeState) { noticeConfig := bsq.bridge.Config.BridgeStatusNotices - isError := state.StateEvent == status.StateBadCredentials || state.StateEvent == status.StateUnknownError + isError := state.StateEvent == status.StateBadCredentials || + state.StateEvent == status.StateUnknownError || + state.UserAction == status.UserActionOpenNative sendNotice := noticeConfig == "all" || (noticeConfig == "errors" && (isError || (bsq.errorSent && state.StateEvent == status.StateConnected))) if !sendNotice { diff --git a/bridgev2/status/bridgestate.go b/bridgev2/status/bridgestate.go index cb862110..005a4f62 100644 --- a/bridgev2/status/bridgestate.go +++ b/bridgev2/status/bridgestate.go @@ -73,6 +73,13 @@ func (e BridgeStateEvent) IsValid() bool { } } +type BridgeStateUserAction string + +const ( + UserActionOpenNative BridgeStateUserAction = "OPEN_NATIVE" + UserActionRelogin BridgeStateUserAction = "RELOGIN" +) + type RemoteProfile struct { Phone string `json:"phone,omitempty"` Email string `json:"email,omitempty"` @@ -110,6 +117,8 @@ type BridgeState struct { Error BridgeStateErrorCode `json:"error,omitempty"` Message string `json:"message,omitempty"` + UserAction BridgeStateUserAction `json:"user_action,omitempty"` + UserID id.UserID `json:"user_id,omitempty"` RemoteID string `json:"remote_id,omitempty"` RemoteName string `json:"remote_name,omitempty"` @@ -192,6 +201,7 @@ func (pong *BridgeState) ShouldDeduplicate(newPong *BridgeState) bool { return pong != nil && pong.StateEvent == newPong.StateEvent && pong.RemoteName == newPong.RemoteName && + pong.UserAction == newPong.UserAction && ptr.Val(pong.RemoteProfile) == ptr.Val(newPong.RemoteProfile) && pong.Error == newPong.Error && maps.EqualFunc(pong.Info, newPong.Info, reflect.DeepEqual) && From 7165d3fa583444352ea770b5c003c93a7fc6989c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 16 Apr 2025 11:59:48 +0300 Subject: [PATCH 022/581] Bump version to v0.23.3 --- CHANGELOG.md | 16 ++++++++++++++++ go.mod | 22 +++++++++++----------- go.sum | 41 ++++++++++++++++++++--------------------- version.go | 2 +- 4 files changed, 48 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc91bb7..565d7f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## v0.23.3 (2025-04-16) + +* *(commands)* Added generic command processing framework for bots. +* *(client)* Added `allowed_room_ids` field to room summary responses + (thanks to [@nexy7574] in [#367]). +* *(bridgev2)* Added support for custom timeouts on outgoing messages which have + to wait for a remote echo. +* *(bridgev2)* Added automatic typing stop event if the ghost user had sent a + typing event before a message. +* *(bridgev2)* The saved management room is now cleared if the user leaves the + room, allowing the next DM to be automatically marked as a management room. +* *(bridge)* Removed deprecated fallback package for bridge statuses. + The status package is now only available under bridgev2. + +[#367]: https://github.com/mautrix/go/pull/367 + ## v0.23.2 (2025-03-16) * **Breaking change *(bridge)*** Removed legacy bridge module. diff --git a/go.mod b/go.mod index cea3c580..40564392 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module maunium.net/go/mautrix go 1.23.0 -toolchain go1.24.1 +toolchain go1.24.2 require ( filippo.io/edwards25519 v1.1.0 @@ -10,20 +10,20 @@ require ( github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.5.0 github.com/lib/pq v1.10.9 - github.com/mattn/go-sqlite3 v1.14.24 + github.com/mattn/go-sqlite3 v1.14.27 github.com/rs/xid v1.6.0 - github.com/rs/zerolog v1.33.0 + github.com/rs/zerolog v1.34.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 - github.com/yuin/goldmark v1.7.8 + github.com/yuin/goldmark v1.7.10 go.mau.fi/util v0.8.6 go.mau.fi/zeroconfig v0.1.3 - golang.org/x/crypto v0.36.0 - golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 - golang.org/x/net v0.37.0 - golang.org/x/sync v0.12.0 + golang.org/x/crypto v0.37.0 + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 + golang.org/x/net v0.39.0 + golang.org/x/sync v0.13.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 ) @@ -33,11 +33,11 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 // indirect + github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index eab4a1b8..cb64c875 100644 --- a/go.sum +++ b/go.sum @@ -26,18 +26,17 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= -github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs= -github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= +github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a h1:S+AGcmAESQ0pXCUNnRH7V+bOUIgkSX5qVt2cNKCrm0Q= +github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= -github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -52,28 +51,28 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= -github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.10 h1:S+LrtBjRmqMac2UdtB6yyCEJm+UILZ2fefI4p7o0QpI= +github.com/yuin/goldmark v1.7.10/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54= go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= diff --git a/version.go b/version.go index dba1a59e..2e670697 100644 --- a/version.go +++ b/version.go @@ -7,7 +7,7 @@ import ( "strings" ) -const Version = "v0.23.2" +const Version = "v0.23.3" var GoModVersion = "" var Commit = "" From d3d20cbcf20a0a16928701e6bd14e7e1af4fa049 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 21 Apr 2025 23:43:23 +0300 Subject: [PATCH 023/581] client: add context parameter for setting max retries --- client.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/client.go b/client.go index a08fbed2..fb310c62 100644 --- a/client.go +++ b/client.go @@ -316,6 +316,7 @@ type contextKey int const ( LogBodyContextKey contextKey = iota LogRequestIDContextKey + MaxAttemptsContextKey ) func (cli *Client) RequestStart(req *http.Request) { @@ -324,6 +325,14 @@ func (cli *Client) RequestStart(req *http.Request) { } } +// WithMaxRetries updates the context to set the maximum number of retries for any HTTP requests made with the context. +// +// 0 means the request will only be attempted once and will not be retried. +// Negative values will remove the override and fallback to the defaults. +func WithMaxRetries(ctx context.Context, maxRetries int) context.Context { + return context.WithValue(ctx, MaxAttemptsContextKey, maxRetries+1) +} + func (cli *Client) LogRequestDone(req *http.Request, resp *http.Response, err error, handlerErr error, contentLength int, duration time.Duration) { if cli == nil { return @@ -473,7 +482,12 @@ func (cli *Client) MakeFullRequestWithResp(ctx context.Context, params FullReque return nil, nil, ErrClientIsNil } if params.MaxAttempts == 0 { - params.MaxAttempts = 1 + cli.DefaultHTTPRetries + maxAttempts, ok := ctx.Value(MaxAttemptsContextKey).(int) + if ok && maxAttempts > 0 { + params.MaxAttempts = maxAttempts + } else { + params.MaxAttempts = 1 + cli.DefaultHTTPRetries + } } if params.BackoffDuration == 0 { if cli.DefaultHTTPBackoff == 0 { From 953334a0a03fbf2b6e16dccb674e625c2eb1dce5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 21 Apr 2025 23:43:44 +0300 Subject: [PATCH 024/581] client,federation: add wrappers for /publicRooms --- client.go | 6 ++++++ federation/client.go | 20 ++++++++++++++++++++ requests.go | 27 +++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/client.go b/client.go index fb310c62..5b5f083e 100644 --- a/client.go +++ b/client.go @@ -1986,6 +1986,12 @@ func (cli *Client) JoinedRooms(ctx context.Context) (resp *RespJoinedRooms, err return } +func (cli *Client) PublicRooms(ctx context.Context, req *ReqPublicRooms) (resp *RespPublicRooms, err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "publicRooms"}, req.Query()) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + // Hierarchy returns a list of rooms that are in the room's hierarchy. See https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv1roomsroomidhierarchy // // The hierarchy API is provided to walk the space tree and discover the rooms with their aesthetic details. works in a depth-first manner: diff --git a/federation/client.go b/federation/client.go index 098df095..7fc630b7 100644 --- a/federation/client.go +++ b/federation/client.go @@ -220,6 +220,26 @@ func (c *Client) Query(ctx context.Context, serverName, queryType string, queryP return } +func queryToValues(query map[string]string) url.Values { + values := make(url.Values, len(query)) + for k, v := range query { + values[k] = []string{v} + } + return values +} + +func (c *Client) PublicRooms(ctx context.Context, serverName string, req *mautrix.ReqPublicRooms) (resp *mautrix.RespPublicRooms, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: serverName, + Method: http.MethodGet, + Path: URLPath{"v1", "publicRooms"}, + Query: queryToValues(req.Query()), + Authenticate: true, + ResponseJSON: &resp, + }) + return +} + type RespOpenIDUserInfo struct { Sub id.UserID `json:"sub"` } diff --git a/requests.go b/requests.go index 377534ae..1bed6c7e 100644 --- a/requests.go +++ b/requests.go @@ -424,6 +424,33 @@ type ReqSendReceipt struct { ThreadID string `json:"thread_id,omitempty"` } +type ReqPublicRooms struct { + IncludeAllNetworks bool + Limit int + Since string + ThirdPartyInstanceID string +} + +func (req *ReqPublicRooms) Query() map[string]string { + query := map[string]string{} + if req == nil { + return query + } + if req.IncludeAllNetworks { + query["include_all_networks"] = "true" + } + if req.Limit > 0 { + query["limit"] = strconv.Itoa(req.Limit) + } + if req.Since != "" { + query["since"] = req.Since + } + if req.ThirdPartyInstanceID != "" { + query["third_party_instance_id"] = req.ThirdPartyInstanceID + } + return query +} + // ReqHierarchy contains the parameters for https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv1roomsroomidhierarchy // // As it's a GET method, there is no JSON body, so this is only query parameters. From 87ca9bef1cdac639ce912de0621346164e4908a4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 23 Apr 2025 11:29:07 +0300 Subject: [PATCH 025/581] bridgev2/networkinterface: add viewing chat callback --- bridgev2/networkinterface.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 29ee9fc9..1565a92c 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -583,6 +583,15 @@ type ReadReceiptHandlingNetworkAPI interface { HandleMatrixReadReceipt(ctx context.Context, msg *MatrixReadReceipt) error } +// ChatViewingNetworkAPI is an optional interface that network connectors can implement to handle viewing chat status. +type ChatViewingNetworkAPI interface { + NetworkAPI + // HandleMatrixViewingChat is called when the user opens a portal room. + // This will never be called by the standard appservice connector, + // as Matrix doesn't have any standard way of signaling chat open status. + HandleMatrixViewingChat(ctx context.Context, msg *MatrixViewingChat) error +} + // TypingHandlingNetworkAPI is an optional interface that network connectors can implement to handle typing events. type TypingHandlingNetworkAPI interface { NetworkAPI @@ -1235,6 +1244,14 @@ type MatrixTyping struct { Type TypingType } +type MatrixViewingChat struct { + // The portal that the user is viewing. This will be nil when the user switches to a chat from a different bridge. + Portal *Portal + // An optional timeout after which the user should not be assumed to be viewing the chat anymore + // unless the event is repeated. + Timeout time.Duration +} + type MatrixMarkedUnread = MatrixRoomMeta[*event.MarkedUnreadEventContent] type MatrixMute = MatrixRoomMeta[*event.BeeperMuteEventContent] type MatrixRoomTag = MatrixRoomMeta[*event.TagEventContent] From f931c9972de92d782f8e4598eca8d1fd11a1f0f7 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 23 Apr 2025 15:29:45 +0300 Subject: [PATCH 026/581] crypto/decryptolm: don't try to parse content if there is none --- crypto/decryptolm.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crypto/decryptolm.go b/crypto/decryptolm.go index 353979d4..b737e4e1 100644 --- a/crypto/decryptolm.go +++ b/crypto/decryptolm.go @@ -106,9 +106,11 @@ func (mach *OlmMachine) decryptAndParseOlmCiphertext(ctx context.Context, evt *e return nil, RecipientKeyMismatch } - err = olmEvt.Content.ParseRaw(olmEvt.Type) - if err != nil && !errors.Is(err, event.ErrUnsupportedContentType) { - return nil, fmt.Errorf("failed to parse content of olm payload event: %w", err) + if len(olmEvt.Content.VeryRaw) > 0 { + err = olmEvt.Content.ParseRaw(olmEvt.Type) + if err != nil && !errors.Is(err, event.ErrUnsupportedContentType) { + return nil, fmt.Errorf("failed to parse content of olm payload event: %w", err) + } } olmEvt.SenderKey = senderKey From 3698f139b6eaf05e9092818497e863a52cab5010 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 23 Apr 2025 15:47:22 +0300 Subject: [PATCH 027/581] crypto/helper: always update crypto store device ID --- crypto/cryptohelper/cryptohelper.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crypto/cryptohelper/cryptohelper.go b/crypto/cryptohelper/cryptohelper.go index f03835ef..56f8b484 100644 --- a/crypto/cryptohelper/cryptohelper.go +++ b/crypto/cryptohelper/cryptohelper.go @@ -170,14 +170,12 @@ func (helper *CryptoHelper) Init(ctx context.Context) error { if err != nil { return err } - rawCryptoStore.DeviceID = resp.DeviceID helper.client.DeviceID = resp.DeviceID } else { helper.log.Debug(). Str("username", helper.LoginAs.Identifier.User). Stringer("device_id", storedDeviceID). Msg("Using existing device") - rawCryptoStore.DeviceID = storedDeviceID helper.client.DeviceID = storedDeviceID } } else if helper.LoginAs != nil { @@ -193,12 +191,10 @@ func (helper *CryptoHelper) Init(ctx context.Context) error { if err != nil { return err } - if storedDeviceID == "" { - rawCryptoStore.DeviceID = helper.client.DeviceID - } } else if storedDeviceID != "" && storedDeviceID != helper.client.DeviceID { return fmt.Errorf("mismatching device ID in client and crypto store (%q != %q)", storedDeviceID, helper.client.DeviceID) } + rawCryptoStore.DeviceID = helper.client.DeviceID } else if helper.LoginAs != nil { return fmt.Errorf("LoginAs can only be used with a managed crypto store") } From 5f4bd44baa012fde1cc33b6f7451bc3ebe111213 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 23 Apr 2025 15:57:35 +0300 Subject: [PATCH 028/581] event/voip: omit empty version field in call events --- event/voip.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/event/voip.go b/event/voip.go index 28f56c95..cd8364a1 100644 --- a/event/voip.go +++ b/event/voip.go @@ -76,7 +76,7 @@ func (cv *CallVersion) Int() (int, error) { type BaseCallEventContent struct { CallID string `json:"call_id"` PartyID string `json:"party_id"` - Version CallVersion `json:"version"` + Version CallVersion `json:"version,omitempty"` } type CallInviteEventContent struct { From 19153e363846b29717fd38f7d40baab777cdce09 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Wed, 23 Apr 2025 16:27:11 +0100 Subject: [PATCH 029/581] client: return immediately if context canceled on external upload --- client.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client.go b/client.go index 5b5f083e..5f47aead 100644 --- a/client.go +++ b/client.go @@ -1805,6 +1805,9 @@ func (cli *Client) uploadMediaToURL(ctx context.Context, data ReqUploadMedia) (* break } err = fmt.Errorf("HTTP %d", resp.StatusCode) + } else if errors.Is(err, context.Canceled) { + cli.Log.Warn().Str("url", data.UnstableUploadURL).Msg("External media upload canceled") + return nil, err } if retries <= 0 { cli.Log.Warn().Str("url", data.UnstableUploadURL).Err(err). From 931f89202b1b6ad953751bd7e62d2f414ed7b9f3 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Wed, 23 Apr 2025 16:27:28 +0100 Subject: [PATCH 030/581] crypto/verification: include the incorrect state in non-ready error message --- crypto/verificationhelper/sas.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto/verificationhelper/sas.go b/crypto/verificationhelper/sas.go index 2906e3a2..89d4a750 100644 --- a/crypto/verificationhelper/sas.go +++ b/crypto/verificationhelper/sas.go @@ -46,7 +46,7 @@ func (vh *VerificationHelper) StartSAS(ctx context.Context, txnID id.Verificatio if err != nil { return fmt.Errorf("failed to get verification transaction %s: %w", txnID, err) } else if txn.VerificationState != VerificationStateReady { - return errors.New("transaction is not in ready state") + return fmt.Errorf("transaction is not in ready state: %s", txn.VerificationState.String()) } else if txn.StartEventContent != nil { return errors.New("start event already sent or received") } From de171e38d5d5492702f7a8a59581a55016230aaf Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Wed, 23 Apr 2025 16:46:46 +0100 Subject: [PATCH 031/581] crypto/verification: use consistent action log --- crypto/verificationhelper/sas.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crypto/verificationhelper/sas.go b/crypto/verificationhelper/sas.go index 89d4a750..1313a613 100644 --- a/crypto/verificationhelper/sas.go +++ b/crypto/verificationhelper/sas.go @@ -35,7 +35,7 @@ import ( // [StartInRoomVerification] functions. func (vh *VerificationHelper) StartSAS(ctx context.Context, txnID id.VerificationTransactionID) error { log := vh.getLog(ctx).With(). - Str("verification_action", "accept verification"). + Str("verification_action", "start SAS"). Stringer("transaction_id", txnID). Logger() ctx = log.WithContext(ctx) @@ -177,7 +177,7 @@ func (vh *VerificationHelper) ConfirmSAS(ctx context.Context, txnID id.Verificat func (vh *VerificationHelper) onVerificationStartSAS(ctx context.Context, txn VerificationTransaction, evt *event.Event) error { startEvt := evt.Content.AsVerificationStart() log := vh.getLog(ctx).With(). - Str("verification_action", "start_sas"). + Str("verification_action", "start SAS"). Stringer("transaction_id", txn.TransactionID). Logger() ctx = log.WithContext(ctx) From 33f3ccd6aef0a3ef3a37fefead208c6249169142 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Wed, 23 Apr 2025 16:46:58 +0100 Subject: [PATCH 032/581] crypto/verification: add missing lock in `AcceptVerification` method --- crypto/verificationhelper/verificationhelper.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crypto/verificationhelper/verificationhelper.go b/crypto/verificationhelper/verificationhelper.go index 550df942..8d99dacc 100644 --- a/crypto/verificationhelper/verificationhelper.go +++ b/crypto/verificationhelper/verificationhelper.go @@ -378,6 +378,9 @@ func (vh *VerificationHelper) StartInRoomVerification(ctx context.Context, roomI // be the transaction ID of a verification request that was received via the // VerificationRequested callback in [RequiredCallbacks]. func (vh *VerificationHelper) AcceptVerification(ctx context.Context, txnID id.VerificationTransactionID) error { + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + log := vh.getLog(ctx).With(). Str("verification_action", "accept verification"). Stringer("transaction_id", txnID). From 3badb9b332fede4b51295e17d5d08001bf36d5f3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 28 Apr 2025 00:25:36 +0300 Subject: [PATCH 033/581] commands: add subcommand system --- commands/container.go | 89 ++++++++++++++++++++++++++++++++++++++ commands/event.go | 18 ++++++++ commands/handler.go | 29 +++++++++++++ commands/prevalidate.go | 4 +- commands/processor.go | 95 +++++++++++------------------------------ 5 files changed, 161 insertions(+), 74 deletions(-) create mode 100644 commands/container.go create mode 100644 commands/handler.go diff --git a/commands/container.go b/commands/container.go new file mode 100644 index 00000000..e9dfd5e9 --- /dev/null +++ b/commands/container.go @@ -0,0 +1,89 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "fmt" + "strings" + "sync" +) + +type CommandContainer[MetaType any] struct { + commands map[string]*Handler[MetaType] + aliases map[string]string + lock sync.RWMutex +} + +func NewCommandContainer[MetaType any]() *CommandContainer[MetaType] { + return &CommandContainer[MetaType]{ + commands: make(map[string]*Handler[MetaType]), + aliases: make(map[string]string), + } +} + +// Register registers the given command handlers. +func (cont *CommandContainer[MetaType]) Register(handlers ...*Handler[MetaType]) { + if cont == nil { + return + } + cont.lock.Lock() + defer cont.lock.Unlock() + for _, handler := range handlers { + cont.registerOne(handler) + } +} + +func (cont *CommandContainer[MetaType]) registerOne(handler *Handler[MetaType]) { + if strings.ToLower(handler.Name) != handler.Name { + panic(fmt.Errorf("command %q is not lowercase", handler.Name)) + } + cont.commands[handler.Name] = handler + for _, alias := range handler.Aliases { + if strings.ToLower(alias) != alias { + panic(fmt.Errorf("alias %q is not lowercase", alias)) + } + cont.aliases[alias] = handler.Name + } + handler.initSubcommandContainer() +} + +func (cont *CommandContainer[MetaType]) Unregister(handlers ...*Handler[MetaType]) { + if cont == nil { + return + } + cont.lock.Lock() + defer cont.lock.Unlock() + for _, handler := range handlers { + cont.unregisterOne(handler) + } +} + +func (cont *CommandContainer[MetaType]) unregisterOne(handler *Handler[MetaType]) { + delete(cont.commands, handler.Name) + for _, alias := range handler.Aliases { + if cont.aliases[alias] == handler.Name { + delete(cont.aliases, alias) + } + } +} + +func (cont *CommandContainer[MetaType]) GetHandler(name string) *Handler[MetaType] { + if cont == nil { + return nil + } + cont.lock.RLock() + defer cont.lock.RUnlock() + alias, ok := cont.aliases[name] + if ok { + name = alias + } + handler, ok := cont.commands[name] + if !ok { + handler = cont.commands[UnknownCommandName] + } + return handler +} diff --git a/commands/event.go b/commands/event.go index baf9ecda..7370844c 100644 --- a/commands/event.go +++ b/commands/event.go @@ -23,6 +23,9 @@ type Event[MetaType any] struct { *event.Event // RawInput is the entire message before splitting into command and arguments. RawInput string + // ParentCommands is the chain of commands leading up to this command. + // This is only set if the command is a subcommand. + ParentCommands []string // Command is the lowercased first word of the message. Command string // Args are the rest of the message split by whitespace ([strings.Fields]). @@ -122,3 +125,18 @@ func (evt *Event[MetaType]) MarkRead() { zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send read receipt") } } + +// PromoteFirstArgToCommand promotes the first argument to the command name. +// +// Command will be set to the lowercased first item in the Args list. +// Both Args and RawArgs will be updated to remove the first argument, but RawInput will be left as-is. +// +// The caller MUST check that there are args before calling this function. +func (evt *Event[MetaType]) PromoteFirstArgToCommand() { + if len(evt.Args) == 0 { + panic(fmt.Errorf("PromoteFirstArgToCommand called with no args")) + } + evt.Command = strings.ToLower(evt.Args[0]) + evt.RawArgs = strings.TrimLeft(strings.TrimPrefix(evt.RawArgs, evt.Args[0]), " ") + evt.Args = evt.Args[1:] +} diff --git a/commands/handler.go b/commands/handler.go new file mode 100644 index 00000000..be1d4e9b --- /dev/null +++ b/commands/handler.go @@ -0,0 +1,29 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +type Handler[MetaType any] struct { + Func func(ce *Event[MetaType]) + + // Name is the primary name of the command. It must be lowercase. + Name string + // Aliases are alternative names for the command. They must be lowercase. + Aliases []string + // Subcommands are subcommands of this command. + Subcommands []*Handler[MetaType] + + subcommandContainer *CommandContainer[MetaType] +} + +func (h *Handler[MetaType]) initSubcommandContainer() { + if len(h.Subcommands) > 0 { + h.subcommandContainer = NewCommandContainer[MetaType]() + h.subcommandContainer.Register(h.Subcommands...) + } else { + h.subcommandContainer = nil + } +} diff --git a/commands/prevalidate.go b/commands/prevalidate.go index 95bbcc97..66da2b49 100644 --- a/commands/prevalidate.go +++ b/commands/prevalidate.go @@ -61,9 +61,7 @@ func (f AnyPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool { func ValidatePrefixCommand[MetaType any](prefix string) PreValidator[MetaType] { return FuncPreValidator[MetaType](func(ce *Event[MetaType]) bool { if ce.Command == prefix && len(ce.Args) > 0 { - ce.Command = strings.ToLower(ce.Args[0]) - ce.RawArgs = strings.TrimLeft(strings.TrimPrefix(ce.RawArgs, ce.Args[0]), " ") - ce.Args = ce.Args[1:] + ce.PromoteFirstArgToCommand() return true } return false diff --git a/commands/processor.go b/commands/processor.go index d4a29690..067f222e 100644 --- a/commands/processor.go +++ b/commands/processor.go @@ -8,10 +8,8 @@ package commands import ( "context" - "fmt" "runtime/debug" "strings" - "sync" "github.com/rs/zerolog" @@ -22,34 +20,23 @@ import ( // Processor implements boilerplate code for splitting messages into a command and arguments, // and finding the appropriate handler for the command. type Processor[MetaType any] struct { + *CommandContainer[MetaType] + Client *mautrix.Client LogArgs bool PreValidator PreValidator[MetaType] Meta MetaType - commands map[string]*Handler[MetaType] - aliases map[string]string - lock sync.RWMutex -} - -type Handler[MetaType any] struct { - Func func(ce *Event[MetaType]) - - // Name is the primary name of the command. It must be lowercase. - Name string - // Aliases are alternative names for the command. They must be lowercase. - Aliases []string } // UnknownCommandName is the name of the fallback handler which is used if no other handler is found. // If even the unknown command handler is not found, the command is ignored. -const UnknownCommandName = "unknown-command" +const UnknownCommandName = "__unknown-command__" func NewProcessor[MetaType any](cli *mautrix.Client) *Processor[MetaType] { proc := &Processor[MetaType]{ - Client: cli, - PreValidator: ValidatePrefixSubstring[MetaType]("!"), - commands: make(map[string]*Handler[MetaType]), - aliases: make(map[string]string), + CommandContainer: NewCommandContainer[MetaType](), + Client: cli, + PreValidator: ValidatePrefixSubstring[MetaType]("!"), } proc.Register(&Handler[MetaType]{ Name: UnknownCommandName, @@ -60,45 +47,6 @@ func NewProcessor[MetaType any](cli *mautrix.Client) *Processor[MetaType] { return proc } -// Register registers the given command handlers. -func (proc *Processor[MetaType]) Register(handlers ...*Handler[MetaType]) { - proc.lock.Lock() - defer proc.lock.Unlock() - for _, handler := range handlers { - proc.registerOne(handler) - } -} - -func (proc *Processor[MetaType]) registerOne(handler *Handler[MetaType]) { - if strings.ToLower(handler.Name) != handler.Name { - panic(fmt.Errorf("command %q is not lowercase", handler.Name)) - } - proc.commands[handler.Name] = handler - for _, alias := range handler.Aliases { - if strings.ToLower(alias) != alias { - panic(fmt.Errorf("alias %q is not lowercase", alias)) - } - proc.aliases[alias] = handler.Name - } -} - -func (proc *Processor[MetaType]) Unregister(handlers ...*Handler[MetaType]) { - proc.lock.Lock() - defer proc.lock.Unlock() - for _, handler := range handlers { - proc.unregisterOne(handler) - } -} - -func (proc *Processor[MetaType]) unregisterOne(handler *Handler[MetaType]) { - delete(proc.commands, handler.Name) - for _, alias := range handler.Aliases { - if proc.aliases[alias] == handler.Name { - delete(proc.aliases, alias) - } - } -} - func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) { log := *zerolog.Ctx(ctx) defer func() { @@ -123,25 +71,30 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) return } - realCommand := parsed.Command - proc.lock.RLock() - alias, ok := proc.aliases[realCommand] - if ok { - realCommand = alias - } - handler, ok := proc.commands[realCommand] - if !ok { - handler, ok = proc.commands[UnknownCommandName] - } - proc.lock.RUnlock() - if !ok { + handler := proc.GetHandler(parsed.Command) + if handler == nil { return } + handlerChain := zerolog.Arr() + handlerChain.Str(handler.Name) + for handler.subcommandContainer != nil && len(parsed.Args) > 0 { + subHandler := handler.subcommandContainer.GetHandler(strings.ToLower(parsed.Args[0])) + if subHandler != nil { + parsed.ParentCommands = append(parsed.ParentCommands, parsed.Command) + handlerChain.Str(subHandler.Name) + parsed.PromoteFirstArgToCommand() + handler = subHandler + } + } logWith := log.With(). - Str("command", realCommand). + Str("command", parsed.Command). + Array("handler", handlerChain). Stringer("sender", evt.Sender). Stringer("room_id", evt.RoomID) + if len(parsed.ParentCommands) > 0 { + logWith = logWith.Strs("parent_commands", parsed.ParentCommands) + } if proc.LogArgs { logWith = logWith.Strs("args", parsed.Args) } From 9dc0b3cddffb8bf88b48b1785ed51a6d0d6d9923 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 28 Apr 2025 00:32:44 +0300 Subject: [PATCH 034/581] commands: make unknown command handler more generic --- commands/handler.go | 17 +++++++++++++++++ commands/processor.go | 7 +------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/commands/handler.go b/commands/handler.go index be1d4e9b..d4c53ff6 100644 --- a/commands/handler.go +++ b/commands/handler.go @@ -6,6 +6,10 @@ package commands +import ( + "strings" +) + type Handler[MetaType any] struct { Func func(ce *Event[MetaType]) @@ -27,3 +31,16 @@ func (h *Handler[MetaType]) initSubcommandContainer() { h.subcommandContainer = nil } } + +func MakeUnknownCommandHandler[MetaType any](prefix string) *Handler[MetaType] { + return &Handler[MetaType]{ + Name: UnknownCommandName, + Func: func(ce *Event[MetaType]) { + if len(ce.ParentCommands) == 0 { + ce.Reply("Unknown command `%s%s`", prefix, ce.Command) + } else { + ce.Reply("Unknown subcommand `%s%s %s`", prefix, strings.Join(ce.ParentCommands, " "), ce.Command) + } + }, + } +} diff --git a/commands/processor.go b/commands/processor.go index 067f222e..c4077250 100644 --- a/commands/processor.go +++ b/commands/processor.go @@ -38,12 +38,7 @@ func NewProcessor[MetaType any](cli *mautrix.Client) *Processor[MetaType] { Client: cli, PreValidator: ValidatePrefixSubstring[MetaType]("!"), } - proc.Register(&Handler[MetaType]{ - Name: UnknownCommandName, - Func: func(ce *Event[MetaType]) { - ce.Reply("Unknown command") - }, - }) + proc.Register(MakeUnknownCommandHandler[MetaType]("!")) return proc } From 287899435dc4d60738c777548fd92d25e41bf7b5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 28 Apr 2025 00:54:00 +0300 Subject: [PATCH 035/581] format: add method to quote string in markdown inline code --- format/htmlparser.go | 22 +--------------------- format/markdown.go | 14 ++++++++++++++ format/markdown_test.go | 15 +++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- 5 files changed, 33 insertions(+), 24 deletions(-) diff --git a/format/htmlparser.go b/format/htmlparser.go index e50a578e..f9d51e39 100644 --- a/format/htmlparser.go +++ b/format/htmlparser.go @@ -187,25 +187,6 @@ func (parser *HTMLParser) listToString(node *html.Node, ctx Context) string { return strings.Join(children, "\n") } -func LongestSequence(in string, of rune) int { - currentSeq := 0 - maxSeq := 0 - for _, chr := range in { - if chr == of { - currentSeq++ - } else { - if currentSeq > maxSeq { - maxSeq = currentSeq - } - currentSeq = 0 - } - } - if currentSeq > maxSeq { - maxSeq = currentSeq - } - return maxSeq -} - func (parser *HTMLParser) basicFormatToString(node *html.Node, ctx Context) string { str := parser.nodeToTagAwareString(node.FirstChild, ctx) switch node.Data { @@ -232,8 +213,7 @@ func (parser *HTMLParser) basicFormatToString(node *html.Node, ctx Context) stri if parser.MonospaceConverter != nil { return parser.MonospaceConverter(str, ctx) } - surround := strings.Repeat("`", LongestSequence(str, '`')+1) - return fmt.Sprintf("%s%s%s", surround, str, surround) + return SafeMarkdownCode(str) } return str } diff --git a/format/markdown.go b/format/markdown.go index a1c93162..59248c72 100644 --- a/format/markdown.go +++ b/format/markdown.go @@ -14,6 +14,7 @@ import ( "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/renderer/html" + "go.mau.fi/util/exstrings" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format/mdext" @@ -49,6 +50,19 @@ func EscapeMarkdown(text string) string { return text } +func SafeMarkdownCode(text string) string { + text = strings.ReplaceAll(text, "\n", " ") + backtickCount := exstrings.LongestSequenceOf(text, '`') + if backtickCount == 0 { + return fmt.Sprintf("`%s`", text) + } + quotes := strings.Repeat("`", backtickCount+1) + if text[0] == '`' || text[len(text)-1] == '`' { + return fmt.Sprintf("%s %s %s", quotes, text, quotes) + } + return fmt.Sprintf("%s%s%s", quotes, text, quotes) +} + func RenderMarkdownCustom(text string, renderer goldmark.Markdown) event.MessageEventContent { var buf strings.Builder err := renderer.Convert([]byte(text), &buf) diff --git a/format/markdown_test.go b/format/markdown_test.go index d4e7d716..46ea4886 100644 --- a/format/markdown_test.go +++ b/format/markdown_test.go @@ -196,3 +196,18 @@ func TestRenderMarkdown_CustomEmoji(t *testing.T) { assert.Equal(t, html, rendered, "with input %q", markdown) } } + +var codeTests = map[string]string{ + "meow": "`meow`", + "me`ow": "``me`ow``", + "`me`ow": "`` `me`ow ``", + "me`ow`": "`` me`ow` ``", + "`meow`": "`` `meow` ``", + "`````````": "`````````` ````````` ``````````", +} + +func TestSafeMarkdownCode(t *testing.T) { + for input, expected := range codeTests { + assert.Equal(t, expected, format.SafeMarkdownCode(input), "with input %q", input) + } +} diff --git a/go.mod b/go.mod index 40564392..e279118e 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.10 - go.mau.fi/util v0.8.6 + go.mau.fi/util v0.8.7-0.20250427215252-d2d18a7e463c go.mau.fi/zeroconfig v0.1.3 golang.org/x/crypto v0.37.0 golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 diff --git a/go.sum b/go.sum index cb64c875..f103b287 100644 --- a/go.sum +++ b/go.sum @@ -53,8 +53,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.10 h1:S+LrtBjRmqMac2UdtB6yyCEJm+UILZ2fefI4p7o0QpI= github.com/yuin/goldmark v1.7.10/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54= -go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= +go.mau.fi/util v0.8.7-0.20250427215252-d2d18a7e463c h1:qfJyMZq1pPyuXKoVWwHs6OmR9CzO3pHFRPYT/QpaaaA= +go.mau.fi/util v0.8.7-0.20250427215252-d2d18a7e463c/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= From a121a6101ce6189fdc7510a86f81c1d9841ec3bd Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 28 Apr 2025 01:00:25 +0300 Subject: [PATCH 036/581] format: accept any string-like type in SafeMarkdownCode --- format/markdown.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/format/markdown.go b/format/markdown.go index 59248c72..f6181ed9 100644 --- a/format/markdown.go +++ b/format/markdown.go @@ -50,8 +50,8 @@ func EscapeMarkdown(text string) string { return text } -func SafeMarkdownCode(text string) string { - text = strings.ReplaceAll(text, "\n", " ") +func SafeMarkdownCode[T ~string](textInput T) string { + text := strings.ReplaceAll(string(textInput), "\n", " ") backtickCount := exstrings.LongestSequenceOf(text, '`') if backtickCount == 0 { return fmt.Sprintf("`%s`", text) From bf33889eab4cf2ca595b058c55827f7070cf9c7c Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 28 Apr 2025 14:10:57 +0100 Subject: [PATCH 037/581] bridgev2/userlogin: delete disappearing messages when deleting portals (#374) --- bridgev2/database/upgrades/00-latest.sql | 9 +++++-- .../21-disappearing-message-fkey.postgres.sql | 8 +++++++ .../21-disappearing-message-fkey.sqlite.sql | 24 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 bridgev2/database/upgrades/21-disappearing-message-fkey.postgres.sql create mode 100644 bridgev2/database/upgrades/21-disappearing-message-fkey.sqlite.sql diff --git a/bridgev2/database/upgrades/00-latest.sql b/bridgev2/database/upgrades/00-latest.sql index 56976b82..7ad01a87 100644 --- a/bridgev2/database/upgrades/00-latest.sql +++ b/bridgev2/database/upgrades/00-latest.sql @@ -1,4 +1,4 @@ --- v0 -> v20 (compatible with v9+): Latest revision +-- v0 -> v21 (compatible with v9+): Latest revision CREATE TABLE "user" ( bridge_id TEXT NOT NULL, mxid TEXT NOT NULL, @@ -63,6 +63,7 @@ CREATE TABLE portal ( REFERENCES user_login (bridge_id, id) ON DELETE SET NULL ON UPDATE CASCADE ); +CREATE UNIQUE INDEX portal_bridge_mxid_idx ON portal (bridge_id, mxid); CREATE TABLE ghost ( bridge_id TEXT NOT NULL, @@ -128,7 +129,11 @@ CREATE TABLE disappearing_message ( timer BIGINT NOT NULL, disappear_at BIGINT, - PRIMARY KEY (bridge_id, mxid) + PRIMARY KEY (bridge_id, mxid), + CONSTRAINT disappearing_message_portal_fkey + FOREIGN KEY (bridge_id, mx_room) + REFERENCES portal (bridge_id, mxid) + ON DELETE CASCADE ); CREATE TABLE reaction ( diff --git a/bridgev2/database/upgrades/21-disappearing-message-fkey.postgres.sql b/bridgev2/database/upgrades/21-disappearing-message-fkey.postgres.sql new file mode 100644 index 00000000..d1c1ad9a --- /dev/null +++ b/bridgev2/database/upgrades/21-disappearing-message-fkey.postgres.sql @@ -0,0 +1,8 @@ +-- v21 (compatible with v9+): Add foreign key constraint from disappearing_message.mx_room to portals.mxid +CREATE UNIQUE INDEX portal_bridge_mxid_idx ON portal (bridge_id, mxid); +DELETE FROM disappearing_message WHERE mx_room NOT IN (SELECT mxid FROM portal WHERE mxid IS NOT NULL); +ALTER TABLE disappearing_message + ADD CONSTRAINT disappearing_message_portal_fkey + FOREIGN KEY (bridge_id, mx_room) + REFERENCES portal (bridge_id, mxid) + ON DELETE CASCADE; diff --git a/bridgev2/database/upgrades/21-disappearing-message-fkey.sqlite.sql b/bridgev2/database/upgrades/21-disappearing-message-fkey.sqlite.sql new file mode 100644 index 00000000..f5468c6b --- /dev/null +++ b/bridgev2/database/upgrades/21-disappearing-message-fkey.sqlite.sql @@ -0,0 +1,24 @@ +-- v21 (compatible with v9+): Add foreign key constraint from disappearing_message.mx_room to portals.mxid +CREATE UNIQUE INDEX portal_bridge_mxid_idx ON portal (bridge_id, mxid); +CREATE TABLE disappearing_message_new ( + bridge_id TEXT NOT NULL, + mx_room TEXT NOT NULL, + mxid TEXT NOT NULL, + type TEXT NOT NULL, + timer BIGINT NOT NULL, + disappear_at BIGINT, + + PRIMARY KEY (bridge_id, mxid), + CONSTRAINT disappearing_message_portal_fkey + FOREIGN KEY (bridge_id, mx_room) + REFERENCES portal (bridge_id, mxid) + ON DELETE CASCADE +); + +WITH portal_mxids AS (SELECT mxid FROM portal WHERE mxid IS NOT NULL) +INSERT INTO disappearing_message_new (bridge_id, mx_room, mxid, type, timer, disappear_at) +SELECT bridge_id, mx_room, mxid, type, timer, disappear_at +FROM disappearing_message WHERE mx_room IN portal_mxids; + +DROP TABLE disappearing_message; +ALTER TABLE disappearing_message_new RENAME TO disappearing_message; From 06a292e1cc1538d43ff4a889fde9b361d55f7bc6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 28 Apr 2025 13:38:18 +0300 Subject: [PATCH 038/581] commands: add pre func for subcommand parameters --- commands/event.go | 21 ++++++++++++--------- commands/handler.go | 5 +++++ commands/prevalidate.go | 2 +- commands/processor.go | 18 +++++++++++++----- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/commands/event.go b/commands/event.go index 7370844c..10d6283f 100644 --- a/commands/event.go +++ b/commands/event.go @@ -126,17 +126,20 @@ func (evt *Event[MetaType]) MarkRead() { } } -// PromoteFirstArgToCommand promotes the first argument to the command name. -// -// Command will be set to the lowercased first item in the Args list. -// Both Args and RawArgs will be updated to remove the first argument, but RawInput will be left as-is. -// -// The caller MUST check that there are args before calling this function. -func (evt *Event[MetaType]) PromoteFirstArgToCommand() { +// ShiftArg removes the first argument from the Args list and RawArgs data and returns it. +// RawInput will not be modified. +func (evt *Event[MetaType]) ShiftArg() string { if len(evt.Args) == 0 { - panic(fmt.Errorf("PromoteFirstArgToCommand called with no args")) + return "" } - evt.Command = strings.ToLower(evt.Args[0]) + firstArg := evt.Args[0] evt.RawArgs = strings.TrimLeft(strings.TrimPrefix(evt.RawArgs, evt.Args[0]), " ") evt.Args = evt.Args[1:] + return firstArg +} + +// UnshiftArg reverses ShiftArg by adding the given value to the beginning of the Args list and RawArgs data. +func (evt *Event[MetaType]) UnshiftArg(arg string) { + evt.RawArgs = arg + " " + evt.RawArgs + evt.Args = append([]string{arg}, evt.Args...) } diff --git a/commands/handler.go b/commands/handler.go index d4c53ff6..b01d594f 100644 --- a/commands/handler.go +++ b/commands/handler.go @@ -11,6 +11,7 @@ import ( ) type Handler[MetaType any] struct { + // Func is the function that is called when the command is executed. Func func(ce *Event[MetaType]) // Name is the primary name of the command. It must be lowercase. @@ -19,6 +20,10 @@ type Handler[MetaType any] struct { Aliases []string // Subcommands are subcommands of this command. Subcommands []*Handler[MetaType] + // PreFunc is a function that is called before checking subcommands. + // It can be used to have parameters between subcommands (e.g. `!rooms `). + // Event.ShiftArg will likely be useful for implementing such parameters. + PreFunc func(ce *Event[MetaType]) subcommandContainer *CommandContainer[MetaType] } diff --git a/commands/prevalidate.go b/commands/prevalidate.go index 66da2b49..facca4da 100644 --- a/commands/prevalidate.go +++ b/commands/prevalidate.go @@ -61,7 +61,7 @@ func (f AnyPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool { func ValidatePrefixCommand[MetaType any](prefix string) PreValidator[MetaType] { return FuncPreValidator[MetaType](func(ce *Event[MetaType]) bool { if ce.Command == prefix && len(ce.Args) > 0 { - ce.PromoteFirstArgToCommand() + ce.Command = strings.ToLower(ce.ShiftArg()) return true } return false diff --git a/commands/processor.go b/commands/processor.go index c4077250..1e0a99a2 100644 --- a/commands/processor.go +++ b/commands/processor.go @@ -65,20 +65,31 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) if !proc.PreValidator.Validate(parsed) { return } + parsed.Proc = proc + parsed.Meta = proc.Meta + parsed.Ctx = ctx handler := proc.GetHandler(parsed.Command) if handler == nil { return } + parsed.Handler = handler + if handler.PreFunc != nil { + handler.PreFunc(parsed) + } handlerChain := zerolog.Arr() handlerChain.Str(handler.Name) for handler.subcommandContainer != nil && len(parsed.Args) > 0 { subHandler := handler.subcommandContainer.GetHandler(strings.ToLower(parsed.Args[0])) if subHandler != nil { + handler = subHandler parsed.ParentCommands = append(parsed.ParentCommands, parsed.Command) handlerChain.Str(subHandler.Name) - parsed.PromoteFirstArgToCommand() - handler = subHandler + parsed.Command = strings.ToLower(parsed.ShiftArg()) + parsed.Handler = subHandler + if subHandler.PreFunc != nil { + subHandler.PreFunc(parsed) + } } } @@ -95,9 +106,6 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) } log = logWith.Logger() parsed.Ctx = log.WithContext(ctx) - parsed.Handler = handler - parsed.Proc = proc - parsed.Meta = proc.Meta log.Debug().Msg("Processing command") handler.Func(parsed) From db62b9a1d875f654eec845c19d7309832d6af576 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 29 Apr 2025 02:26:50 +0300 Subject: [PATCH 039/581] commands: ignore notices --- commands/event.go | 6 ++++++ commands/processor.go | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/commands/event.go b/commands/event.go index 10d6283f..c02bd3b9 100644 --- a/commands/event.go +++ b/commands/event.go @@ -58,10 +58,16 @@ var IDHTMLParser = &format.HTMLParser{ // ParseEvent parses a message into a command event struct. func ParseEvent[MetaType any](ctx context.Context, evt *event.Event) *Event[MetaType] { content := evt.Content.Parsed.(*event.MessageEventContent) + if content.MsgType == event.MsgNotice || content.RelatesTo.GetReplaceID() != "" { + return nil + } text := content.Body if content.Format == event.FormatHTML { text = IDHTMLParser.Parse(content.FormattedBody, format.NewContext(ctx)) } + if len(text) == 0 { + return nil + } parts := strings.Fields(text) return &Event[MetaType]{ Event: evt, diff --git a/commands/processor.go b/commands/processor.go index 1e0a99a2..cc55aceb 100644 --- a/commands/processor.go +++ b/commands/processor.go @@ -62,7 +62,7 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) } }() parsed := ParseEvent[MetaType](ctx, evt) - if !proc.PreValidator.Validate(parsed) { + if parsed == nil || !proc.PreValidator.Validate(parsed) { return } parsed.Proc = proc From 771424f86b6559c3b383ad65a92303d7dbf8454d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 29 Apr 2025 17:31:27 +0300 Subject: [PATCH 040/581] commands: stop looking for subcommands if not found --- commands/processor.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/commands/processor.go b/commands/processor.go index cc55aceb..a7c1d941 100644 --- a/commands/processor.go +++ b/commands/processor.go @@ -90,6 +90,8 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) if subHandler.PreFunc != nil { subHandler.PreFunc(parsed) } + } else { + break } } From 6c9cd6da6bea3f38ac5e3e655fcc70edef655792 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 29 Apr 2025 18:41:37 +0300 Subject: [PATCH 041/581] commands: return event ID to allow edits --- commands/event.go | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/commands/event.go b/commands/event.go index c02bd3b9..29a16538 100644 --- a/commands/event.go +++ b/commands/event.go @@ -15,6 +15,7 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" ) // Event contains the data of a single command event. @@ -85,16 +86,17 @@ type ReplyOpts struct { Reply bool Thread bool SendAsText bool + Edit id.EventID } -func (evt *Event[MetaType]) Reply(msg string, args ...any) { +func (evt *Event[MetaType]) Reply(msg string, args ...any) id.EventID { if len(args) > 0 { msg = fmt.Sprintf(msg, args...) } - evt.Respond(msg, ReplyOpts{AllowMarkdown: true, Reply: true}) + return evt.Respond(msg, ReplyOpts{AllowMarkdown: true, Reply: true}) } -func (evt *Event[MetaType]) Respond(msg string, opts ReplyOpts) { +func (evt *Event[MetaType]) Respond(msg string, opts ReplyOpts) id.EventID { content := format.RenderMarkdown(msg, opts.AllowMarkdown, opts.AllowHTML) if opts.Thread { content.SetThread(evt.Event) @@ -105,24 +107,33 @@ func (evt *Event[MetaType]) Respond(msg string, opts ReplyOpts) { if !opts.SendAsText { content.MsgType = event.MsgNotice } - _, err := evt.Proc.Client.SendMessageEvent(evt.Ctx, evt.RoomID, event.EventMessage, content) + if opts.Edit != "" { + content.SetEdit(opts.Edit) + } + resp, err := evt.Proc.Client.SendMessageEvent(evt.Ctx, evt.RoomID, event.EventMessage, content) if err != nil { zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reply") + return "" } + return resp.EventID } -func (evt *Event[MetaType]) React(emoji string) { - _, err := evt.Proc.Client.SendReaction(evt.Ctx, evt.RoomID, evt.ID, emoji) +func (evt *Event[MetaType]) React(emoji string) id.EventID { + resp, err := evt.Proc.Client.SendReaction(evt.Ctx, evt.RoomID, evt.ID, emoji) if err != nil { zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reaction") + return "" } + return resp.EventID } -func (evt *Event[MetaType]) Redact() { - _, err := evt.Proc.Client.RedactEvent(evt.Ctx, evt.RoomID, evt.ID) +func (evt *Event[MetaType]) Redact() id.EventID { + resp, err := evt.Proc.Client.RedactEvent(evt.Ctx, evt.RoomID, evt.ID) if err != nil { zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to redact command") + return "" } + return resp.EventID } func (evt *Event[MetaType]) MarkRead() { From da25a87fc1824a789ea89be1dd87e8a3ba0acad9 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 29 Apr 2025 19:58:47 +0300 Subject: [PATCH 042/581] event: clear mentions in SetEdit --- event/message.go | 1 + 1 file changed, 1 insertion(+) diff --git a/event/message.go b/event/message.go index 48313784..51403889 100644 --- a/event/message.go +++ b/event/message.go @@ -210,6 +210,7 @@ func (content *MessageEventContent) SetEdit(original id.EventID) { content.RelatesTo = (&RelatesTo{}).SetReplace(original) if content.MsgType == MsgText || content.MsgType == MsgNotice { content.Body = "* " + content.Body + content.Mentions = &Mentions{} if content.Format == FormatHTML && len(content.FormattedBody) > 0 { content.FormattedBody = "* " + content.FormattedBody } From e0b1e9b0d386e5789888b34ca03c52d84868f3be Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 29 Apr 2025 19:59:31 +0300 Subject: [PATCH 043/581] commands/event: allow overriding mentions when replying --- commands/event.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/commands/event.go b/commands/event.go index 29a16538..13b1d7c1 100644 --- a/commands/event.go +++ b/commands/event.go @@ -81,12 +81,13 @@ func ParseEvent[MetaType any](ctx context.Context, evt *event.Event) *Event[Meta } type ReplyOpts struct { - AllowHTML bool - AllowMarkdown bool - Reply bool - Thread bool - SendAsText bool - Edit id.EventID + AllowHTML bool + AllowMarkdown bool + Reply bool + Thread bool + SendAsText bool + Edit id.EventID + OverrideMentions *event.Mentions } func (evt *Event[MetaType]) Reply(msg string, args ...any) id.EventID { @@ -110,6 +111,9 @@ func (evt *Event[MetaType]) Respond(msg string, opts ReplyOpts) id.EventID { if opts.Edit != "" { content.SetEdit(opts.Edit) } + if opts.OverrideMentions != nil { + content.Mentions = opts.OverrideMentions + } resp, err := evt.Proc.Client.SendMessageEvent(evt.Ctx, evt.RoomID, event.EventMessage, content) if err != nil { zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reply") From 58e4d0f2ccb3a1e8ede45f42eb659d06f1174953 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 30 Apr 2025 15:33:33 +0300 Subject: [PATCH 044/581] bridgev2: stop disappearing message loop on shutdown --- bridgev2/bridge.go | 1 + bridgev2/disappear.go | 16 +++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index 24ceaf6b..38f7ce1d 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -318,6 +318,7 @@ func (br *Bridge) Stop() { func (br *Bridge) stop(isRunOnce bool) { br.Log.Info().Msg("Shutting down bridge") + br.DisappearLoop.Stop() br.stopBackfillQueue.Set() br.Matrix.PreStop() if !isRunOnce { diff --git a/bridgev2/disappear.go b/bridgev2/disappear.go index 5f9900a5..d7b2182b 100644 --- a/bridgev2/disappear.go +++ b/bridgev2/disappear.go @@ -8,6 +8,7 @@ package bridgev2 import ( "context" + "sync/atomic" "time" "github.com/rs/zerolog" @@ -21,15 +22,17 @@ import ( type DisappearLoop struct { br *Bridge NextCheck time.Time - stop context.CancelFunc + stop atomic.Pointer[context.CancelFunc] } const DisappearCheckInterval = 1 * time.Hour func (dl *DisappearLoop) Start() { log := dl.br.Log.With().Str("component", "disappear loop").Logger() - ctx := log.WithContext(context.Background()) - ctx, dl.stop = context.WithCancel(ctx) + ctx, stop := context.WithCancel(log.WithContext(context.Background())) + if oldStop := dl.stop.Swap(&stop); oldStop != nil { + (*oldStop)() + } log.Debug().Msg("Disappearing message loop starting") for { dl.NextCheck = time.Now().Add(DisappearCheckInterval) @@ -49,8 +52,11 @@ func (dl *DisappearLoop) Start() { } func (dl *DisappearLoop) Stop() { - if dl.stop != nil { - dl.stop() + if dl == nil { + return + } + if stop := dl.stop.Load(); stop != nil { + (*stop)() } } From 69a17c6a599958b259f3eef8faeedfe6533cd906 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 1 May 2025 15:22:47 +0300 Subject: [PATCH 045/581] bridgev2/networkinterface: remove timeout from ViewingChat --- bridgev2/networkinterface.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 1565a92c..6ea0b02c 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -589,6 +589,7 @@ type ChatViewingNetworkAPI interface { // HandleMatrixViewingChat is called when the user opens a portal room. // This will never be called by the standard appservice connector, // as Matrix doesn't have any standard way of signaling chat open status. + // Clients are expected to call this every 5 seconds. There is no signal for closing a chat. HandleMatrixViewingChat(ctx context.Context, msg *MatrixViewingChat) error } @@ -1247,9 +1248,6 @@ type MatrixTyping struct { type MatrixViewingChat struct { // The portal that the user is viewing. This will be nil when the user switches to a chat from a different bridge. Portal *Portal - // An optional timeout after which the user should not be assumed to be viewing the chat anymore - // unless the event is repeated. - Timeout time.Duration } type MatrixMarkedUnread = MatrixRoomMeta[*event.MarkedUnreadEventContent] From 5c9529606e814ba035e26a6521ba774b0e87861b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 1 May 2025 15:23:31 +0300 Subject: [PATCH 046/581] crypto/keybackup: return wrapped errors in ImportRoomKeyFromBackup --- crypto/keybackup.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crypto/keybackup.go b/crypto/keybackup.go index a9686fdf..5724002b 100644 --- a/crypto/keybackup.go +++ b/crypto/keybackup.go @@ -3,6 +3,7 @@ package crypto import ( "context" "encoding/base64" + "errors" "fmt" "time" @@ -154,13 +155,19 @@ func (mach *OlmMachine) GetAndStoreKeyBackup(ctx context.Context, version id.Key return nil } +var ( + ErrUnknownAlgorithmInKeyBackup = errors.New("ignoring room key in backup with weird algorithm") + ErrMismatchingSessionIDInKeyBackup = errors.New("mismatched session ID while creating inbound group session from key backup") + ErrFailedToStoreNewInboundGroupSessionFromBackup = errors.New("failed to store new inbound group session from key backup") +) + func (mach *OlmMachine) ImportRoomKeyFromBackup(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, sessionID id.SessionID, keyBackupData *backup.MegolmSessionData) (*InboundGroupSession, error) { log := zerolog.Ctx(ctx).With(). Str("room_id", roomID.String()). Str("session_id", sessionID.String()). Logger() if keyBackupData.Algorithm != id.AlgorithmMegolmV1 { - return nil, fmt.Errorf("ignoring room key in backup with weird algorithm %s", keyBackupData.Algorithm) + return nil, fmt.Errorf("%w %s", ErrUnknownAlgorithmInKeyBackup, keyBackupData.Algorithm) } igsInternal, err := olm.InboundGroupSessionImport([]byte(keyBackupData.SessionKey)) @@ -170,7 +177,7 @@ func (mach *OlmMachine) ImportRoomKeyFromBackup(ctx context.Context, version id. log.Warn(). Stringer("actual_session_id", igsInternal.ID()). Msg("Mismatched session ID while creating inbound group session from key backup") - return nil, fmt.Errorf("mismatched session ID while creating inbound group session from key backup") + return nil, ErrMismatchingSessionIDInKeyBackup } var maxAge time.Duration @@ -202,7 +209,7 @@ func (mach *OlmMachine) ImportRoomKeyFromBackup(ctx context.Context, version id. } err = mach.CryptoStore.PutGroupSession(ctx, igs) if err != nil { - return nil, fmt.Errorf("failed to store new inbound group session: %w", err) + return nil, fmt.Errorf("%w: %w", ErrFailedToStoreNewInboundGroupSessionFromBackup, err) } mach.markSessionReceived(ctx, roomID, sessionID, firstKnownIndex) return igs, nil From 5094eea718641b820671749e92f482ff0516fcf2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 1 May 2025 16:28:21 +0300 Subject: [PATCH 047/581] bridgev2/networkinterface: allow clients to generate transaction IDs --- bridgev2/database/message.go | 3 +++ bridgev2/networkid/bridgeid.go | 5 +++++ bridgev2/networkinterface.go | 7 +++++++ bridgev2/portal.go | 23 +++++++++++++++++++++++ 4 files changed, 38 insertions(+) diff --git a/bridgev2/database/message.go b/bridgev2/database/message.go index 42581c6e..fd6b65d8 100644 --- a/bridgev2/database/message.go +++ b/bridgev2/database/message.go @@ -214,6 +214,9 @@ func (m *Message) updateSQLVariables() []any { } const FakeMXIDPrefix = "~fake:" +const TxnMXIDPrefix = "~txn:" +const NetworkTxnMXIDPrefix = TxnMXIDPrefix + "network:" +const RandomTxnMXIDPrefix = TxnMXIDPrefix + "random:" func (m *Message) SetFakeMXID() { hash := sha256.Sum256([]byte(m.ID)) diff --git a/bridgev2/networkid/bridgeid.go b/bridgev2/networkid/bridgeid.go index d78813eb..443d3655 100644 --- a/bridgev2/networkid/bridgeid.go +++ b/bridgev2/networkid/bridgeid.go @@ -94,6 +94,11 @@ type MessageID string // Transaction IDs must be unique across users in a room, but don't need to be unique across different rooms. type TransactionID string +// RawTransactionID is a client-generated identifier for a message send operation on the remote network. +// +// Unlike TransactionID, RawTransactionID's are only used for sending and don't have any uniqueness requirements. +type RawTransactionID string + // PartID is the ID of a message part on the remote network (e.g. index of image in album). // // Part IDs are only unique within a message, not globally. diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 6ea0b02c..14e3a681 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -259,6 +259,11 @@ type IdentifierValidatingNetwork interface { ValidateUserID(id networkid.UserID) bool } +type TransactionIDGeneratingNetwork interface { + NetworkConnector + GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID +} + type PortalBridgeInfoFillingNetwork interface { NetworkConnector FillPortalBridgeInfo(portal *Portal, content *event.BridgeEventContent) @@ -1161,6 +1166,8 @@ type MatrixEventBase[ContentType any] struct { // The original sender user ID. Only present in case the event is being relayed (and Sender is not the same user). OrigSender *OrigSender + + InputTransactionID networkid.RawTransactionID } type MatrixMessage struct { diff --git a/bridgev2/portal.go b/bridgev2/portal.go index d8262d0a..d88f5a7c 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -811,6 +811,13 @@ func (portal *Portal) checkMessageContentCaps(ctx context.Context, caps *event.R return true } +func (portal *Portal) parseInputTransactionID(origSender *OrigSender, evt *event.Event) networkid.RawTransactionID { + if origSender != nil || !strings.HasPrefix(evt.ID.String(), database.NetworkTxnMXIDPrefix) { + return "" + } + return networkid.RawTransactionID(strings.TrimPrefix(evt.ID.String(), database.NetworkTxnMXIDPrefix)) +} + func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) { log := zerolog.Ctx(ctx) var relatesTo *event.RelatesTo @@ -938,6 +945,8 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin Content: msgContent, OrigSender: origSender, Portal: portal, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), }, ThreadRoot: threadRoot, ReplyTo: replyTo, @@ -1173,6 +1182,8 @@ func (portal *Portal) handleMatrixEdit(ctx context.Context, sender *UserLogin, o Content: content, OrigSender: origSender, Portal: portal, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), }, EditTarget: editTarget, }) @@ -1224,6 +1235,8 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi Event: evt, Content: content, Portal: portal, + + InputTransactionID: portal.parseInputTransactionID(nil, evt), }, TargetMessage: reactionTarget, } @@ -1380,6 +1393,8 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( Content: content, Portal: portal, OrigSender: origSender, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), }, PrevContent: prevContent, }) @@ -1501,6 +1516,8 @@ func (portal *Portal) handleMatrixMembership( Content: content, Portal: portal, OrigSender: origSender, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), }, PrevContent: prevContent, }, @@ -1565,6 +1582,8 @@ func (portal *Portal) handleMatrixPowerLevels( Content: content, Portal: portal, OrigSender: origSender, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), }, PrevContent: prevContent, }, @@ -1651,6 +1670,8 @@ func (portal *Portal) handleMatrixRedaction(ctx context.Context, sender *UserLog Content: content, Portal: portal, OrigSender: origSender, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), }, TargetMessage: redactionTargetMsg, }) @@ -1671,6 +1692,8 @@ func (portal *Portal) handleMatrixRedaction(ctx context.Context, sender *UserLog Content: content, Portal: portal, OrigSender: origSender, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), }, TargetReaction: redactionTargetReaction, }) From e491e87309b05692445bf1a27175535268a5b01e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 2 May 2025 01:56:46 +0300 Subject: [PATCH 048/581] commands: panic on duplicate registration --- commands/container.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/commands/container.go b/commands/container.go index e9dfd5e9..bc685b7b 100644 --- a/commands/container.go +++ b/commands/container.go @@ -40,11 +40,19 @@ func (cont *CommandContainer[MetaType]) Register(handlers ...*Handler[MetaType]) func (cont *CommandContainer[MetaType]) registerOne(handler *Handler[MetaType]) { if strings.ToLower(handler.Name) != handler.Name { panic(fmt.Errorf("command %q is not lowercase", handler.Name)) + } else if val, alreadyExists := cont.commands[handler.Name]; alreadyExists && val != handler { + panic(fmt.Errorf("tried to register command %q, but it's already registered", handler.Name)) + } else if aliasTarget, alreadyExists := cont.aliases[handler.Name]; alreadyExists { + panic(fmt.Errorf("tried to register command %q, but it's already registered as an alias for %q", handler.Name, aliasTarget)) } cont.commands[handler.Name] = handler for _, alias := range handler.Aliases { if strings.ToLower(alias) != alias { panic(fmt.Errorf("alias %q is not lowercase", alias)) + } else if val, alreadyExists := cont.aliases[alias]; alreadyExists && val != handler.Name { + panic(fmt.Errorf("tried to register alias %q for %q, but it's already registered for %q", alias, handler.Name, cont.aliases[alias])) + } else if _, alreadyExists = cont.commands[alias]; alreadyExists { + panic(fmt.Errorf("tried to register alias %q for %q, but it's already registered as a command", alias, handler.Name)) } cont.aliases[alias] = handler.Name } From 2b973cac00c67e125d562de80d370aae7b6ad0c3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 2 May 2025 02:05:11 +0300 Subject: [PATCH 049/581] commands: include handler chain in command events --- commands/event.go | 1 + commands/processor.go | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/commands/event.go b/commands/event.go index 13b1d7c1..8d51eadd 100644 --- a/commands/event.go +++ b/commands/event.go @@ -27,6 +27,7 @@ type Event[MetaType any] struct { // ParentCommands is the chain of commands leading up to this command. // This is only set if the command is a subcommand. ParentCommands []string + ParentHandlers []*Handler[MetaType] // Command is the lowercased first word of the message. Command string // Args are the rest of the message split by whitespace ([strings.Fields]). diff --git a/commands/processor.go b/commands/processor.go index a7c1d941..da802fd9 100644 --- a/commands/processor.go +++ b/commands/processor.go @@ -82,8 +82,9 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) for handler.subcommandContainer != nil && len(parsed.Args) > 0 { subHandler := handler.subcommandContainer.GetHandler(strings.ToLower(parsed.Args[0])) if subHandler != nil { - handler = subHandler parsed.ParentCommands = append(parsed.ParentCommands, parsed.Command) + parsed.ParentHandlers = append(parsed.ParentHandlers, handler) + handler = subHandler handlerChain.Str(subHandler.Name) parsed.Command = strings.ToLower(parsed.ShiftArg()) parsed.Handler = subHandler From 441349efac9ec84ef8162d9dea25b59853180e9a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 3 May 2025 03:00:31 +0300 Subject: [PATCH 050/581] synapseadmin: add SuspendAccount method --- synapseadmin/userapi.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/synapseadmin/userapi.go b/synapseadmin/userapi.go index 9cbb17e4..d3672367 100644 --- a/synapseadmin/userapi.go +++ b/synapseadmin/userapi.go @@ -106,6 +106,19 @@ func (cli *Client) DeactivateAccount(ctx context.Context, userID id.UserID, req return err } +type ReqSuspendUser struct { + Suspend bool `json:"suspend"` +} + +// SuspendAccount suspends or unsuspends a specific local user account. +// +// https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#suspendunsuspend-account +func (cli *Client) SuspendAccount(ctx context.Context, userID id.UserID, req ReqSuspendUser) error { + reqURL := cli.BuildAdminURL("v1", "suspend", userID) + _, err := cli.MakeRequest(ctx, http.MethodPut, reqURL, &req, nil) + return err +} + type ReqCreateOrModifyAccount struct { Password string `json:"password,omitempty"` LogoutDevices *bool `json:"logout_devices,omitempty"` From 36781e7de4af886bf4a51fed1714be6a96d6516a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 3 May 2025 01:43:56 +0300 Subject: [PATCH 051/581] federation: move server name cache to separate type --- bridgev2/matrix/provisioning.go | 2 +- federation/cache.go | 71 +++++++++++++++++++++++++++++++++ federation/client.go | 4 +- federation/client_test.go | 2 +- federation/httpclient.go | 62 ++++++++++++---------------- 5 files changed, 101 insertions(+), 40 deletions(-) create mode 100644 federation/cache.go diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 126d54de..d809d039 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -103,7 +103,7 @@ func (prov *ProvisioningAPI) Init() { prov.logins = make(map[string]*ProvLogin) prov.net = prov.br.Bridge.Network prov.log = prov.br.Log.With().Str("component", "provisioning").Logger() - prov.fedClient = federation.NewClient("", nil) + prov.fedClient = federation.NewClient("", nil, nil) prov.fedClient.HTTP.Timeout = 20 * time.Second tp := prov.fedClient.HTTP.Transport.(*federation.ServerResolvingTransport) tp.Dialer.Timeout = 10 * time.Second diff --git a/federation/cache.go b/federation/cache.go new file mode 100644 index 00000000..95d096fa --- /dev/null +++ b/federation/cache.go @@ -0,0 +1,71 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "sync" + "time" +) + +// ResolutionCache is an interface for caching resolved server names. +type ResolutionCache interface { + StoreResolution(*ResolvedServerName) + // LoadResolution loads a resolved server name from the cache. + // Expired entries MUST NOT be returned. + LoadResolution(serverName string) (*ResolvedServerName, error) +} + +type KeyCache interface { + StoreKeys(*ServerKeyResponse) + LoadKeys(serverName string) (*ServerKeyResponse, error) +} + +type InMemoryCache struct { + resolutions map[string]*ResolvedServerName + resolutionsLock sync.RWMutex + keys map[string]*ServerKeyResponse + keysLock sync.RWMutex +} + +func NewInMemoryCache() *InMemoryCache { + return &InMemoryCache{ + resolutions: make(map[string]*ResolvedServerName), + keys: make(map[string]*ServerKeyResponse), + } +} + +func (c *InMemoryCache) StoreResolution(resolution *ResolvedServerName) { + c.resolutionsLock.Lock() + defer c.resolutionsLock.Unlock() + c.resolutions[resolution.ServerName] = resolution +} + +func (c *InMemoryCache) LoadResolution(serverName string) (*ResolvedServerName, error) { + c.resolutionsLock.RLock() + defer c.resolutionsLock.RUnlock() + resolution, ok := c.resolutions[serverName] + if !ok || time.Until(resolution.Expires) < 0 { + return nil, nil + } + return resolution, nil +} + +func (c *InMemoryCache) StoreKeys(keys *ServerKeyResponse) { + c.keysLock.Lock() + defer c.keysLock.Unlock() + c.keys[keys.ServerName] = keys +} + +func (c *InMemoryCache) LoadKeys(serverName string) (*ServerKeyResponse, error) { + c.keysLock.RLock() + defer c.keysLock.RUnlock() + keys, ok := c.keys[serverName] + if !ok || time.Until(keys.ValidUntilTS.Time) < 0 { + return nil, nil + } + return keys, nil +} diff --git a/federation/client.go b/federation/client.go index 7fc630b7..7aff19c9 100644 --- a/federation/client.go +++ b/federation/client.go @@ -32,10 +32,10 @@ type Client struct { Key *SigningKey } -func NewClient(serverName string, key *SigningKey) *Client { +func NewClient(serverName string, key *SigningKey, cache ResolutionCache) *Client { return &Client{ HTTP: &http.Client{ - Transport: NewServerResolvingTransport(), + Transport: NewServerResolvingTransport(cache), Timeout: 120 * time.Second, }, UserAgent: mautrix.DefaultUserAgent, diff --git a/federation/client_test.go b/federation/client_test.go index ba3c3ed4..ece399ea 100644 --- a/federation/client_test.go +++ b/federation/client_test.go @@ -16,7 +16,7 @@ import ( ) func TestClient_Version(t *testing.T) { - cli := federation.NewClient("", nil) + cli := federation.NewClient("", nil, nil) resp, err := cli.Version(context.TODO(), "maunium.net") require.NoError(t, err) require.Equal(t, "Synapse", resp.Server.Name) diff --git a/federation/httpclient.go b/federation/httpclient.go index d6d97280..cbb1674d 100644 --- a/federation/httpclient.go +++ b/federation/httpclient.go @@ -12,7 +12,6 @@ import ( "net" "net/http" "sync" - "time" ) // ServerResolvingTransport is an http.RoundTripper that resolves Matrix server names before sending requests. @@ -22,17 +21,20 @@ type ServerResolvingTransport struct { Transport *http.Transport Dialer *net.Dialer - cache map[string]*ResolvedServerName - resolveLocks map[string]*sync.Mutex - cacheLock sync.Mutex + cache ResolutionCache + + resolveLocks map[string]*sync.Mutex + resolveLocksLock sync.Mutex } -func NewServerResolvingTransport() *ServerResolvingTransport { +func NewServerResolvingTransport(cache ResolutionCache) *ServerResolvingTransport { + if cache == nil { + cache = NewInMemoryCache() + } srt := &ServerResolvingTransport{ - cache: make(map[string]*ResolvedServerName), resolveLocks: make(map[string]*sync.Mutex), - - Dialer: &net.Dialer{}, + cache: cache, + Dialer: &net.Dialer{}, } srt.Transport = &http.Transport{ DialContext: srt.DialContext, @@ -72,37 +74,25 @@ func (srt *ServerResolvingTransport) RoundTrip(request *http.Request) (*http.Res } func (srt *ServerResolvingTransport) resolve(ctx context.Context, serverName string) (*ResolvedServerName, error) { - res, lock := srt.getResolveCache(serverName) - if res != nil { - return res, nil + srt.resolveLocksLock.Lock() + lock, ok := srt.resolveLocks[serverName] + if !ok { + lock = &sync.Mutex{} + srt.resolveLocks[serverName] = lock } + srt.resolveLocksLock.Unlock() + lock.Lock() defer lock.Unlock() - res, _ = srt.getResolveCache(serverName) - if res != nil { + res, err := srt.cache.LoadResolution(serverName) + if err != nil { + return nil, fmt.Errorf("failed to read cache: %w", err) + } else if res != nil { + return res, nil + } else if res, err = ResolveServerName(ctx, serverName, srt.ResolveOpts); err != nil { + return nil, err + } else { + srt.cache.StoreResolution(res) return res, nil } - var err error - res, err = ResolveServerName(ctx, serverName, srt.ResolveOpts) - if err != nil { - return nil, err - } - srt.cacheLock.Lock() - srt.cache[serverName] = res - srt.cacheLock.Unlock() - return res, nil -} - -func (srt *ServerResolvingTransport) getResolveCache(serverName string) (*ResolvedServerName, *sync.Mutex) { - srt.cacheLock.Lock() - defer srt.cacheLock.Unlock() - if val, ok := srt.cache[serverName]; ok && time.Until(val.Expires) > 0 { - return val, nil - } - rl, ok := srt.resolveLocks[serverName] - if !ok { - rl = &sync.Mutex{} - srt.resolveLocks[serverName] = rl - } - return nil, rl } From 66e7d834cc7cdf5415d9bb88e45b530606ee0fd5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 3 May 2025 01:44:07 +0300 Subject: [PATCH 052/581] federation/resolution: parse cache-control headers for .well-known --- federation/resolution.go | 41 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/federation/resolution.go b/federation/resolution.go index 24085282..69d4d3bf 100644 --- a/federation/resolution.go +++ b/federation/resolution.go @@ -120,6 +120,38 @@ func RequestSRV(ctx context.Context, cli *net.Resolver, hostname string) ([]*net return target, err } +func parseCacheControl(resp *http.Response) time.Duration { + cc := resp.Header.Get("Cache-Control") + if cc == "" { + return 0 + } + parts := strings.Split(cc, ",") + for _, part := range parts { + kv := strings.SplitN(strings.TrimSpace(part), "=", 1) + switch kv[0] { + case "no-cache", "no-store": + return 0 + case "max-age": + if len(kv) < 2 { + continue + } + maxAge, err := strconv.Atoi(kv[1]) + if err != nil || maxAge < 0 { + continue + } + age, _ := strconv.Atoi(resp.Header.Get("Age")) + return time.Duration(maxAge-age) * time.Second + } + } + return 0 +} + +const ( + MinCacheDuration = 1 * time.Hour + MaxCacheDuration = 72 * time.Hour + DefaultCacheDuration = 24 * time.Hour +) + // RequestWellKnown sends a request to the well-known endpoint of a server and returns the response, // plus the time when the cache should expire. func RequestWellKnown(ctx context.Context, cli *http.Client, hostname string) (*RespWellKnown, time.Time, error) { @@ -147,6 +179,13 @@ func RequestWellKnown(ctx context.Context, cli *http.Client, hostname string) (* } else if respData.Server == "" { return nil, time.Time{}, errors.New("server name not found in response") } - // TODO parse cache-control header + cacheDuration := parseCacheControl(resp) + if cacheDuration <= 0 { + cacheDuration = DefaultCacheDuration + } else if cacheDuration < MinCacheDuration { + cacheDuration = MinCacheDuration + } else if cacheDuration > MaxCacheDuration { + cacheDuration = MaxCacheDuration + } return &respData, time.Now().Add(24 * time.Hour), nil } From 44de13a7de37cd8f699002a59a6b53f2e00143d2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 3 May 2025 01:53:48 +0300 Subject: [PATCH 053/581] federation/keyserver: use shared utilities for writing responses --- federation/keyserver.go | 53 ++++++++++------------------------------- 1 file changed, 13 insertions(+), 40 deletions(-) diff --git a/federation/keyserver.go b/federation/keyserver.go index 3e74bfdf..505be44f 100644 --- a/federation/keyserver.go +++ b/federation/keyserver.go @@ -8,12 +8,12 @@ package federation import ( "encoding/json" - "fmt" "net/http" "strconv" "time" "github.com/gorilla/mux" + "go.mau.fi/util/exhttp" "go.mau.fi/util/jsontime" "maunium.net/go/mautrix" @@ -58,25 +58,13 @@ func (ks *KeyServer) Register(r *mux.Router) { keyRouter.HandleFunc("/v2/query/{serverName}", ks.GetQueryKeys).Methods(http.MethodGet) keyRouter.HandleFunc("/v2/query", ks.PostQueryKeys).Methods(http.MethodPost) keyRouter.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ - ErrCode: mautrix.MUnrecognized.ErrCode, - Err: "Unrecognized endpoint", - }) + mautrix.MUnrecognized.WithStatus(http.StatusNotFound).WithMessage("Unrecognized endpoint").Write(w) }) keyRouter.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - jsonResponse(w, http.StatusMethodNotAllowed, &mautrix.RespError{ - ErrCode: mautrix.MUnrecognized.ErrCode, - Err: "Invalid method for endpoint", - }) + mautrix.MUnrecognized.WithStatus(http.StatusMethodNotAllowed).WithMessage("Invalid method for endpoint").Write(w) }) } -func jsonResponse(w http.ResponseWriter, code int, data any) { - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(code) - _ = json.NewEncoder(w).Encode(data) -} - // RespWellKnown is the response body for the `GET /.well-known/matrix/server` endpoint. type RespWellKnown struct { Server string `json:"m.server"` @@ -87,12 +75,9 @@ type RespWellKnown struct { // https://spec.matrix.org/v1.9/server-server-api/#get_well-knownmatrixserver func (ks *KeyServer) GetWellKnown(w http.ResponseWriter, r *http.Request) { if ks.WellKnownTarget == "" { - jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ - ErrCode: mautrix.MNotFound.ErrCode, - Err: "No well-known target set", - }) + mautrix.MNotFound.WithMessage("No well-known target set").Write(w) } else { - jsonResponse(w, http.StatusOK, &RespWellKnown{Server: ks.WellKnownTarget}) + exhttp.WriteJSONResponse(w, http.StatusOK, &RespWellKnown{Server: ks.WellKnownTarget}) } } @@ -105,7 +90,7 @@ type RespServerVersion struct { // // https://spec.matrix.org/v1.9/server-server-api/#get_matrixfederationv1version func (ks *KeyServer) GetServerVersion(w http.ResponseWriter, r *http.Request) { - jsonResponse(w, http.StatusOK, &RespServerVersion{Server: ks.Version}) + exhttp.WriteJSONResponse(w, http.StatusOK, &RespServerVersion{Server: ks.Version}) } // GetServerKey implements the `GET /_matrix/key/v2/server` endpoint. @@ -114,12 +99,9 @@ func (ks *KeyServer) GetServerVersion(w http.ResponseWriter, r *http.Request) { func (ks *KeyServer) GetServerKey(w http.ResponseWriter, r *http.Request) { domain, key := ks.KeyProvider.Get(r) if key == nil { - jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ - ErrCode: mautrix.MNotFound.ErrCode, - Err: fmt.Sprintf("No signing key found for %q", r.Host), - }) + mautrix.MNotFound.WithMessage("No signing key found for %q", r.Host).Write(w) } else { - jsonResponse(w, http.StatusOK, key.GenerateKeyResponse(domain, nil)) + exhttp.WriteJSONResponse(w, http.StatusOK, key.GenerateKeyResponse(domain, nil)) } } @@ -144,10 +126,7 @@ func (ks *KeyServer) PostQueryKeys(w http.ResponseWriter, r *http.Request) { var req ReqQueryKeys err := json.NewDecoder(r.Body).Decode(&req) if err != nil { - jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ - ErrCode: mautrix.MBadJSON.ErrCode, - Err: fmt.Sprintf("failed to parse request: %v", err), - }) + mautrix.MBadJSON.WithMessage("failed to parse request: %v", err).Write(w) return } @@ -165,7 +144,7 @@ func (ks *KeyServer) PostQueryKeys(w http.ResponseWriter, r *http.Request) { } } } - jsonResponse(w, http.StatusOK, resp) + exhttp.WriteJSONResponse(w, http.StatusOK, resp) } // GetQueryKeysResponse is the response body for the `GET /_matrix/key/v2/query/{serverName}` endpoint @@ -181,16 +160,10 @@ func (ks *KeyServer) GetQueryKeys(w http.ResponseWriter, r *http.Request) { minimumValidUntilTSString := r.URL.Query().Get("minimum_valid_until_ts") minimumValidUntilTS, err := strconv.ParseInt(minimumValidUntilTSString, 10, 64) if err != nil && minimumValidUntilTSString != "" { - jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ - ErrCode: mautrix.MInvalidParam.ErrCode, - Err: fmt.Sprintf("failed to parse ?minimum_valid_until_ts: %v", err), - }) + mautrix.MInvalidParam.WithMessage("failed to parse ?minimum_valid_until_ts: %v", err).Write(w) return } else if time.UnixMilli(minimumValidUntilTS).After(time.Now().Add(24 * time.Hour)) { - jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ - ErrCode: mautrix.MInvalidParam.ErrCode, - Err: "minimum_valid_until_ts may not be more than 24 hours in the future", - }) + mautrix.MInvalidParam.WithMessage("minimum_valid_until_ts may not be more than 24 hours in the future").Write(w) return } resp := &GetQueryKeysResponse{ @@ -199,5 +172,5 @@ func (ks *KeyServer) GetQueryKeys(w http.ResponseWriter, r *http.Request) { if domain, key := ks.KeyProvider.Get(r); key != nil && domain == serverName { resp.ServerKeys = append(resp.ServerKeys, key.GenerateKeyResponse(serverName, nil)) } - jsonResponse(w, http.StatusOK, resp) + exhttp.WriteJSONResponse(w, http.StatusOK, resp) } From b1f0b1732f22917179527edb5c93bce075ab646d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 3 May 2025 02:08:14 +0300 Subject: [PATCH 054/581] federation/cache: add noop cache --- federation/cache.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/federation/cache.go b/federation/cache.go index 95d096fa..a491dbd1 100644 --- a/federation/cache.go +++ b/federation/cache.go @@ -31,6 +31,11 @@ type InMemoryCache struct { keysLock sync.RWMutex } +var ( + _ ResolutionCache = (*InMemoryCache)(nil) + _ KeyCache = (*InMemoryCache)(nil) +) + func NewInMemoryCache() *InMemoryCache { return &InMemoryCache{ resolutions: make(map[string]*ResolvedServerName), @@ -69,3 +74,15 @@ func (c *InMemoryCache) LoadKeys(serverName string) (*ServerKeyResponse, error) } return keys, nil } + +type NoopCache struct{} + +func (*NoopCache) StoreKeys(_ *ServerKeyResponse) {} +func (*NoopCache) LoadKeys(_ string) (*ServerKeyResponse, error) { return nil, nil } +func (*NoopCache) StoreResolution(_ *ResolvedServerName) {} +func (*NoopCache) LoadResolution(_ string) (*ResolvedServerName, error) { return nil, nil } + +var ( + _ ResolutionCache = (*NoopCache)(nil) + _ KeyCache = (*NoopCache)(nil) +) From 9c3e1b5904f08796cc924620beb4821afbe6a808 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 3 May 2025 02:08:55 +0300 Subject: [PATCH 055/581] federation/signingkey: add support for roundtripping ServerKeyResponses --- federation/client.go | 3 +-- federation/signingkey.go | 54 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/federation/client.go b/federation/client.go index 7aff19c9..b7927b91 100644 --- a/federation/client.go +++ b/federation/client.go @@ -9,7 +9,6 @@ package federation import ( "bytes" "context" - "encoding/base64" "encoding/json" "fmt" "io" @@ -414,6 +413,6 @@ func (r *signableRequest) Sign(key *SigningKey) (string, error) { r.Origin, r.Destination, key.ID, - base64.RawURLEncoding.EncodeToString(sig), + sig, ), nil } diff --git a/federation/signingkey.go b/federation/signingkey.go index 67751b48..a74b4d6a 100644 --- a/federation/signingkey.go +++ b/federation/signingkey.go @@ -11,9 +11,11 @@ import ( "encoding/base64" "encoding/json" "fmt" + "maps" "strings" "time" + "github.com/tidwall/sjson" "go.mau.fi/util/jsontime" "maunium.net/go/mautrix/crypto/canonicaljson" @@ -77,6 +79,46 @@ type ServerKeyResponse struct { OldVerifyKeys map[id.KeyID]OldVerifyKey `json:"old_verify_keys,omitempty"` Signatures map[string]map[id.KeyID]string `json:"signatures,omitempty"` ValidUntilTS jsontime.UnixMilli `json:"valid_until_ts"` + + Extra map[string]any `json:"-"` +} + +type marshalableSKR ServerKeyResponse + +func (skr *ServerKeyResponse) MarshalJSON() ([]byte, error) { + if skr.Extra == nil { + return json.Marshal((*marshalableSKR)(skr)) + } + marshalable := maps.Clone(skr.Extra) + marshalable["server_name"] = skr.ServerName + marshalable["verify_keys"] = skr.VerifyKeys + marshalable["old_verify_keys"] = skr.OldVerifyKeys + marshalable["signatures"] = skr.Signatures + marshalable["valid_until_ts"] = skr.ValidUntilTS + return json.Marshal(skr.Extra) +} + +func (skr *ServerKeyResponse) UnmarshalJSON(data []byte) error { + err := json.Unmarshal(data, (*marshalableSKR)(skr)) + if err != nil { + return err + } + var extra map[string]any + err = json.Unmarshal(data, &extra) + if err != nil { + return err + } + delete(extra, "server_name") + delete(extra, "verify_keys") + delete(extra, "old_verify_keys") + delete(extra, "signatures") + delete(extra, "valid_until_ts") + if len(extra) > 0 { + skr.Extra = extra + } else { + skr.Extra = nil + } + return nil } type ServerVerifyKey struct { @@ -92,12 +134,16 @@ type OldVerifyKey struct { ExpiredTS jsontime.UnixMilli `json:"expired_ts"` } -func (sk *SigningKey) SignJSON(data any) ([]byte, error) { +func (sk *SigningKey) SignJSON(data any) (string, error) { marshaled, err := json.Marshal(data) if err != nil { - return nil, err + return "", err } - return sk.SignRawJSON(marshaled), nil + marshaled, err = sjson.DeleteBytes(marshaled, "signatures") + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(sk.SignRawJSON(marshaled)), nil } func (sk *SigningKey) SignRawJSON(data json.RawMessage) []byte { @@ -120,7 +166,7 @@ func (sk *SigningKey) GenerateKeyResponse(serverName string, oldVerifyKeys map[i } skr.Signatures = map[string]map[id.KeyID]string{ serverName: { - sk.ID: base64.RawURLEncoding.EncodeToString(signature), + sk.ID: signature, }, } return skr From 2d1620ded3716850637d414cf4fe78249d661017 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 3 May 2025 02:09:51 +0300 Subject: [PATCH 056/581] federation/keyserver: add support for returning other servers keys --- federation/keyserver.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/federation/keyserver.go b/federation/keyserver.go index 505be44f..b0faf8fb 100644 --- a/federation/keyserver.go +++ b/federation/keyserver.go @@ -47,6 +47,7 @@ type KeyServer struct { KeyProvider ServerKeyProvider Version ServerVersion WellKnownTarget string + OtherKeys KeyCache } // Register registers the key server endpoints to the given router. @@ -169,8 +170,26 @@ func (ks *KeyServer) GetQueryKeys(w http.ResponseWriter, r *http.Request) { resp := &GetQueryKeysResponse{ ServerKeys: []*ServerKeyResponse{}, } - if domain, key := ks.KeyProvider.Get(r); key != nil && domain == serverName { - resp.ServerKeys = append(resp.ServerKeys, key.GenerateKeyResponse(serverName, nil)) + domain, key := ks.KeyProvider.Get(r) + if domain == serverName { + if key != nil { + resp.ServerKeys = append(resp.ServerKeys, key.GenerateKeyResponse(serverName, nil)) + } + } else if ks.OtherKeys != nil { + otherKey, err := ks.OtherKeys.LoadKeys(serverName) + if err != nil { + mautrix.MUnknown.WithMessage("Failed to load keys from cache").Write(w) + return + } + if key != nil && domain != "" { + signature, err := key.SignJSON(otherKey) + if err == nil { + otherKey.Signatures[domain] = map[id.KeyID]string{ + key.ID: signature, + } + } + } + resp.ServerKeys = append(resp.ServerKeys, otherKey) } exhttp.WriteJSONResponse(w, http.StatusOK, resp) } From 9a02b6428d44b5563bcb87ecaaf788fb55402594 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 4 May 2025 00:06:51 +0300 Subject: [PATCH 057/581] federation/serverauth: implement server side of request authentication --- federation/client.go | 38 +++---- federation/context.go | 30 ++++++ federation/httpclient.go | 6 -- federation/serverauth.go | 218 +++++++++++++++++++++++++++++++++++++++ federation/signingkey.go | 48 +++++++++ 5 files changed, 316 insertions(+), 24 deletions(-) create mode 100644 federation/context.go create mode 100644 federation/serverauth.go diff --git a/federation/client.go b/federation/client.go index b7927b91..93ed759c 100644 --- a/federation/client.go +++ b/federation/client.go @@ -10,7 +10,6 @@ import ( "bytes" "context" "encoding/json" - "fmt" "io" "net/http" "net/url" @@ -373,16 +372,12 @@ func (c *Client) compileRequest(ctx context.Context, params RequestParams) (*htt Message: "client not configured for authentication", } } - var contentAny any - if reqJSON != nil { - contentAny = reqJSON - } auth, err := (&signableRequest{ Method: req.Method, URI: reqURL.RequestURI(), Origin: c.ServerName, Destination: params.ServerName, - Content: contentAny, + Content: reqJSON, }).Sign(c.Key) if err != nil { return nil, mautrix.HTTPError{ @@ -396,11 +391,19 @@ func (c *Client) compileRequest(ctx context.Context, params RequestParams) (*htt } type signableRequest struct { - Method string `json:"method"` - URI string `json:"uri"` - Origin string `json:"origin"` - Destination string `json:"destination"` - Content any `json:"content,omitempty"` + Method string `json:"method"` + URI string `json:"uri"` + Origin string `json:"origin"` + Destination string `json:"destination"` + Content json.RawMessage `json:"content,omitempty"` +} + +func (r *signableRequest) Verify(key id.SigningKey, sig string) bool { + message, err := json.Marshal(r) + if err != nil { + return false + } + return VerifyJSONRaw(key, sig, message) } func (r *signableRequest) Sign(key *SigningKey) (string, error) { @@ -408,11 +411,10 @@ func (r *signableRequest) Sign(key *SigningKey) (string, error) { if err != nil { return "", err } - return fmt.Sprintf( - `X-Matrix origin="%s",destination="%s",key="%s",sig="%s"`, - r.Origin, - r.Destination, - key.ID, - sig, - ), nil + return XMatrixAuth{ + Origin: r.Origin, + Destination: r.Destination, + KeyID: key.ID, + Signature: sig, + }.String(), nil } diff --git a/federation/context.go b/federation/context.go new file mode 100644 index 00000000..8280431f --- /dev/null +++ b/federation/context.go @@ -0,0 +1,30 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "context" + "net/http" +) + +type contextKey int + +const ( + contextKeyIPPort contextKey = iota + contextKeyDestinationServer +) + +func DestinationServerNameFromRequest(r *http.Request) string { + return DestinationServerName(r.Context()) +} + +func DestinationServerName(ctx context.Context) string { + if dest, ok := ctx.Value(contextKeyDestinationServer).(string); ok { + return dest + } + return "" +} diff --git a/federation/httpclient.go b/federation/httpclient.go index cbb1674d..2f8dbb4f 100644 --- a/federation/httpclient.go +++ b/federation/httpclient.go @@ -52,12 +52,6 @@ func (srt *ServerResolvingTransport) DialContext(ctx context.Context, network, a return srt.Dialer.DialContext(ctx, network, addrs[0]) } -type contextKey int - -const ( - contextKeyIPPort contextKey = iota -) - func (srt *ServerResolvingTransport) RoundTrip(request *http.Request) (*http.Response, error) { if request.URL.Scheme != "matrix-federation" { return nil, fmt.Errorf("unsupported scheme: %s", request.URL.Scheme) diff --git a/federation/serverauth.go b/federation/serverauth.go new file mode 100644 index 00000000..fadd500e --- /dev/null +++ b/federation/serverauth.go @@ -0,0 +1,218 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "slices" + "strings" + "sync" + + "github.com/rs/zerolog" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +type ServerAuth struct { + Keys KeyCache + Client *Client + GetDestination func(XMatrixAuth) string + MaxBodySize int64 + + keyFetchLocks map[string]*sync.Mutex + keyFetchLocksLock sync.Mutex +} + +var MUnauthorized = mautrix.RespError{ErrCode: "M_UNAUTHORIZED", StatusCode: http.StatusUnauthorized} + +var ( + ErrMissingAuthHeader = MUnauthorized.WithMessage("Missing Authorization header") + ErrInvalidAuthHeader = MUnauthorized.WithMessage("Authorization header does not start with X-Matrix") + ErrMalformedAuthHeader = MUnauthorized.WithMessage("X-Matrix value is missing required components") + ErrInvalidDestination = MUnauthorized.WithMessage("Invalid destination in X-Matrix header") + ErrFailedToQueryKeys = MUnauthorized.WithMessage("Failed to query server keys") + ErrInvalidSelfSignatures = MUnauthorized.WithMessage("Server keys don't have valid self-signatures") + ErrRequestBodyTooLarge = mautrix.MTooLarge.WithMessage("Request body too large") + ErrInvalidJSONBody = mautrix.MBadJSON.WithMessage("Request body is not valid JSON") + ErrBodyReadFailed = mautrix.MUnknown.WithMessage("Failed to read request body") + ErrInvalidRequestSignature = MUnauthorized.WithMessage("Failed to verify request signature") +) + +type XMatrixAuth struct { + Origin string + Destination string + KeyID id.KeyID + Signature string +} + +func (xma XMatrixAuth) String() string { + return fmt.Sprintf( + `X-Matrix origin="%s",destination="%s",key="%s",sig="%s"`, + xma.Origin, + xma.Destination, + xma.KeyID, + xma.Signature, + ) +} + +func ParseXMatrixAuth(auth string) (xma XMatrixAuth) { + auth = strings.TrimPrefix(auth, "X-Matrix ") + for part := range strings.SplitSeq(auth, ",") { + part = strings.TrimSpace(part) + eqIdx := strings.Index(part, "=") + if eqIdx == -1 || strings.Count(part, "=") > 1 { + continue + } + val := strings.Trim(part[eqIdx+1:], "\"") + switch strings.ToLower(part[:eqIdx]) { + case "origin": + xma.Origin = val + case "destination": + xma.Destination = val + case "key": + xma.KeyID = id.KeyID(val) + case "sig": + xma.Signature = val + } + } + return +} + +func (sa *ServerAuth) GetKeysWithCache(ctx context.Context, serverName string) (*ServerKeyResponse, error) { + sa.keyFetchLocksLock.Lock() + lock, ok := sa.keyFetchLocks[serverName] + if !ok { + lock = &sync.Mutex{} + sa.keyFetchLocks[serverName] = lock + } + sa.keyFetchLocksLock.Unlock() + + lock.Lock() + defer lock.Unlock() + res, err := sa.Keys.LoadKeys(serverName) + if err != nil { + return nil, fmt.Errorf("failed to read cache: %w", err) + } else if res != nil { + return res, nil + } else if res, err = sa.Client.ServerKeys(ctx, serverName); err != nil { + return nil, err + } else { + sa.Keys.StoreKeys(res) + return res, nil + } +} + +type fixedLimitedReader struct { + R io.Reader + N int64 + Err error +} + +func (l *fixedLimitedReader) Read(p []byte) (n int, err error) { + if l.N <= 0 { + return 0, l.Err + } + if int64(len(p)) > l.N { + p = p[0:l.N] + } + n, err = l.R.Read(p) + l.N -= int64(n) + return +} + +func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.RespError) { + defer func() { + _ = r.Body.Close() + }() + log := zerolog.Ctx(r.Context()) + if r.ContentLength > sa.MaxBodySize { + return nil, &ErrRequestBodyTooLarge + } + auth := r.Header.Get("Authorization") + if auth == "" { + return nil, &ErrMissingAuthHeader + } else if !strings.HasPrefix(auth, "X-Matrix ") { + return nil, &ErrInvalidAuthHeader + } + parsed := ParseXMatrixAuth(auth) + if parsed.Origin == "" || parsed.KeyID == "" || parsed.Signature == "" { + log.Trace().Str("auth_header", auth).Msg("Malformed X-Matrix header") + return nil, &ErrMalformedAuthHeader + } + destination := sa.GetDestination(parsed) + if destination == "" || (parsed.Destination != "" && parsed.Destination != destination) { + log.Trace(). + Str("got_destination", parsed.Destination). + Str("expected_destination", destination). + Msg("Invalid destination in X-Matrix header") + return nil, &ErrInvalidDestination + } + resp, err := sa.GetKeysWithCache(r.Context(), parsed.Origin) + if err != nil { + log.Err(err). + Str("server_name", parsed.Origin). + Msg("Failed to query keys to authenticate request") + return nil, &ErrFailedToQueryKeys + } else if !resp.VerifySelfSignature() { + return nil, &ErrInvalidSelfSignatures + } + key, ok := resp.VerifyKeys[parsed.KeyID] + if !ok { + keys := slices.Collect(maps.Keys(resp.VerifyKeys)) + log.Trace(). + Stringer("expected_key_id", parsed.KeyID). + Any("found_key_ids", keys). + Msg("Didn't find expected key ID to verify request") + return nil, ptr.Ptr(MUnauthorized.WithMessage("Key ID %q not found (got %v)", parsed.KeyID, keys)) + } + reqBody, err := io.ReadAll(&fixedLimitedReader{R: r.Body, N: sa.MaxBodySize, Err: ErrRequestBodyTooLarge}) + if errors.Is(err, ErrRequestBodyTooLarge) { + return nil, &ErrRequestBodyTooLarge + } else if err != nil { + log.Err(err). + Str("server_name", parsed.Origin). + Msg("Failed to read request body to authenticate") + return nil, &ErrBodyReadFailed + } else if !json.Valid(reqBody) { + return nil, &ErrInvalidJSONBody + } + valid := (&signableRequest{ + Method: r.Method, + URI: r.URL.RawPath, + Origin: parsed.Origin, + Destination: destination, + Content: reqBody, + }).Verify(key.Key, parsed.Signature) + if !valid { + log.Trace().Msg("Request has invalid signature") + return nil, &ErrInvalidRequestSignature + } + ctx := context.WithValue(r.Context(), contextKeyDestinationServer, destination) + ctx = log.With().Str("destination_server_name", destination).Logger().WithContext(ctx) + modifiedReq := r.WithContext(ctx) + modifiedReq.Body = io.NopCloser(bytes.NewReader(reqBody)) + return modifiedReq, nil +} + +func (sa *ServerAuth) AuthenticateMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if modifiedReq, err := sa.Authenticate(r); err != nil { + err.Write(w) + } else { + next.ServeHTTP(w, modifiedReq) + } + }) +} diff --git a/federation/signingkey.go b/federation/signingkey.go index a74b4d6a..54c62492 100644 --- a/federation/signingkey.go +++ b/federation/signingkey.go @@ -15,7 +15,9 @@ import ( "strings" "time" + "github.com/tidwall/gjson" "github.com/tidwall/sjson" + "go.mau.fi/util/exgjson" "go.mau.fi/util/jsontime" "maunium.net/go/mautrix/crypto/canonicaljson" @@ -83,6 +85,52 @@ type ServerKeyResponse struct { Extra map[string]any `json:"-"` } +func (skr *ServerKeyResponse) VerifySelfSignature() bool { + for keyID, key := range skr.VerifyKeys { + if !VerifyJSON(skr.ServerName, keyID, key.Key, skr) { + return false + } + } + return true +} + +func VerifyJSON(serverName string, keyID id.KeyID, key id.SigningKey, data any) bool { + var err error + message, ok := data.(json.RawMessage) + if !ok { + message, err = json.Marshal(data) + if err != nil { + return false + } + } + sigVal := gjson.GetBytes(message, exgjson.Path("signatures", serverName, string(keyID))) + if sigVal.Type != gjson.String { + return false + } + message, err = sjson.DeleteBytes(message, "signatures") + if err != nil { + return false + } + message, err = sjson.DeleteBytes(message, "unsigned") + if err != nil { + return false + } + return VerifyJSONRaw(key, sigVal.Str, message) +} + +func VerifyJSONRaw(key id.SigningKey, sig string, message json.RawMessage) bool { + sigBytes, err := base64.RawURLEncoding.DecodeString(sig) + if err != nil { + return false + } + keyBytes, err := base64.RawStdEncoding.DecodeString(string(key)) + if err != nil { + return false + } + message = canonicaljson.CanonicalJSONAssumeValid(message) + return ed25519.Verify(keyBytes, message, sigBytes) +} + type marshalableSKR ServerKeyResponse func (skr *ServerKeyResponse) MarshalJSON() ([]byte, error) { From d145f008635f8bbdddbe9be44e4344816fdf1248 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 4 May 2025 00:39:43 +0300 Subject: [PATCH 058/581] federation/serverauth: cache key querying errors --- federation/cache.go | 67 ++++++++++++++++++++++++++++++++++++++-- federation/serverauth.go | 57 +++++++++++++++++++++++++++------- federation/signingkey.go | 9 ++++++ 3 files changed, 119 insertions(+), 14 deletions(-) diff --git a/federation/cache.go b/federation/cache.go index a491dbd1..301091b3 100644 --- a/federation/cache.go +++ b/federation/cache.go @@ -7,6 +7,9 @@ package federation import ( + "errors" + "fmt" + "math" "sync" "time" ) @@ -21,13 +24,19 @@ type ResolutionCache interface { type KeyCache interface { StoreKeys(*ServerKeyResponse) + StoreFetchError(serverName string, err error) + ShouldReQuery(serverName string) bool LoadKeys(serverName string) (*ServerKeyResponse, error) } type InMemoryCache struct { + MinKeyRefetchDelay time.Duration + resolutions map[string]*ResolvedServerName resolutionsLock sync.RWMutex keys map[string]*ServerKeyResponse + lastReQueryAt map[string]time.Time + lastError map[string]*resolutionErrorCache keysLock sync.RWMutex } @@ -38,8 +47,11 @@ var ( func NewInMemoryCache() *InMemoryCache { return &InMemoryCache{ - resolutions: make(map[string]*ResolvedServerName), - keys: make(map[string]*ServerKeyResponse), + resolutions: make(map[string]*ResolvedServerName), + keys: make(map[string]*ServerKeyResponse), + lastReQueryAt: make(map[string]time.Time), + lastError: make(map[string]*resolutionErrorCache), + MinKeyRefetchDelay: 1 * time.Hour, } } @@ -63,22 +75,73 @@ func (c *InMemoryCache) StoreKeys(keys *ServerKeyResponse) { c.keysLock.Lock() defer c.keysLock.Unlock() c.keys[keys.ServerName] = keys + delete(c.lastError, keys.ServerName) } +type resolutionErrorCache struct { + Error error + Time time.Time + Count int +} + +const MaxBackoff = 7 * 24 * time.Hour + +func (rec *resolutionErrorCache) ShouldRetry() bool { + backoff := time.Duration(math.Exp(float64(rec.Count))) * time.Second + return time.Since(rec.Time) > backoff +} + +var ErrRecentKeyQueryFailed = errors.New("last retry was too recent") + func (c *InMemoryCache) LoadKeys(serverName string) (*ServerKeyResponse, error) { c.keysLock.RLock() defer c.keysLock.RUnlock() keys, ok := c.keys[serverName] if !ok || time.Until(keys.ValidUntilTS.Time) < 0 { + err, ok := c.lastError[serverName] + if ok && !err.ShouldRetry() { + return nil, fmt.Errorf( + "%w (%s ago) and failed with %w", + ErrRecentKeyQueryFailed, + time.Since(err.Time).String(), + err.Error, + ) + } return nil, nil } return keys, nil } +func (c *InMemoryCache) StoreFetchError(serverName string, err error) { + c.keysLock.Lock() + defer c.keysLock.Unlock() + errorCache, ok := c.lastError[serverName] + if ok { + errorCache.Time = time.Now() + errorCache.Error = err + errorCache.Count++ + } else { + c.lastError[serverName] = &resolutionErrorCache{Error: err, Time: time.Now(), Count: 1} + } +} + +func (c *InMemoryCache) ShouldReQuery(serverName string) bool { + c.keysLock.Lock() + defer c.keysLock.Unlock() + lastQuery, ok := c.lastReQueryAt[serverName] + if ok && time.Since(lastQuery) < c.MinKeyRefetchDelay { + return false + } + c.lastReQueryAt[serverName] = time.Now() + return true +} + type NoopCache struct{} func (*NoopCache) StoreKeys(_ *ServerKeyResponse) {} func (*NoopCache) LoadKeys(_ string) (*ServerKeyResponse, error) { return nil, nil } +func (*NoopCache) StoreFetchError(_ string, _ error) {} +func (*NoopCache) ShouldReQuery(_ string) bool { return true } func (*NoopCache) StoreResolution(_ *ResolvedServerName) {} func (*NoopCache) LoadResolution(_ string) (*ResolvedServerName, error) { return nil, nil } diff --git a/federation/serverauth.go b/federation/serverauth.go index fadd500e..ef4ed246 100644 --- a/federation/serverauth.go +++ b/federation/serverauth.go @@ -36,6 +36,16 @@ type ServerAuth struct { keyFetchLocksLock sync.Mutex } +func NewServerAuth(client *Client, keyCache KeyCache, getDestination func(auth XMatrixAuth) string) *ServerAuth { + return &ServerAuth{ + Keys: keyCache, + Client: client, + GetDestination: getDestination, + MaxBodySize: 50 * 1024 * 1024, + keyFetchLocks: make(map[string]*sync.Mutex), + } +} + var MUnauthorized = mautrix.RespError{ErrCode: "M_UNAUTHORIZED", StatusCode: http.StatusUnauthorized} var ( @@ -91,7 +101,14 @@ func ParseXMatrixAuth(auth string) (xma XMatrixAuth) { return } -func (sa *ServerAuth) GetKeysWithCache(ctx context.Context, serverName string) (*ServerKeyResponse, error) { +func (sa *ServerAuth) GetKeysWithCache(ctx context.Context, serverName string, keyID id.KeyID) (*ServerKeyResponse, error) { + res, err := sa.Keys.LoadKeys(serverName) + if err != nil { + return nil, fmt.Errorf("failed to read cache: %w", err) + } else if res.HasKey(keyID) { + return res, nil + } + sa.keyFetchLocksLock.Lock() lock, ok := sa.keyFetchLocks[serverName] if !ok { @@ -102,17 +119,27 @@ func (sa *ServerAuth) GetKeysWithCache(ctx context.Context, serverName string) ( lock.Lock() defer lock.Unlock() - res, err := sa.Keys.LoadKeys(serverName) + res, err = sa.Keys.LoadKeys(serverName) if err != nil { return nil, fmt.Errorf("failed to read cache: %w", err) } else if res != nil { - return res, nil - } else if res, err = sa.Client.ServerKeys(ctx, serverName); err != nil { - return nil, err - } else { - sa.Keys.StoreKeys(res) - return res, nil + if res.HasKey(keyID) { + return res, nil + } else if !sa.Keys.ShouldReQuery(serverName) { + zerolog.Ctx(ctx).Trace(). + Str("server_name", serverName). + Stringer("key_id", keyID). + Msg("Not sending key request for missing key ID, last query was too recent") + return res, nil + } } + res, err = sa.Client.ServerKeys(ctx, serverName) + if err != nil { + sa.Keys.StoreFetchError(serverName, err) + return nil, err + } + sa.Keys.StoreKeys(res) + return res, nil } type fixedLimitedReader struct { @@ -160,11 +187,17 @@ func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.Res Msg("Invalid destination in X-Matrix header") return nil, &ErrInvalidDestination } - resp, err := sa.GetKeysWithCache(r.Context(), parsed.Origin) + resp, err := sa.GetKeysWithCache(r.Context(), parsed.Origin, parsed.KeyID) if err != nil { - log.Err(err). - Str("server_name", parsed.Origin). - Msg("Failed to query keys to authenticate request") + if !errors.Is(err, ErrRecentKeyQueryFailed) { + log.Err(err). + Str("server_name", parsed.Origin). + Msg("Failed to query keys to authenticate request") + } else { + log.Trace().Err(err). + Str("server_name", parsed.Origin). + Msg("Failed to query keys to authenticate request (cached error)") + } return nil, &ErrFailedToQueryKeys } else if !resp.VerifySelfSignature() { return nil, &ErrInvalidSelfSignatures diff --git a/federation/signingkey.go b/federation/signingkey.go index 54c62492..87c12a5e 100644 --- a/federation/signingkey.go +++ b/federation/signingkey.go @@ -85,6 +85,15 @@ type ServerKeyResponse struct { Extra map[string]any `json:"-"` } +func (skr *ServerKeyResponse) HasKey(keyID id.KeyID) bool { + if skr == nil { + return false + } else if _, ok := skr.VerifyKeys[keyID]; ok { + return true + } + return false +} + func (skr *ServerKeyResponse) VerifySelfSignature() bool { for keyID, key := range skr.VerifyKeys { if !VerifyJSON(skr.ServerName, keyID, key.Key, skr) { From dec68fb4d730b70f533ab7b750aa19e86dcfc954 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 4 May 2025 00:49:34 +0300 Subject: [PATCH 059/581] federation/serverauth: don't unnecessarily export errors --- federation/serverauth.go | 46 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/federation/serverauth.go b/federation/serverauth.go index ef4ed246..e2036d30 100644 --- a/federation/serverauth.go +++ b/federation/serverauth.go @@ -49,16 +49,16 @@ func NewServerAuth(client *Client, keyCache KeyCache, getDestination func(auth X var MUnauthorized = mautrix.RespError{ErrCode: "M_UNAUTHORIZED", StatusCode: http.StatusUnauthorized} var ( - ErrMissingAuthHeader = MUnauthorized.WithMessage("Missing Authorization header") - ErrInvalidAuthHeader = MUnauthorized.WithMessage("Authorization header does not start with X-Matrix") - ErrMalformedAuthHeader = MUnauthorized.WithMessage("X-Matrix value is missing required components") - ErrInvalidDestination = MUnauthorized.WithMessage("Invalid destination in X-Matrix header") - ErrFailedToQueryKeys = MUnauthorized.WithMessage("Failed to query server keys") - ErrInvalidSelfSignatures = MUnauthorized.WithMessage("Server keys don't have valid self-signatures") - ErrRequestBodyTooLarge = mautrix.MTooLarge.WithMessage("Request body too large") - ErrInvalidJSONBody = mautrix.MBadJSON.WithMessage("Request body is not valid JSON") - ErrBodyReadFailed = mautrix.MUnknown.WithMessage("Failed to read request body") - ErrInvalidRequestSignature = MUnauthorized.WithMessage("Failed to verify request signature") + errMissingAuthHeader = MUnauthorized.WithMessage("Missing Authorization header") + errInvalidAuthHeader = MUnauthorized.WithMessage("Authorization header does not start with X-Matrix") + errMalformedAuthHeader = MUnauthorized.WithMessage("X-Matrix value is missing required components") + errInvalidDestination = MUnauthorized.WithMessage("Invalid destination in X-Matrix header") + errFailedToQueryKeys = MUnauthorized.WithMessage("Failed to query server keys") + errInvalidSelfSignatures = MUnauthorized.WithMessage("Server keys don't have valid self-signatures") + errRequestBodyTooLarge = mautrix.MTooLarge.WithMessage("Request body too large") + errInvalidJSONBody = mautrix.MBadJSON.WithMessage("Request body is not valid JSON") + errBodyReadFailed = mautrix.MUnknown.WithMessage("Failed to read request body") + errInvalidRequestSignature = MUnauthorized.WithMessage("Failed to verify request signature") ) type XMatrixAuth struct { @@ -166,18 +166,18 @@ func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.Res }() log := zerolog.Ctx(r.Context()) if r.ContentLength > sa.MaxBodySize { - return nil, &ErrRequestBodyTooLarge + return nil, &errRequestBodyTooLarge } auth := r.Header.Get("Authorization") if auth == "" { - return nil, &ErrMissingAuthHeader + return nil, &errMissingAuthHeader } else if !strings.HasPrefix(auth, "X-Matrix ") { - return nil, &ErrInvalidAuthHeader + return nil, &errInvalidAuthHeader } parsed := ParseXMatrixAuth(auth) if parsed.Origin == "" || parsed.KeyID == "" || parsed.Signature == "" { log.Trace().Str("auth_header", auth).Msg("Malformed X-Matrix header") - return nil, &ErrMalformedAuthHeader + return nil, &errMalformedAuthHeader } destination := sa.GetDestination(parsed) if destination == "" || (parsed.Destination != "" && parsed.Destination != destination) { @@ -185,7 +185,7 @@ func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.Res Str("got_destination", parsed.Destination). Str("expected_destination", destination). Msg("Invalid destination in X-Matrix header") - return nil, &ErrInvalidDestination + return nil, &errInvalidDestination } resp, err := sa.GetKeysWithCache(r.Context(), parsed.Origin, parsed.KeyID) if err != nil { @@ -198,9 +198,9 @@ func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.Res Str("server_name", parsed.Origin). Msg("Failed to query keys to authenticate request (cached error)") } - return nil, &ErrFailedToQueryKeys + return nil, &errFailedToQueryKeys } else if !resp.VerifySelfSignature() { - return nil, &ErrInvalidSelfSignatures + return nil, &errInvalidSelfSignatures } key, ok := resp.VerifyKeys[parsed.KeyID] if !ok { @@ -211,16 +211,16 @@ func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.Res Msg("Didn't find expected key ID to verify request") return nil, ptr.Ptr(MUnauthorized.WithMessage("Key ID %q not found (got %v)", parsed.KeyID, keys)) } - reqBody, err := io.ReadAll(&fixedLimitedReader{R: r.Body, N: sa.MaxBodySize, Err: ErrRequestBodyTooLarge}) - if errors.Is(err, ErrRequestBodyTooLarge) { - return nil, &ErrRequestBodyTooLarge + reqBody, err := io.ReadAll(&fixedLimitedReader{R: r.Body, N: sa.MaxBodySize, Err: errRequestBodyTooLarge}) + if errors.Is(err, errRequestBodyTooLarge) { + return nil, &errRequestBodyTooLarge } else if err != nil { log.Err(err). Str("server_name", parsed.Origin). Msg("Failed to read request body to authenticate") - return nil, &ErrBodyReadFailed + return nil, &errBodyReadFailed } else if !json.Valid(reqBody) { - return nil, &ErrInvalidJSONBody + return nil, &errInvalidJSONBody } valid := (&signableRequest{ Method: r.Method, @@ -231,7 +231,7 @@ func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.Res }).Verify(key.Key, parsed.Signature) if !valid { log.Trace().Msg("Request has invalid signature") - return nil, &ErrInvalidRequestSignature + return nil, &errInvalidRequestSignature } ctx := context.WithValue(r.Context(), contextKeyDestinationServer, destination) ctx = log.With().Str("destination_server_name", destination).Logger().WithContext(ctx) From 0a33bde865aec94f162c7a5351f3b8fd7c6abc37 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 4 May 2025 01:00:08 +0300 Subject: [PATCH 060/581] federation/cache: expose noop cache as variable instead of type --- federation/cache.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/federation/cache.go b/federation/cache.go index 301091b3..24154974 100644 --- a/federation/cache.go +++ b/federation/cache.go @@ -136,16 +136,18 @@ func (c *InMemoryCache) ShouldReQuery(serverName string) bool { return true } -type NoopCache struct{} +type noopCache struct{} -func (*NoopCache) StoreKeys(_ *ServerKeyResponse) {} -func (*NoopCache) LoadKeys(_ string) (*ServerKeyResponse, error) { return nil, nil } -func (*NoopCache) StoreFetchError(_ string, _ error) {} -func (*NoopCache) ShouldReQuery(_ string) bool { return true } -func (*NoopCache) StoreResolution(_ *ResolvedServerName) {} -func (*NoopCache) LoadResolution(_ string) (*ResolvedServerName, error) { return nil, nil } +func (*noopCache) StoreKeys(_ *ServerKeyResponse) {} +func (*noopCache) LoadKeys(_ string) (*ServerKeyResponse, error) { return nil, nil } +func (*noopCache) StoreFetchError(_ string, _ error) {} +func (*noopCache) ShouldReQuery(_ string) bool { return true } +func (*noopCache) StoreResolution(_ *ResolvedServerName) {} +func (*noopCache) LoadResolution(_ string) (*ResolvedServerName, error) { return nil, nil } var ( - _ ResolutionCache = (*NoopCache)(nil) - _ KeyCache = (*NoopCache)(nil) + _ ResolutionCache = (*noopCache)(nil) + _ KeyCache = (*noopCache)(nil) ) + +var NoopCache *noopCache From 63f35754c6e4e806ffb2e239b5ac9c02854331c5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 4 May 2025 01:04:12 +0300 Subject: [PATCH 061/581] federation/serverauth: store verified origin in request context --- federation/context.go | 12 ++++++++++++ federation/serverauth.go | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/federation/context.go b/federation/context.go index 8280431f..eedb2dc1 100644 --- a/federation/context.go +++ b/federation/context.go @@ -16,6 +16,7 @@ type contextKey int const ( contextKeyIPPort contextKey = iota contextKeyDestinationServer + contextKeyOriginServer ) func DestinationServerNameFromRequest(r *http.Request) string { @@ -28,3 +29,14 @@ func DestinationServerName(ctx context.Context) string { } return "" } + +func OriginServerNameFromRequest(r *http.Request) string { + return OriginServerName(r.Context()) +} + +func OriginServerName(ctx context.Context) string { + if origin, ok := ctx.Value(contextKeyOriginServer).(string); ok { + return origin + } + return "" +} diff --git a/federation/serverauth.go b/federation/serverauth.go index e2036d30..02780ff8 100644 --- a/federation/serverauth.go +++ b/federation/serverauth.go @@ -234,7 +234,11 @@ func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.Res return nil, &errInvalidRequestSignature } ctx := context.WithValue(r.Context(), contextKeyDestinationServer, destination) - ctx = log.With().Str("destination_server_name", destination).Logger().WithContext(ctx) + ctx = context.WithValue(ctx, contextKeyOriginServer, parsed.Origin) + ctx = log.With(). + Str("origin_server_name", parsed.Origin). + Str("destination_server_name", destination). + Logger().WithContext(ctx) modifiedReq := r.WithContext(ctx) modifiedReq.Body = io.NopCloser(bytes.NewReader(reqBody)) return modifiedReq, nil From 5c2bc3b1cf2415d57854d2774127173df1f5d4ce Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 4 May 2025 01:04:40 +0300 Subject: [PATCH 062/581] mediaproxy: add option to enforce federation auth --- mediaproxy/mediaproxy.go | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/mediaproxy/mediaproxy.go b/mediaproxy/mediaproxy.go index ff8b2157..d76439a1 100644 --- a/mediaproxy/mediaproxy.go +++ b/mediaproxy/mediaproxy.go @@ -99,9 +99,8 @@ type GetMediaResponseFile struct { type GetMediaFunc = func(ctx context.Context, mediaID string, params map[string]string) (response GetMediaResponse, err error) type MediaProxy struct { - KeyServer *federation.KeyServer - - ForceProxyLegacyFederation bool + KeyServer *federation.KeyServer + ServerAuth *federation.ServerAuth GetMedia GetMediaFunc PrepareProxyRequest func(*http.Request) @@ -139,6 +138,7 @@ func New(serverName string, serverKey string, getMedia GetMediaFunc) (*MediaProx type BasicConfig struct { ServerName string `yaml:"server_name" json:"server_name"` ServerKey string `yaml:"server_key" json:"server_key"` + FederationAuth bool `yaml:"federation_auth" json:"federation_auth"` WellKnownResponse string `yaml:"well_known_response" json:"well_known_response"` } @@ -150,6 +150,9 @@ func NewFromConfig(cfg BasicConfig, getMedia GetMediaFunc) (*MediaProxy, error) if cfg.WellKnownResponse != "" { mp.KeyServer.WellKnownTarget = cfg.WellKnownResponse } + if cfg.FederationAuth { + mp.EnableServerAuth(nil, nil) + } return mp, nil } @@ -172,6 +175,19 @@ func (mp *MediaProxy) GetServerKey() *federation.SigningKey { return mp.serverKey } +func (mp *MediaProxy) EnableServerAuth(client *federation.Client, keyCache federation.KeyCache) { + if keyCache == nil { + keyCache = federation.NewInMemoryCache() + } + if client == nil { + resCache, _ := keyCache.(federation.ResolutionCache) + client = federation.NewClient(mp.serverName, mp.serverKey, resCache) + } + mp.ServerAuth = federation.NewServerAuth(client, keyCache, func(auth federation.XMatrixAuth) string { + return mp.GetServerName() + }) +} + func (mp *MediaProxy) RegisterRoutes(router *mux.Router) { if mp.FederationRouter == nil { mp.FederationRouter = router.PathPrefix("/_matrix/federation").Subrouter() @@ -271,9 +287,16 @@ func startMultipart(ctx context.Context, w http.ResponseWriter) *multipart.Write } func (mp *MediaProxy) DownloadMediaFederation(w http.ResponseWriter, r *http.Request) { + if mp.ServerAuth != nil { + var err *mautrix.RespError + r, err = mp.ServerAuth.Authenticate(r) + if err != nil { + err.Write(w) + return + } + } ctx := r.Context() log := zerolog.Ctx(ctx) - // TODO check destination header in X-Matrix auth resp := mp.getMedia(w, r) if resp == nil { From b45dcd42fc38aa0e99a266f1f02496bdb2e5bad3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 4 May 2025 01:09:12 +0300 Subject: [PATCH 063/581] federation/serverauth: fix get requests --- federation/serverauth.go | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/federation/serverauth.go b/federation/serverauth.go index 02780ff8..f3875498 100644 --- a/federation/serverauth.go +++ b/federation/serverauth.go @@ -211,16 +211,19 @@ func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.Res Msg("Didn't find expected key ID to verify request") return nil, ptr.Ptr(MUnauthorized.WithMessage("Key ID %q not found (got %v)", parsed.KeyID, keys)) } - reqBody, err := io.ReadAll(&fixedLimitedReader{R: r.Body, N: sa.MaxBodySize, Err: errRequestBodyTooLarge}) - if errors.Is(err, errRequestBodyTooLarge) { - return nil, &errRequestBodyTooLarge - } else if err != nil { - log.Err(err). - Str("server_name", parsed.Origin). - Msg("Failed to read request body to authenticate") - return nil, &errBodyReadFailed - } else if !json.Valid(reqBody) { - return nil, &errInvalidJSONBody + var reqBody []byte + if r.ContentLength != 0 && r.Method != http.MethodGet && r.Method != http.MethodHead { + reqBody, err = io.ReadAll(&fixedLimitedReader{R: r.Body, N: sa.MaxBodySize, Err: errRequestBodyTooLarge}) + if errors.Is(err, errRequestBodyTooLarge) { + return nil, &errRequestBodyTooLarge + } else if err != nil { + log.Err(err). + Str("server_name", parsed.Origin). + Msg("Failed to read request body to authenticate") + return nil, &errBodyReadFailed + } else if !json.Valid(reqBody) { + return nil, &errInvalidJSONBody + } } valid := (&signableRequest{ Method: r.Method, @@ -240,7 +243,9 @@ func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.Res Str("destination_server_name", destination). Logger().WithContext(ctx) modifiedReq := r.WithContext(ctx) - modifiedReq.Body = io.NopCloser(bytes.NewReader(reqBody)) + if reqBody != nil { + modifiedReq.Body = io.NopCloser(bytes.NewReader(reqBody)) + } return modifiedReq, nil } From 5cd8ba88877562dc6452b4baa57e00b9b27fa1cc Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 4 May 2025 01:14:44 +0300 Subject: [PATCH 064/581] federation/serverauth: fix go 1.23 compatibility --- federation/serverauth.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/federation/serverauth.go b/federation/serverauth.go index f3875498..22ce8403 100644 --- a/federation/serverauth.go +++ b/federation/serverauth.go @@ -80,7 +80,8 @@ func (xma XMatrixAuth) String() string { func ParseXMatrixAuth(auth string) (xma XMatrixAuth) { auth = strings.TrimPrefix(auth, "X-Matrix ") - for part := range strings.SplitSeq(auth, ",") { + // TODO upgrade to strings.SplitSeq after Go 1.24 is the minimum + for _, part := range strings.Split(auth, ",") { part = strings.TrimSpace(part) eqIdx := strings.Index(part, "=") if eqIdx == -1 || strings.Count(part, "=") > 1 { From 6eb4c7b17f97004887d76145553a1ab456c0ad37 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 4 May 2025 14:09:06 +0300 Subject: [PATCH 065/581] crypto/keybackup: allow importing room keys without saving --- crypto/keybackup.go | 56 ++++++++++++++++++++++++++++++-------------- crypto/keyimport.go | 2 +- crypto/keysharing.go | 2 +- crypto/machine.go | 4 ++-- 4 files changed, 43 insertions(+), 21 deletions(-) diff --git a/crypto/keybackup.go b/crypto/keybackup.go index 5724002b..d8b3d715 100644 --- a/crypto/keybackup.go +++ b/crypto/keybackup.go @@ -13,6 +13,7 @@ import ( "maunium.net/go/mautrix/crypto/backup" "maunium.net/go/mautrix/crypto/olm" "maunium.net/go/mautrix/crypto/signatures" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -161,11 +162,15 @@ var ( ErrFailedToStoreNewInboundGroupSessionFromBackup = errors.New("failed to store new inbound group session from key backup") ) -func (mach *OlmMachine) ImportRoomKeyFromBackup(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, sessionID id.SessionID, keyBackupData *backup.MegolmSessionData) (*InboundGroupSession, error) { - log := zerolog.Ctx(ctx).With(). - Str("room_id", roomID.String()). - Str("session_id", sessionID.String()). - Logger() +func (mach *OlmMachine) ImportRoomKeyFromBackupWithoutSaving( + ctx context.Context, + version id.KeyBackupVersion, + roomID id.RoomID, + config *event.EncryptionEventContent, + sessionID id.SessionID, + keyBackupData *backup.MegolmSessionData, +) (*InboundGroupSession, error) { + log := zerolog.Ctx(ctx) if keyBackupData.Algorithm != id.AlgorithmMegolmV1 { return nil, fmt.Errorf("%w %s", ErrUnknownAlgorithmInKeyBackup, keyBackupData.Algorithm) } @@ -175,6 +180,8 @@ func (mach *OlmMachine) ImportRoomKeyFromBackup(ctx context.Context, version id. return nil, fmt.Errorf("failed to import inbound group session: %w", err) } else if igsInternal.ID() != sessionID { log.Warn(). + Stringer("room_id", roomID). + Stringer("session_id", sessionID). Stringer("actual_session_id", igsInternal.ID()). Msg("Mismatched session ID while creating inbound group session from key backup") return nil, ErrMismatchingSessionIDInKeyBackup @@ -182,19 +189,12 @@ func (mach *OlmMachine) ImportRoomKeyFromBackup(ctx context.Context, version id. var maxAge time.Duration var maxMessages int - if config, err := mach.StateStore.GetEncryptionEvent(ctx, roomID); err != nil { - log.Error().Err(err).Msg("Failed to get encryption event for room") - } else if config != nil { + if config != nil { maxAge = time.Duration(config.RotationPeriodMillis) * time.Millisecond maxMessages = config.RotationPeriodMessages } - firstKnownIndex := igsInternal.FirstKnownIndex() - if firstKnownIndex > 0 { - log.Warn().Uint32("first_known_index", firstKnownIndex).Msg("Importing partial session") - } - - igs := &InboundGroupSession{ + return &InboundGroupSession{ Internal: igsInternal, SigningKey: keyBackupData.SenderClaimedKeys.Ed25519, SenderKey: keyBackupData.SenderKey, @@ -206,11 +206,33 @@ func (mach *OlmMachine) ImportRoomKeyFromBackup(ctx context.Context, version id. MaxAge: maxAge.Milliseconds(), MaxMessages: maxMessages, KeyBackupVersion: version, + }, nil +} + +func (mach *OlmMachine) ImportRoomKeyFromBackup(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, sessionID id.SessionID, keyBackupData *backup.MegolmSessionData) (*InboundGroupSession, error) { + config, err := mach.StateStore.GetEncryptionEvent(ctx, roomID) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Stringer("room_id", roomID). + Stringer("session_id", sessionID). + Msg("Failed to get encryption event for room") } - err = mach.CryptoStore.PutGroupSession(ctx, igs) + imported, err := mach.ImportRoomKeyFromBackupWithoutSaving(ctx, version, roomID, config, sessionID, keyBackupData) + if err != nil { + return nil, err + } + firstKnownIndex := imported.Internal.FirstKnownIndex() + if firstKnownIndex > 0 { + zerolog.Ctx(ctx).Warn(). + Stringer("room_id", roomID). + Stringer("session_id", sessionID). + Uint32("first_known_index", firstKnownIndex). + Msg("Importing partial session") + } + err = mach.CryptoStore.PutGroupSession(ctx, imported) if err != nil { return nil, fmt.Errorf("%w: %w", ErrFailedToStoreNewInboundGroupSessionFromBackup, err) } - mach.markSessionReceived(ctx, roomID, sessionID, firstKnownIndex) - return igs, nil + mach.MarkSessionReceived(ctx, roomID, sessionID, firstKnownIndex) + return imported, nil } diff --git a/crypto/keyimport.go b/crypto/keyimport.go index 1dc7f6cc..36ad6b9c 100644 --- a/crypto/keyimport.go +++ b/crypto/keyimport.go @@ -127,7 +127,7 @@ func (mach *OlmMachine) importExportedRoomKey(ctx context.Context, session Expor if err != nil { return false, fmt.Errorf("failed to store imported session: %w", err) } - mach.markSessionReceived(ctx, session.RoomID, igs.ID(), firstKnownIndex) + mach.MarkSessionReceived(ctx, session.RoomID, igs.ID(), firstKnownIndex) return true, nil } diff --git a/crypto/keysharing.go b/crypto/keysharing.go index ea0ae65d..e78bb65c 100644 --- a/crypto/keysharing.go +++ b/crypto/keysharing.go @@ -200,7 +200,7 @@ func (mach *OlmMachine) importForwardedRoomKey(ctx context.Context, evt *Decrypt log.Error().Err(err).Msg("Failed to store new inbound group session") return false } - mach.markSessionReceived(ctx, content.RoomID, content.SessionID, firstKnownIndex) + mach.MarkSessionReceived(ctx, content.RoomID, content.SessionID, firstKnownIndex) log.Debug().Msg("Received forwarded inbound group session") return true } diff --git a/crypto/machine.go b/crypto/machine.go index e2af298b..cac91bf8 100644 --- a/crypto/machine.go +++ b/crypto/machine.go @@ -584,7 +584,7 @@ func (mach *OlmMachine) createGroupSession(ctx context.Context, senderKey id.Sen log.Err(err).Str("session_id", sessionID.String()).Msg("Failed to store new inbound group session") return fmt.Errorf("failed to store new inbound group session: %w", err) } - mach.markSessionReceived(ctx, roomID, sessionID, igs.Internal.FirstKnownIndex()) + mach.MarkSessionReceived(ctx, roomID, sessionID, igs.Internal.FirstKnownIndex()) log.Debug(). Str("session_id", sessionID.String()). Str("sender_key", senderKey.String()). @@ -595,7 +595,7 @@ func (mach *OlmMachine) createGroupSession(ctx context.Context, senderKey id.Sen return nil } -func (mach *OlmMachine) markSessionReceived(ctx context.Context, roomID id.RoomID, id id.SessionID, firstKnownIndex uint32) { +func (mach *OlmMachine) MarkSessionReceived(ctx context.Context, roomID id.RoomID, id id.SessionID, firstKnownIndex uint32) { if mach.SessionReceived != nil { mach.SessionReceived(ctx, roomID, id, firstKnownIndex) } From ba43e615f8e971f5290d7807c68cde3a2cc57b1f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 6 May 2025 18:49:54 +0300 Subject: [PATCH 066/581] bridgev2/login: add wait_for_url_pattern field to cookie logins --- bridgev2/login.go | 6 ++++++ bridgev2/matrix/provisioning.yaml | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/bridgev2/login.go b/bridgev2/login.go index b28ccfdb..1fa3afbc 100644 --- a/bridgev2/login.go +++ b/bridgev2/login.go @@ -159,6 +159,12 @@ type LoginCookiesParams struct { // The snippet will evaluate to a promise that resolves when the relevant fields are found. // Fields that are not present in the promise result must be extracted another way. ExtractJS string `json:"extract_js,omitempty"` + // A regex pattern that the URL should match before the client closes the webview. + // + // The client may submit the login if the user closes the webview after all cookies are collected + // even if this URL is not reached, but it should only automatically close the webview after + // both cookies and the URL match. + WaitForURLPattern string `json:"wait_for_url_pattern,omitempty"` } type LoginInputFieldType string diff --git a/bridgev2/matrix/provisioning.yaml b/bridgev2/matrix/provisioning.yaml index bf6c6f3d..b9879ea5 100644 --- a/bridgev2/matrix/provisioning.yaml +++ b/bridgev2/matrix/provisioning.yaml @@ -671,6 +671,20 @@ components: user_agent: type: string description: An optional user agent that the webview should use. + wait_for_url_pattern: + type: string + description: | + A regex pattern that the URL should match before the client closes the webview. + + The client may submit the login if the user closes the webview after all cookies are collected + even if this URL is not reached, but it should only automatically close the webview after + both cookies and the URL match. + extract_js: + type: string + description: | + A JavaScript snippet that can extract some or all of the fields. + The snippet will evaluate to a promise that resolves when the relevant fields are found. + Fields that are not present in the promise result must be extracted another way. fields: type: array description: The list of cookies or other stored data that must be extracted. From 37d486dfcd0e19b36e956835c2fcfba90ea6d606 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 6 May 2025 20:53:00 +0300 Subject: [PATCH 067/581] bridgev2/portal: ignore fake mxids when bridging read receipts --- bridgev2/database/message.go | 7 ++++++- bridgev2/portal.go | 9 ++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/bridgev2/database/message.go b/bridgev2/database/message.go index fd6b65d8..6447ac1d 100644 --- a/bridgev2/database/message.go +++ b/bridgev2/database/message.go @@ -64,7 +64,8 @@ const ( getLastMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY timestamp DESC, part_id DESC LIMIT 1` getLastNInPortal = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 ORDER BY timestamp DESC, part_id DESC LIMIT $4` - getLastMessagePartAtOrBeforeTimeQuery = getMessageBaseQuery + `WHERE bridge_id = $1 AND room_id=$2 AND room_receiver=$3 AND timestamp<=$4 ORDER BY timestamp DESC, part_id DESC LIMIT 1` + getLastMessagePartAtOrBeforeTimeQuery = getMessageBaseQuery + `WHERE bridge_id = $1 AND room_id=$2 AND room_receiver=$3 AND timestamp<=$4 ORDER BY timestamp DESC, part_id DESC LIMIT 1` + getLastNonFakeMessagePartAtOrBeforeTimeQuery = getMessageBaseQuery + `WHERE bridge_id = $1 AND room_id=$2 AND room_receiver=$3 AND timestamp<=$4 AND mxid NOT LIKE '~fake:%' ORDER BY timestamp DESC, part_id DESC LIMIT 1` countMessagesInPortalQuery = ` SELECT COUNT(*) FROM message WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 @@ -128,6 +129,10 @@ func (mq *MessageQuery) GetLastPartAtOrBeforeTime(ctx context.Context, portal ne return mq.QueryOne(ctx, getLastMessagePartAtOrBeforeTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, maxTS.UnixNano()) } +func (mq *MessageQuery) GetLastNonFakePartAtOrBeforeTime(ctx context.Context, portal networkid.PortalKey, maxTS time.Time) (*Message, error) { + return mq.QueryOne(ctx, getLastNonFakeMessagePartAtOrBeforeTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, maxTS.UnixNano()) +} + func (mq *MessageQuery) GetMessagesBetweenTimeQuery(ctx context.Context, portal networkid.PortalKey, start, end time.Time) ([]*Message, error) { return mq.QueryMany(ctx, getMessagesBetweenTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, start.UnixNano(), end.UnixNano()) } diff --git a/bridgev2/portal.go b/bridgev2/portal.go index d88f5a7c..3e0353d0 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -2638,7 +2638,6 @@ func (portal *Portal) redactMessageParts(ctx context.Context, parts []*database. } func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserLogin, evt RemoteReadReceipt) { - // TODO exclude fake mxids log := zerolog.Ctx(ctx) var err error var lastTarget *database.Message @@ -2651,6 +2650,10 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL } else if lastTarget == nil { log.Debug().Str("last_target_id", string(lastTargetID)). Msg("Last target message not found") + } else if lastTarget.HasFakeMXID() { + log.Debug().Str("last_target_id", string(lastTargetID)). + Msg("Last target message is fake") + lastTarget = nil } } if lastTarget == nil { @@ -2660,14 +2663,14 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL log.Err(err).Str("target_id", string(targetID)). Msg("Failed to get target message for read receipt") return - } else if target != nil && (lastTarget == nil || target.Timestamp.After(lastTarget.Timestamp)) { + } else if target != nil && !target.HasFakeMXID() && (lastTarget == nil || target.Timestamp.After(lastTarget.Timestamp)) { lastTarget = target } } } readUpTo := evt.GetReadUpTo() if lastTarget == nil && !readUpTo.IsZero() { - lastTarget, err = portal.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, portal.PortalKey, readUpTo) + lastTarget, err = portal.Bridge.DB.Message.GetLastNonFakePartAtOrBeforeTime(ctx, portal.PortalKey, readUpTo) if err != nil { log.Err(err).Time("read_up_to", readUpTo).Msg("Failed to get target message for read receipt") } From a7faac33c8158cadb369713c198104be08df78af Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 6 May 2025 20:55:26 +0300 Subject: [PATCH 068/581] bridgev2/portal: add fallback if last receipt target is fake --- bridgev2/portal.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 3e0353d0..c49f041c 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -2641,6 +2641,7 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL log := zerolog.Ctx(ctx) var err error var lastTarget *database.Message + readUpTo := evt.GetReadUpTo() if lastTargetID := evt.GetLastReceiptTarget(); lastTargetID != "" { lastTarget, err = portal.Bridge.DB.Message.GetLastPartByID(ctx, portal.Receiver, lastTargetID) if err != nil { @@ -2653,6 +2654,9 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL } else if lastTarget.HasFakeMXID() { log.Debug().Str("last_target_id", string(lastTargetID)). Msg("Last target message is fake") + if readUpTo.IsZero() { + readUpTo = lastTarget.Timestamp + } lastTarget = nil } } @@ -2668,7 +2672,6 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL } } } - readUpTo := evt.GetReadUpTo() if lastTarget == nil && !readUpTo.IsZero() { lastTarget, err = portal.Bridge.DB.Message.GetLastNonFakePartAtOrBeforeTime(ctx, portal.PortalKey, readUpTo) if err != nil { From bef23edaea2851d0e8a75ee054197456b89d6ba9 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 6 May 2025 22:50:46 +0300 Subject: [PATCH 069/581] crypto/keysharing: ensure forwarding chains is always set --- crypto/keysharing.go | 5 +---- crypto/sessions.go | 9 ++++++++- crypto/sql_store.go | 2 ++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crypto/keysharing.go b/crypto/keysharing.go index e78bb65c..e6d8d603 100644 --- a/crypto/keysharing.go +++ b/crypto/keysharing.go @@ -347,9 +347,6 @@ func (mach *OlmMachine) HandleRoomKeyRequest(ctx context.Context, sender id.User mach.rejectKeyRequest(ctx, KeyShareRejectInternalError, device, content.Body) return } - if igs.ForwardingChains == nil { - igs.ForwardingChains = []string{} - } forwardedRoomKey := event.Content{ Parsed: &event.ForwardedRoomKeyEventContent{ @@ -360,7 +357,7 @@ func (mach *OlmMachine) HandleRoomKeyRequest(ctx context.Context, sender id.User SessionKey: string(exportedKey), }, SenderKey: content.Body.SenderKey, - ForwardingKeyChain: igs.ForwardingChains, + ForwardingKeyChain: igs.ForwardingChainsOrEmpty(), SenderClaimedKey: igs.SigningKey, }, } diff --git a/crypto/sessions.go b/crypto/sessions.go index 457a0a43..8724d05a 100644 --- a/crypto/sessions.go +++ b/crypto/sessions.go @@ -125,7 +125,7 @@ func NewInboundGroupSession(senderKey id.SenderKey, signingKey id.Ed25519, roomI SigningKey: signingKey, SenderKey: senderKey, RoomID: roomID, - ForwardingChains: nil, + ForwardingChains: []string{}, ReceivedAt: time.Now().UTC(), MaxAge: maxAge.Milliseconds(), MaxMessages: maxMessages, @@ -133,6 +133,13 @@ func NewInboundGroupSession(senderKey id.SenderKey, signingKey id.Ed25519, roomI }, nil } +func (igs *InboundGroupSession) ForwardingChainsOrEmpty() []string { + if igs.ForwardingChains == nil { + return []string{} + } + return igs.ForwardingChains +} + func (igs *InboundGroupSession) ID() id.SessionID { if igs.id == "" { igs.id = igs.Internal.ID() diff --git a/crypto/sql_store.go b/crypto/sql_store.go index 514c1e8c..9d8e7ed7 100644 --- a/crypto/sql_store.go +++ b/crypto/sql_store.go @@ -509,6 +509,8 @@ func (store *SQLCryptoStore) postScanInboundGroupSession(sessionBytes, ratchetSa } if forwardingChains != "" { chains = strings.Split(forwardingChains, ",") + } else { + chains = []string{} } var rs RatchetSafety if len(ratchetSafetyBytes) > 0 { From 0ffe3524f68bc44beadef265facc6c9adc8b17f5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 6 May 2025 22:54:32 +0300 Subject: [PATCH 070/581] crypto/sql_store: ensure forwarding chains is always set instead of having fallback in getter --- crypto/keysharing.go | 2 +- crypto/sessions.go | 7 ------- crypto/sql_store.go | 3 +++ 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/crypto/keysharing.go b/crypto/keysharing.go index e6d8d603..f1d427af 100644 --- a/crypto/keysharing.go +++ b/crypto/keysharing.go @@ -357,7 +357,7 @@ func (mach *OlmMachine) HandleRoomKeyRequest(ctx context.Context, sender id.User SessionKey: string(exportedKey), }, SenderKey: content.Body.SenderKey, - ForwardingKeyChain: igs.ForwardingChainsOrEmpty(), + ForwardingKeyChain: igs.ForwardingChains, SenderClaimedKey: igs.SigningKey, }, } diff --git a/crypto/sessions.go b/crypto/sessions.go index 8724d05a..aecb0416 100644 --- a/crypto/sessions.go +++ b/crypto/sessions.go @@ -133,13 +133,6 @@ func NewInboundGroupSession(senderKey id.SenderKey, signingKey id.Ed25519, roomI }, nil } -func (igs *InboundGroupSession) ForwardingChainsOrEmpty() []string { - if igs.ForwardingChains == nil { - return []string{} - } - return igs.ForwardingChains -} - func (igs *InboundGroupSession) ID() id.SessionID { if igs.id == "" { igs.id = igs.Internal.ID() diff --git a/crypto/sql_store.go b/crypto/sql_store.go index 9d8e7ed7..b0625763 100644 --- a/crypto/sql_store.go +++ b/crypto/sql_store.go @@ -326,6 +326,9 @@ func (store *SQLCryptoStore) PutGroupSession(ctx context.Context, session *Inbou if err != nil { return err } + if session.ForwardingChains == nil { + session.ForwardingChains = []string{} + } forwardingChains := strings.Join(session.ForwardingChains, ",") ratchetSafety, err := json.Marshal(&session.RatchetSafety) if err != nil { From 72f6229f40c6f8b8f49c5b70d3a002d39330ce23 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 6 May 2025 23:18:23 +0300 Subject: [PATCH 071/581] crypto: fix key export test --- crypto/keyexport_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto/keyexport_test.go b/crypto/keyexport_test.go index 15d944d5..47616a20 100644 --- a/crypto/keyexport_test.go +++ b/crypto/keyexport_test.go @@ -31,5 +31,5 @@ func TestExportKeys(t *testing.T) { )) data, err := crypto.ExportKeys("meow", []*crypto.InboundGroupSession{sess}) assert.NoError(t, err) - assert.Len(t, data, 840) + assert.Len(t, data, 836) } From c93d30a83c87e1cb9169d9786da70b9301df2dc1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 7 May 2025 14:47:04 +0300 Subject: [PATCH 072/581] bridgev2: add option to deduplicate Matrix messages by event or transaction ID --- bridgev2/bridgeconfig/config.go | 37 ++++++++++--------- bridgev2/bridgeconfig/upgrade.go | 1 + bridgev2/database/message.go | 30 +++++++++++---- bridgev2/database/upgrades/00-latest.sql | 6 ++- .../upgrades/22-message-send-txn-id.sql | 6 +++ bridgev2/matrix/mxmain/example-config.yaml | 2 + bridgev2/portal.go | 15 ++++++++ 7 files changed, 69 insertions(+), 28 deletions(-) create mode 100644 bridgev2/database/upgrades/22-message-send-txn-id.sql diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index 937d9441..bd7746d1 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -58,24 +58,25 @@ type CleanupOnLogouts struct { } type BridgeConfig struct { - CommandPrefix string `yaml:"command_prefix"` - PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"` - PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"` - AsyncEvents bool `yaml:"async_events"` - SplitPortals bool `yaml:"split_portals"` - ResendBridgeInfo bool `yaml:"resend_bridge_info"` - NoBridgeInfoStateKey bool `yaml:"no_bridge_info_state_key"` - BridgeStatusNotices string `yaml:"bridge_status_notices"` - BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"` - BridgeNotices bool `yaml:"bridge_notices"` - TagOnlyOnCreate bool `yaml:"tag_only_on_create"` - OnlyBridgeTags []event.RoomTag `yaml:"only_bridge_tags"` - MuteOnlyOnCreate bool `yaml:"mute_only_on_create"` - OutgoingMessageReID bool `yaml:"outgoing_message_re_id"` - CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"` - Relay RelayConfig `yaml:"relay"` - Permissions PermissionConfig `yaml:"permissions"` - Backfill BackfillConfig `yaml:"backfill"` + CommandPrefix string `yaml:"command_prefix"` + PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"` + PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"` + AsyncEvents bool `yaml:"async_events"` + SplitPortals bool `yaml:"split_portals"` + ResendBridgeInfo bool `yaml:"resend_bridge_info"` + NoBridgeInfoStateKey bool `yaml:"no_bridge_info_state_key"` + BridgeStatusNotices string `yaml:"bridge_status_notices"` + BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"` + BridgeNotices bool `yaml:"bridge_notices"` + TagOnlyOnCreate bool `yaml:"tag_only_on_create"` + OnlyBridgeTags []event.RoomTag `yaml:"only_bridge_tags"` + MuteOnlyOnCreate bool `yaml:"mute_only_on_create"` + DeduplicateMatrixMessages bool `yaml:"deduplicate_matrix_messages"` + OutgoingMessageReID bool `yaml:"outgoing_message_re_id"` + CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"` + Relay RelayConfig `yaml:"relay"` + Permissions PermissionConfig `yaml:"permissions"` + Backfill BackfillConfig `yaml:"backfill"` } type MatrixConfig struct { diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index 95370681..18b98263 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -37,6 +37,7 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.Bool, "bridge", "tag_only_on_create") helper.Copy(up.List, "bridge", "only_bridge_tags") helper.Copy(up.Bool, "bridge", "mute_only_on_create") + helper.Copy(up.Bool, "bridge", "deduplicate_matrix_messages") helper.Copy(up.Bool, "bridge", "cleanup_on_logout", "enabled") helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "private") helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "relayed") diff --git a/bridgev2/database/message.go b/bridgev2/database/message.go index 6447ac1d..9b3b1493 100644 --- a/bridgev2/database/message.go +++ b/bridgev2/database/message.go @@ -43,19 +43,23 @@ type Message struct { ThreadRoot networkid.MessageID ReplyTo networkid.MessageOptionalPartID + SendTxnID networkid.RawTransactionID + Metadata any } const ( getMessageBaseQuery = ` SELECT rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid, - timestamp, edit_count, double_puppeted, thread_root_id, reply_to_id, reply_to_part_id, metadata + timestamp, edit_count, double_puppeted, thread_root_id, reply_to_id, reply_to_part_id, + send_txn_id, metadata FROM message ` getAllMessagePartsByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3` getMessagePartByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 AND part_id=$4` getMessagePartByRowIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND rowid=$2` getMessageByMXIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND mxid=$2` + getMessageByTxnIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND (mxid=$3 OR send_txn_id=$4)` getLastMessagePartByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 ORDER BY part_id DESC LIMIT 1` getFirstMessagePartByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 ORDER BY part_id ASC LIMIT 1` getMessagesBetweenTimeQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND timestamp>$4 AND timestamp<=$5` @@ -74,16 +78,17 @@ const ( insertMessageQuery = ` INSERT INTO message ( bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid, - timestamp, edit_count, double_puppeted, thread_root_id, reply_to_id, reply_to_part_id, metadata + timestamp, edit_count, double_puppeted, thread_root_id, reply_to_id, reply_to_part_id, + send_txn_id, metadata ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING rowid ` updateMessageQuery = ` UPDATE message SET id=$2, part_id=$3, mxid=$4, room_id=$5, room_receiver=$6, sender_id=$7, sender_mxid=$8, timestamp=$9, edit_count=$10, double_puppeted=$11, thread_root_id=$12, reply_to_id=$13, - reply_to_part_id=$14, metadata=$15 - WHERE bridge_id=$1 AND rowid=$16 + reply_to_part_id=$14, send_txn_id=$15, metadata=$16 + WHERE bridge_id=$1 AND rowid=$17 ` deleteAllMessagePartsByIDQuery = ` DELETE FROM message WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 @@ -105,6 +110,10 @@ func (mq *MessageQuery) GetPartByMXID(ctx context.Context, mxid id.EventID) (*Me return mq.QueryOne(ctx, getMessageByMXIDQuery, mq.BridgeID, mxid) } +func (mq *MessageQuery) GetPartByTxnID(ctx context.Context, receiver networkid.UserLoginID, mxid id.EventID, txnID networkid.RawTransactionID) (*Message, error) { + return mq.QueryOne(ctx, getMessageByTxnIDQuery, mq.BridgeID, receiver, mxid, txnID) +} + func (mq *MessageQuery) GetLastPartByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) (*Message, error) { return mq.QueryOne(ctx, getLastMessagePartByIDQuery, mq.BridgeID, receiver, id) } @@ -178,11 +187,12 @@ func (mq *MessageQuery) CountMessagesInPortal(ctx context.Context, key networkid func (m *Message) Scan(row dbutil.Scannable) (*Message, error) { var timestamp int64 - var threadRootID, replyToID, replyToPartID sql.NullString + var threadRootID, replyToID, replyToPartID, sendTxnID sql.NullString var doublePuppeted sql.NullBool err := row.Scan( &m.RowID, &m.BridgeID, &m.ID, &m.PartID, &m.MXID, &m.Room.ID, &m.Room.Receiver, &m.SenderID, &m.SenderMXID, - ×tamp, &m.EditCount, &doublePuppeted, &threadRootID, &replyToID, &replyToPartID, dbutil.JSON{Data: m.Metadata}, + ×tamp, &m.EditCount, &doublePuppeted, &threadRootID, &replyToID, &replyToPartID, &sendTxnID, + dbutil.JSON{Data: m.Metadata}, ) if err != nil { return nil, err @@ -196,6 +206,9 @@ func (m *Message) Scan(row dbutil.Scannable) (*Message, error) { m.ReplyTo.PartID = (*networkid.PartID)(&replyToPartID.String) } } + if sendTxnID.Valid { + m.SendTxnID = networkid.RawTransactionID(sendTxnID.String) + } return m, nil } @@ -210,7 +223,8 @@ func (m *Message) sqlVariables() []any { return []any{ m.BridgeID, m.ID, m.PartID, m.MXID, m.Room.ID, m.Room.Receiver, m.SenderID, m.SenderMXID, m.Timestamp.UnixNano(), m.EditCount, m.IsDoublePuppeted, dbutil.StrPtr(m.ThreadRoot), - dbutil.StrPtr(m.ReplyTo.MessageID), m.ReplyTo.PartID, dbutil.JSON{Data: m.Metadata}, + dbutil.StrPtr(m.ReplyTo.MessageID), m.ReplyTo.PartID, dbutil.StrPtr(m.SendTxnID), + dbutil.JSON{Data: m.Metadata}, } } diff --git a/bridgev2/database/upgrades/00-latest.sql b/bridgev2/database/upgrades/00-latest.sql index 7ad01a87..4eea05bb 100644 --- a/bridgev2/database/upgrades/00-latest.sql +++ b/bridgev2/database/upgrades/00-latest.sql @@ -1,4 +1,4 @@ --- v0 -> v21 (compatible with v9+): Latest revision +-- v0 -> v22 (compatible with v9+): Latest revision CREATE TABLE "user" ( bridge_id TEXT NOT NULL, mxid TEXT NOT NULL, @@ -108,6 +108,7 @@ CREATE TABLE message ( thread_root_id TEXT, reply_to_id TEXT, reply_to_part_id TEXT, + send_txn_id TEXT, metadata jsonb NOT NULL, CONSTRAINT message_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver) @@ -117,7 +118,8 @@ CREATE TABLE message ( REFERENCES ghost (bridge_id, id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT message_real_pkey UNIQUE (bridge_id, room_receiver, id, part_id), - CONSTRAINT message_mxid_unique UNIQUE (bridge_id, mxid) + CONSTRAINT message_mxid_unique UNIQUE (bridge_id, mxid), + CONSTRAINT message_txn_id_unique UNIQUE (bridge_id, room_receiver, send_txn_id) ); CREATE INDEX message_room_idx ON message (bridge_id, room_id, room_receiver); diff --git a/bridgev2/database/upgrades/22-message-send-txn-id.sql b/bridgev2/database/upgrades/22-message-send-txn-id.sql new file mode 100644 index 00000000..8933984e --- /dev/null +++ b/bridgev2/database/upgrades/22-message-send-txn-id.sql @@ -0,0 +1,6 @@ +-- v22 (compatible with v9+): Add message send transaction ID column +ALTER TABLE message ADD COLUMN send_txn_id TEXT; +-- only: postgres +ALTER TABLE message ADD CONSTRAINT message_txn_id_unique UNIQUE (bridge_id, room_receiver, send_txn_id); +-- only: sqlite +CREATE UNIQUE INDEX message_txn_id_unique ON message (bridge_id, room_receiver, send_txn_id); diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 1d4e18cf..4dee2650 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -38,6 +38,8 @@ bridge: # Should room mute status only be synced when creating the portal? # Like tags, mutes can't currently be synced back to the remote network. mute_only_on_create: true + # Should the bridge check the db to ensure that incoming events haven't been handled before + deduplicate_matrix_messages: false # What should be done to portal rooms when a user logs out or is logged out? # Permitted values: diff --git a/bridgev2/portal.go b/bridgev2/portal.go index c49f041c..cfdad822 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -951,6 +951,18 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin ThreadRoot: threadRoot, ReplyTo: replyTo, } + if portal.Bridge.Config.DeduplicateMatrixMessages { + if part, err := portal.Bridge.DB.Message.GetPartByTxnID(ctx, portal.Receiver, evt.ID, wrappedMsgEvt.InputTransactionID); err != nil { + log.Err(err).Msg("Failed to check db if message is already sent") + } else if part != nil { + log.Debug(). + Stringer("message_mxid", part.MXID). + Stringer("input_event_id", evt.ID). + Msg("Message already sent, ignoring") + return + } + } + var resp *MatrixMessageResponse if msgContent != nil { resp, err = sender.Client.HandleMatrixMessage(ctx, wrappedMsgEvt) @@ -1091,6 +1103,9 @@ func (evt *MatrixMessage) fillDBMessage(message *database.Message) *database.Mes if message.SenderMXID == "" { message.SenderMXID = evt.Event.Sender } + if message.SendTxnID != "" { + message.SendTxnID = evt.InputTransactionID + } return message } From 4ffe1d23e9e7edd6337db7a6a6639179740bf7e4 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Wed, 7 May 2025 14:19:01 +0100 Subject: [PATCH 073/581] client: don't attempt to make requests if the homeserver URL isn't set (#376) Quick guard for where the client is created without using the `NewClient` method. --- client.go | 3 +++ error.go | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/client.go b/client.go index 5f47aead..c8e366b0 100644 --- a/client.go +++ b/client.go @@ -481,6 +481,9 @@ func (cli *Client) MakeFullRequestWithResp(ctx context.Context, params FullReque if cli == nil { return nil, nil, ErrClientIsNil } + if cli.HomeserverURL == nil || cli.HomeserverURL.Scheme == "" { + return nil, nil, ErrClientHasNoHomeserver + } if params.MaxAttempts == 0 { maxAttempts, ok := ctx.Value(MaxAttemptsContextKey).(int) if ok && maxAttempts > 0 { diff --git a/error.go b/error.go index 6f5dbe72..6f4880df 100644 --- a/error.go +++ b/error.go @@ -77,7 +77,8 @@ var ( ) var ( - ErrClientIsNil = errors.New("client is nil") + ErrClientIsNil = errors.New("client is nil") + ErrClientHasNoHomeserver = errors.New("client has no homeserver set") ) // HTTPError An HTTP Error response, which may wrap an underlying native Go Error. From 27769dfc98bebdcec16bb2293029904109a2b9df Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Wed, 7 May 2025 15:33:33 +0100 Subject: [PATCH 074/581] bridgev2: add shared event handling context This context is then passed into the network connectors handlers and message conversion functions which may require making network requests, which before this would not be canceled on bridge stop. --- bridgev2/bridge.go | 10 ++++++++++ bridgev2/portal.go | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index 38f7ce1d..2e1fe8f1 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -54,6 +54,9 @@ type Bridge struct { wakeupBackfillQueue chan struct{} stopBackfillQueue *exsync.Event + + backgroundCtx context.Context + cancelBackgroundCtx context.CancelFunc } func NewBridge( @@ -108,6 +111,10 @@ func (e DBUpgradeError) Unwrap() error { } func (br *Bridge) Start(ctx context.Context) error { + if br.backgroundCtx == nil || br.backgroundCtx.Err() != nil { + // Ensure we have a valid event handling context + br.backgroundCtx, br.cancelBackgroundCtx = context.WithCancel(context.Background()) + } ctx = br.Log.WithContext(ctx) err := br.StartConnectors(ctx) if err != nil { @@ -332,6 +339,9 @@ func (br *Bridge) stop(isRunOnce bool) { wg.Wait() } br.Matrix.Stop() + if br.cancelBackgroundCtx != nil { + br.cancelBackgroundCtx() + } if stopNet, ok := br.Network.(StoppableNetwork); ok { stopNet.Stop() } diff --git a/bridgev2/portal.go b/bridgev2/portal.go index cfdad822..a7ca5995 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -375,7 +375,7 @@ func (portal *Portal) getEventCtxWithLog(rawEvt any, idx int) context.Context { case *portalCreateEvent: return evt.ctx } - return logWith.Logger().WithContext(context.Background()) + return logWith.Logger().WithContext(portal.Bridge.backgroundCtx) } func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCallback func()) { From 376fa1f36898e60ba2d4e4142499ed6470ab28c0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 8 May 2025 15:25:20 +0300 Subject: [PATCH 075/581] bridgev2: fix initializing background context --- bridgev2/bridge.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index 2e1fe8f1..bef0c79c 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -111,10 +111,6 @@ func (e DBUpgradeError) Unwrap() error { } func (br *Bridge) Start(ctx context.Context) error { - if br.backgroundCtx == nil || br.backgroundCtx.Err() != nil { - // Ensure we have a valid event handling context - br.backgroundCtx, br.cancelBackgroundCtx = context.WithCancel(context.Background()) - } ctx = br.Log.WithContext(ctx) err := br.StartConnectors(ctx) if err != nil { @@ -174,6 +170,9 @@ func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, pa func (br *Bridge) StartConnectors(ctx context.Context) error { br.Log.Info().Msg("Starting bridge") + if br.backgroundCtx == nil || br.backgroundCtx.Err() != nil { + br.backgroundCtx, br.cancelBackgroundCtx = context.WithCancel(context.Background()) + } if !br.ExternallyManagedDB { err := br.DB.Upgrade(ctx) From 23d91b64cb68dc897797a1a48de530c5b95c8a00 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 9 May 2025 14:25:43 +0300 Subject: [PATCH 076/581] bridgev2: fall back to remote ID for state update notices --- bridgev2/bridgestate.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bridgev2/bridgestate.go b/bridgev2/bridgestate.go index 148b522c..81ec8160 100644 --- a/bridgev2/bridgestate.go +++ b/bridgev2/bridgestate.go @@ -89,7 +89,11 @@ func (bsq *BridgeStateQueue) sendNotice(ctx context.Context, state status.Bridge bsq.login.Log.Err(err).Msg("Failed to get management room") return } - message := fmt.Sprintf("State update for %s: `%s`", bsq.login.RemoteName, state.StateEvent) + name := bsq.login.RemoteName + if name == "" { + name = fmt.Sprintf("`%s`", bsq.login.ID) + } + message := fmt.Sprintf("State update for %s: `%s`", name, state.StateEvent) if state.Error != "" { message += fmt.Sprintf(" (`%s`)", state.Error) } From a0191c8f5847a67568c86de7c958ad89eeada97c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 9 May 2025 15:16:14 +0300 Subject: [PATCH 077/581] bridgev2: expose background context --- bridgev2/bridge.go | 6 +++--- bridgev2/portal.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index bef0c79c..05a67b6a 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -55,7 +55,7 @@ type Bridge struct { wakeupBackfillQueue chan struct{} stopBackfillQueue *exsync.Event - backgroundCtx context.Context + BackgroundCtx context.Context cancelBackgroundCtx context.CancelFunc } @@ -170,8 +170,8 @@ func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, pa func (br *Bridge) StartConnectors(ctx context.Context) error { br.Log.Info().Msg("Starting bridge") - if br.backgroundCtx == nil || br.backgroundCtx.Err() != nil { - br.backgroundCtx, br.cancelBackgroundCtx = context.WithCancel(context.Background()) + if br.BackgroundCtx == nil || br.BackgroundCtx.Err() != nil { + br.BackgroundCtx, br.cancelBackgroundCtx = context.WithCancel(context.Background()) } if !br.ExternallyManagedDB { diff --git a/bridgev2/portal.go b/bridgev2/portal.go index a7ca5995..63081f57 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -375,7 +375,7 @@ func (portal *Portal) getEventCtxWithLog(rawEvt any, idx int) context.Context { case *portalCreateEvent: return evt.ctx } - return logWith.Logger().WithContext(portal.Bridge.backgroundCtx) + return logWith.Logger().WithContext(portal.Bridge.BackgroundCtx) } func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCallback func()) { From f23fc99ef40d933502342e0e2b339f05bac80595 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 10 May 2025 11:32:42 +0300 Subject: [PATCH 078/581] crypto/cross_signing: allow json marshaling cross-signing key seeds --- crypto/cross_sign_key.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crypto/cross_sign_key.go b/crypto/cross_sign_key.go index 97ecd865..4094f695 100644 --- a/crypto/cross_sign_key.go +++ b/crypto/cross_sign_key.go @@ -11,6 +11,8 @@ import ( "context" "fmt" + "go.mau.fi/util/jsonbytes" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/crypto/olm" "maunium.net/go/mautrix/crypto/signatures" @@ -33,9 +35,9 @@ func (cskc *CrossSigningKeysCache) PublicKeys() *CrossSigningPublicKeysCache { } type CrossSigningSeeds struct { - MasterKey []byte - SelfSigningKey []byte - UserSigningKey []byte + MasterKey jsonbytes.UnpaddedURLBytes `json:"m.cross_signing.master"` + SelfSigningKey jsonbytes.UnpaddedURLBytes `json:"m.cross_signing.self_signing"` + UserSigningKey jsonbytes.UnpaddedURLBytes `json:"m.cross_signing.user_signing"` } func (mach *OlmMachine) ExportCrossSigningKeys() CrossSigningSeeds { From 978e0983eadf3815b0b63c8a8f6df07493209417 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 15 May 2025 14:15:34 +0300 Subject: [PATCH 079/581] dependencies: update --- go.mod | 22 +++++++++++----------- go.sum | 40 ++++++++++++++++++++-------------------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/go.mod b/go.mod index e279118e..fbe4274e 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module maunium.net/go/mautrix go 1.23.0 -toolchain go1.24.2 +toolchain go1.24.3 require ( filippo.io/edwards25519 v1.1.0 @@ -10,20 +10,20 @@ require ( github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.5.0 github.com/lib/pq v1.10.9 - github.com/mattn/go-sqlite3 v1.14.27 + github.com/mattn/go-sqlite3 v1.14.28 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.34.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 - github.com/yuin/goldmark v1.7.10 - go.mau.fi/util v0.8.7-0.20250427215252-d2d18a7e463c + github.com/yuin/goldmark v1.7.11 + go.mau.fi/util v0.8.7-0.20250515110144-747f5904911e go.mau.fi/zeroconfig v0.1.3 - golang.org/x/crypto v0.37.0 - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 - golang.org/x/net v0.39.0 - golang.org/x/sync v0.13.0 + golang.org/x/crypto v0.38.0 + golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 + golang.org/x/net v0.40.0 + golang.org/x/sync v0.14.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 ) @@ -33,11 +33,11 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a // indirect + github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index f103b287..3fbbb766 100644 --- a/go.sum +++ b/go.sum @@ -26,10 +26,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= -github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a h1:S+AGcmAESQ0pXCUNnRH7V+bOUIgkSX5qVt2cNKCrm0Q= -github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb h1:3PrKuO92dUTMrQ9dx0YNejC6U/Si6jqKmyQ9vWjwqR4= +github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -51,28 +51,28 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/yuin/goldmark v1.7.10 h1:S+LrtBjRmqMac2UdtB6yyCEJm+UILZ2fefI4p7o0QpI= -github.com/yuin/goldmark v1.7.10/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.8.7-0.20250427215252-d2d18a7e463c h1:qfJyMZq1pPyuXKoVWwHs6OmR9CzO3pHFRPYT/QpaaaA= -go.mau.fi/util v0.8.7-0.20250427215252-d2d18a7e463c/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= +github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo= +github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mau.fi/util v0.8.7-0.20250515110144-747f5904911e h1:8kfjOQ+L38Zq2HbhMFVhbkTdwiGbAmgTriioRnRB+LQ= +go.mau.fi/util v0.8.7-0.20250515110144-747f5904911e/go.mod h1:j6R3cENakc1f8HpQeFl0N15UiSTcNmIfDBNJUbL71RY= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= -golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= From 0a8e8230164055ea3d6f59fcefbba05756abd907 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 16 May 2025 08:13:16 +0300 Subject: [PATCH 080/581] Bump version to v0.24.0 --- CHANGELOG.md | 25 +++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- version.go | 2 +- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 565d7f15..95f214c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +## v0.24.0 (2025-05-16) + +* *(commands)* Added generic framework for implementing bot commands. +* *(client)* Added support for specifying maximum number of HTTP retries using + a context value instead of having to call `MakeFullRequest` manually. +* *(client,federation)* Added methods for fetching room directories. +* *(federation)* Added support for server side of request authentication. +* *(synapseadmin)* Added wrapper for the account suspension endpoint. +* *(format)* Added method for safely wrapping a string in markdown inline code. +* *(crypto)* Added method to import key backup without persisting to database, + to allow the client more control over the process. +* *(bridgev2)* Added viewing chat interface to signal when the user is viewing + a given chat. +* *(bridgev2)* Added option to pass through transaction ID from client when + sending messages to remote network. +* *(crypto)* Fixed unnecessary error log when decrypting dummy events used for + unwedging Olm sessions. +* *(crypto)* Fixed `forwarding_curve25519_key_chain` not being set consistently + when backing up keys. +* *(event)* Fixed marshaling legacy VoIP events with no version field. +* *(bridgev2)* Fixed disappearing message references not being deleted when the + portal is deleted. +* *(bridgev2)* Fixed read receipt bridging not ignoring fake message entries + and causing unnecessary error logs. + ## v0.23.3 (2025-04-16) * *(commands)* Added generic command processing framework for bots. diff --git a/go.mod b/go.mod index fbe4274e..ebc7a61c 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.11 - go.mau.fi/util v0.8.7-0.20250515110144-747f5904911e + go.mau.fi/util v0.8.7 go.mau.fi/zeroconfig v0.1.3 golang.org/x/crypto v0.38.0 golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 diff --git a/go.sum b/go.sum index 3fbbb766..a3c7542d 100644 --- a/go.sum +++ b/go.sum @@ -53,8 +53,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo= github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.8.7-0.20250515110144-747f5904911e h1:8kfjOQ+L38Zq2HbhMFVhbkTdwiGbAmgTriioRnRB+LQ= -go.mau.fi/util v0.8.7-0.20250515110144-747f5904911e/go.mod h1:j6R3cENakc1f8HpQeFl0N15UiSTcNmIfDBNJUbL71RY= +go.mau.fi/util v0.8.7 h1:ywKarPxouJQEEijTs4mPlxC7F4AWEKokEpWc+2TYy6c= +go.mau.fi/util v0.8.7/go.mod h1:j6R3cENakc1f8HpQeFl0N15UiSTcNmIfDBNJUbL71RY= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= diff --git a/version.go b/version.go index 2e670697..8366c5bf 100644 --- a/version.go +++ b/version.go @@ -7,7 +7,7 @@ import ( "strings" ) -const Version = "v0.23.3" +const Version = "v0.24.0" var GoModVersion = "" var Commit = "" From a205a77db46adefe4e9bfab7cf6ff1d8a6752424 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Tue, 20 May 2025 10:27:35 +0100 Subject: [PATCH 081/581] bridgev2: add `CredentialExportingNetworkAPI` interface --- bridgev2/networkinterface.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 14e3a681..76db8cc8 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -387,6 +387,13 @@ type BackgroundSyncingNetworkAPI interface { ConnectBackground(ctx context.Context, params *ConnectBackgroundParams) error } +// CredentialExportingNetworkAPI is an optional interface that networks connectors can implement to support export of +// the credentials associated with that login. Credential type is bridge specific. +type CredentialExportingNetworkAPI interface { + NetworkAPI + ExportCredentials(ctx context.Context) any +} + // FetchMessagesParams contains the parameters for a message history pagination request. type FetchMessagesParams struct { // The portal to fetch messages in. Always present. From 487fc699fe8f4154b45f37cccfcea3d915eea9c0 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Tue, 20 May 2025 10:32:33 +0100 Subject: [PATCH 082/581] bridgev2/provisioning: add session transfer support For connector logins that support it this will expose an API to transfer credentials between bridge instances. Currently does not do any extra validation beyond the usual provisioning API request validation (so shared secret or matrix token). One future improvement would be to require clients to sign incoming requests, and to then validate a) the signature and b) the device is verified. --- bridgev2/bridgeconfig/config.go | 7 +- bridgev2/matrix/mxmain/example-config.yaml | 3 + bridgev2/matrix/provisioning.go | 121 +++++++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index bd7746d1..37517818 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -95,9 +95,10 @@ type AnalyticsConfig struct { } type ProvisioningConfig struct { - Prefix string `yaml:"prefix"` - SharedSecret string `yaml:"shared_secret"` - DebugEndpoints bool `yaml:"debug_endpoints"` + Prefix string `yaml:"prefix"` + SharedSecret string `yaml:"shared_secret"` + DebugEndpoints bool `yaml:"debug_endpoints"` + EnableSessionTransfers bool `yaml:"enable_session_transfers"` } type DirectMediaConfig struct { diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 4dee2650..a9d05fd1 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -252,6 +252,9 @@ provisioning: allow_matrix_auth: true # Enable debug API at /debug with provisioning authentication. debug_endpoints: false + # Enable session transfers between bridges. Note that this only validates Matrix or shared secret + # auth before passing live network client credentials down in the response. + enable_session_transfers: false # Some networks require publicly accessible media download links (e.g. for user avatars when using Discord webhooks). # These settings control whether the bridge will provide such public media access. diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index d809d039..2b9b5124 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -53,6 +53,11 @@ type ProvisioningAPI struct { matrixAuthCache map[string]matrixAuthCacheEntry matrixAuthCacheLock sync.Mutex + // Set for a given login once credentials have been exported, once in this state the finish + // API is available which will call logout on the client in question. + sessionTransfers map[networkid.UserLoginID]struct{} + sessionTransfersLock sync.Mutex + // GetAuthFromRequest is a custom function for getting the auth token from // the request if the Authorization header is not present. GetAuthFromRequest func(r *http.Request) string @@ -101,6 +106,7 @@ func (br *Connector) GetProvisioning() IProvisioningAPI { func (prov *ProvisioningAPI) Init() { prov.matrixAuthCache = make(map[string]matrixAuthCacheEntry) prov.logins = make(map[string]*ProvLogin) + prov.sessionTransfers = make(map[networkid.UserLoginID]struct{}) prov.net = prov.br.Bridge.Network prov.log = prov.br.Log.With().Str("component", "provisioning").Logger() prov.fedClient = federation.NewClient("", nil, nil) @@ -128,6 +134,12 @@ func (prov *ProvisioningAPI) Init() { prov.Router.Path("/v3/create_dm/{identifier}").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostCreateDM) prov.Router.Path("/v3/create_group").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostCreateGroup) + if prov.br.Config.Provisioning.EnableSessionTransfers { + prov.log.Debug().Msg("Enabling session transfer API") + prov.Router.Path("/v3/session_transfer/init").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostInitSessionTransfer) + prov.Router.Path("/v3/session_transfer/finish").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostFinishSessionTransfer) + } + if prov.br.Config.Provisioning.DebugEndpoints { prov.log.Debug().Msg("Enabling debug API at /debug") r := prov.br.AS.Router.PathPrefix("/debug").Subrouter() @@ -791,3 +803,112 @@ func (prov *ProvisioningAPI) PostCreateGroup(w http.ResponseWriter, r *http.Requ ErrCode: mautrix.MUnrecognized.ErrCode, }) } + +type ReqExportCredentials struct { + RemoteID networkid.UserLoginID `json:"remote_name"` +} + +type RespExportCredentials struct { + Credentials any `json:"credentials"` +} + +func (prov *ProvisioningAPI) PostInitSessionTransfer(w http.ResponseWriter, r *http.Request) { + prov.sessionTransfersLock.Lock() + defer prov.sessionTransfersLock.Unlock() + + var req ReqExportCredentials + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") + jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ + Err: "Failed to decode request body", + ErrCode: mautrix.MNotJSON.ErrCode, + }) + return + } + + user := prov.GetUser(r) + logins := user.GetUserLogins() + var loginToExport *bridgev2.UserLogin + for _, login := range logins { + if login.ID == req.RemoteID { + loginToExport = login + break + } + } + if loginToExport == nil { + jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ + Err: "No matching user login found", + ErrCode: mautrix.MNotFound.ErrCode, + }) + return + } + + client, ok := loginToExport.Client.(bridgev2.CredentialExportingNetworkAPI) + if !ok { + jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ + Err: "Client does not support credential exporting", + ErrCode: mautrix.MInvalidParam.ErrCode, + }) + return + } + + if _, ok := prov.sessionTransfers[loginToExport.ID]; ok { + // Warn, but allow, double exports. This might happen if a client crashes handling creds, + // and should be safe to call multiple times. + zerolog.Ctx(r.Context()).Warn().Msg("Exporting already exported credentials") + } + + resp := RespExportCredentials{ + Credentials: client.ExportCredentials(r.Context()), + } + jsonResponse(w, http.StatusOK, resp) +} + +func (prov *ProvisioningAPI) PostFinishSessionTransfer(w http.ResponseWriter, r *http.Request) { + prov.sessionTransfersLock.Lock() + defer prov.sessionTransfersLock.Unlock() + + var req ReqExportCredentials + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") + jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ + Err: "Failed to decode request body", + ErrCode: mautrix.MNotJSON.ErrCode, + }) + return + } + + user := prov.GetUser(r) + logins := user.GetUserLogins() + var loginToExport *bridgev2.UserLogin + for _, login := range logins { + if login.ID == req.RemoteID { + loginToExport = login + break + } + } + if loginToExport == nil { + jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ + Err: "No matching user login found", + ErrCode: mautrix.MNotFound.ErrCode, + }) + return + } else if _, ok := prov.sessionTransfers[loginToExport.ID]; !ok { + jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ + Err: "No matching credential export found", + ErrCode: mautrix.MNotJSON.ErrCode, + }) + return + } + + zerolog.Ctx(r.Context()).Info(). + Str("remote_name", string(req.RemoteID)). + Msg("Logging out remote after finishing credential export") + + loginToExport.Client.LogoutRemote(r.Context()) + delete(prov.sessionTransfers, req.RemoteID) + + jsonResponse(w, http.StatusOK, struct{}{}) +} From a3efaa36322985859faa1d31a95907db1905d0ad Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Thu, 22 May 2025 15:20:36 +0100 Subject: [PATCH 083/581] bridgev2/provisioning: disconnect login before exporting credentials --- bridgev2/matrix/provisioning.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 2b9b5124..eacc86c6 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -859,6 +859,8 @@ func (prov *ProvisioningAPI) PostInitSessionTransfer(w http.ResponseWriter, r *h zerolog.Ctx(r.Context()).Warn().Msg("Exporting already exported credentials") } + // Disconnect now so we don't use the same network session in two places at once + client.Disconnect() resp := RespExportCredentials{ Credentials: client.ExportCredentials(r.Context()), } From 203e402ebf1af54f215279757e1a5a03a870fd3c Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Thu, 22 May 2025 15:22:13 +0100 Subject: [PATCH 084/581] bridgev2/provisioning: correct field name --- bridgev2/matrix/provisioning.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index eacc86c6..d05b005e 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -805,7 +805,7 @@ func (prov *ProvisioningAPI) PostCreateGroup(w http.ResponseWriter, r *http.Requ } type ReqExportCredentials struct { - RemoteID networkid.UserLoginID `json:"remote_name"` + RemoteID networkid.UserLoginID `json:"remote_id"` } type RespExportCredentials struct { From ad8145c43b4e46c00dd000ceb06a126479e6b9cc Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 24 May 2025 14:23:18 +0300 Subject: [PATCH 085/581] synapseadmin: don't embed mautrix.Client in admin client struct --- synapseadmin/client.go | 4 ++-- synapseadmin/register.go | 4 ++-- synapseadmin/roomapi.go | 26 ++++++++++++++++---------- synapseadmin/userapi.go | 22 +++++++++++----------- 4 files changed, 31 insertions(+), 25 deletions(-) diff --git a/synapseadmin/client.go b/synapseadmin/client.go index 775b4b13..6925ca7d 100644 --- a/synapseadmin/client.go +++ b/synapseadmin/client.go @@ -14,9 +14,9 @@ import ( // // https://matrix-org.github.io/synapse/latest/usage/administration/admin_api/index.html type Client struct { - *mautrix.Client + Client *mautrix.Client } func (cli *Client) BuildAdminURL(path ...any) string { - return cli.BuildURL(mautrix.SynapseAdminURLPath(path)) + return cli.Client.BuildURL(mautrix.SynapseAdminURLPath(path)) } diff --git a/synapseadmin/register.go b/synapseadmin/register.go index 641f9b56..05e0729a 100644 --- a/synapseadmin/register.go +++ b/synapseadmin/register.go @@ -73,7 +73,7 @@ func (req *ReqSharedSecretRegister) Sign(secret string) string { // This does not need to be called manually as SharedSecretRegister will automatically call this if no nonce is provided. func (cli *Client) GetRegisterNonce(ctx context.Context) (string, error) { var resp respGetRegisterNonce - _, err := cli.MakeRequest(ctx, http.MethodGet, cli.BuildURL(mautrix.SynapseAdminURLPath{"v1", "register"}), nil, &resp) + _, err := cli.Client.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v1", "register"), nil, &resp) if err != nil { return "", err } @@ -93,7 +93,7 @@ func (cli *Client) SharedSecretRegister(ctx context.Context, sharedSecret string } req.SHA1Checksum = req.Sign(sharedSecret) var resp mautrix.RespRegister - _, err = cli.MakeRequest(ctx, http.MethodPost, cli.BuildURL(mautrix.SynapseAdminURLPath{"v1", "register"}), &req, &resp) + _, err = cli.Client.MakeRequest(ctx, http.MethodPost, cli.BuildAdminURL("v1", "register"), &req, &resp) if err != nil { return nil, err } diff --git a/synapseadmin/roomapi.go b/synapseadmin/roomapi.go index 6c072e23..b2d82fb3 100644 --- a/synapseadmin/roomapi.go +++ b/synapseadmin/roomapi.go @@ -76,11 +76,17 @@ type RespListRooms struct { func (cli *Client) ListRooms(ctx context.Context, req ReqListRoom) (RespListRooms, error) { var resp RespListRooms var reqURL string - reqURL = cli.BuildURLWithQuery(mautrix.SynapseAdminURLPath{"v1", "rooms"}, req.BuildQuery()) - _, err := cli.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) + reqURL = cli.Client.BuildURLWithQuery(mautrix.SynapseAdminURLPath{"v1", "rooms"}, req.BuildQuery()) + _, err := cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) return resp, err } +func (cli *Client) RoomInfo(ctx context.Context, roomID id.RoomID) (resp *RoomInfo, err error) { + reqURL := cli.BuildAdminURL("v1", "rooms", roomID) + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) + return +} + type RespRoomMessages = mautrix.RespMessages // RoomMessages returns a list of messages in a room. @@ -104,8 +110,8 @@ func (cli *Client) RoomMessages(ctx context.Context, roomID id.RoomID, from, to if limit != 0 { query["limit"] = strconv.Itoa(limit) } - urlPath := cli.BuildURLWithQuery(mautrix.SynapseAdminURLPath{"v1", "rooms", roomID, "messages"}, query) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + urlPath := cli.Client.BuildURLWithQuery(mautrix.SynapseAdminURLPath{"v1", "rooms", roomID, "messages"}, query) + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) return resp, err } @@ -129,7 +135,7 @@ type RespDeleteRoom struct { func (cli *Client) DeleteRoom(ctx context.Context, roomID id.RoomID, req ReqDeleteRoom) (RespDeleteRoom, error) { reqURL := cli.BuildAdminURL("v2", "rooms", roomID) var resp RespDeleteRoom - _, err := cli.MakeRequest(ctx, http.MethodDelete, reqURL, &req, &resp) + _, err := cli.Client.MakeRequest(ctx, http.MethodDelete, reqURL, &req, &resp) return resp, err } @@ -144,7 +150,7 @@ type RespRoomsMembers struct { func (cli *Client) RoomMembers(ctx context.Context, roomID id.RoomID) (RespRoomsMembers, error) { reqURL := cli.BuildAdminURL("v1", "rooms", roomID, "members") var resp RespRoomsMembers - _, err := cli.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) + _, err := cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) return resp, err } @@ -157,7 +163,7 @@ type ReqMakeRoomAdmin struct { // https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#make-room-admin-api func (cli *Client) MakeRoomAdmin(ctx context.Context, roomIDOrAlias string, req ReqMakeRoomAdmin) error { reqURL := cli.BuildAdminURL("v1", "rooms", roomIDOrAlias, "make_room_admin") - _, err := cli.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) + _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) return err } @@ -170,7 +176,7 @@ type ReqJoinUserToRoom struct { // https://matrix-org.github.io/synapse/latest/admin_api/room_membership.html func (cli *Client) JoinUserToRoom(ctx context.Context, roomID id.RoomID, req ReqJoinUserToRoom) error { reqURL := cli.BuildAdminURL("v1", "join", roomID) - _, err := cli.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) + _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) return err } @@ -183,7 +189,7 @@ type ReqBlockRoom struct { // https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#block-room-api func (cli *Client) BlockRoom(ctx context.Context, roomID id.RoomID, req ReqBlockRoom) error { reqURL := cli.BuildAdminURL("v1", "rooms", roomID, "block") - _, err := cli.MakeRequest(ctx, http.MethodPut, reqURL, &req, nil) + _, err := cli.Client.MakeRequest(ctx, http.MethodPut, reqURL, &req, nil) return err } @@ -199,6 +205,6 @@ type RoomsBlockResponse struct { func (cli *Client) GetRoomBlockStatus(ctx context.Context, roomID id.RoomID) (RoomsBlockResponse, error) { var resp RoomsBlockResponse reqURL := cli.BuildAdminURL("v1", "rooms", roomID, "block") - _, err := cli.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) + _, err := cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) return resp, err } diff --git a/synapseadmin/userapi.go b/synapseadmin/userapi.go index d3672367..b1de55b6 100644 --- a/synapseadmin/userapi.go +++ b/synapseadmin/userapi.go @@ -32,7 +32,7 @@ type ReqResetPassword struct { // https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#reset-password func (cli *Client) ResetPassword(ctx context.Context, req ReqResetPassword) error { reqURL := cli.BuildAdminURL("v1", "reset_password", req.UserID) - _, err := cli.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) + _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) return err } @@ -43,8 +43,8 @@ func (cli *Client) ResetPassword(ctx context.Context, req ReqResetPassword) erro // // https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#check-username-availability func (cli *Client) UsernameAvailable(ctx context.Context, username string) (resp *mautrix.RespRegisterAvailable, err error) { - u := cli.BuildURLWithQuery(mautrix.SynapseAdminURLPath{"v1", "username_available"}, map[string]string{"username": username}) - _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &resp) + u := cli.Client.BuildURLWithQuery(mautrix.SynapseAdminURLPath{"v1", "username_available"}, map[string]string{"username": username}) + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, u, nil, &resp) if err == nil && !resp.Available { err = fmt.Errorf(`request returned OK status without "available": true`) } @@ -65,7 +65,7 @@ type RespListDevices struct { // // https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#list-all-devices func (cli *Client) ListDevices(ctx context.Context, userID id.UserID) (resp *RespListDevices, err error) { - _, err = cli.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v2", "users", userID, "devices"), nil, &resp) + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v2", "users", userID, "devices"), nil, &resp) return } @@ -89,7 +89,7 @@ type RespUserInfo struct { // // https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#query-user-account func (cli *Client) GetUserInfo(ctx context.Context, userID id.UserID) (resp *RespUserInfo, err error) { - _, err = cli.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v2", "users", userID), nil, &resp) + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v2", "users", userID), nil, &resp) return } @@ -102,7 +102,7 @@ type ReqDeleteUser struct { // https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#deactivate-account func (cli *Client) DeactivateAccount(ctx context.Context, userID id.UserID, req ReqDeleteUser) error { reqURL := cli.BuildAdminURL("v1", "deactivate", userID) - _, err := cli.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) + _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) return err } @@ -115,7 +115,7 @@ type ReqSuspendUser struct { // https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#suspendunsuspend-account func (cli *Client) SuspendAccount(ctx context.Context, userID id.UserID, req ReqSuspendUser) error { reqURL := cli.BuildAdminURL("v1", "suspend", userID) - _, err := cli.MakeRequest(ctx, http.MethodPut, reqURL, &req, nil) + _, err := cli.Client.MakeRequest(ctx, http.MethodPut, reqURL, &req, nil) return err } @@ -137,7 +137,7 @@ type ReqCreateOrModifyAccount struct { // https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#create-or-modify-account func (cli *Client) CreateOrModifyAccount(ctx context.Context, userID id.UserID, req ReqCreateOrModifyAccount) error { reqURL := cli.BuildAdminURL("v2", "users", userID) - _, err := cli.MakeRequest(ctx, http.MethodPut, reqURL, &req, nil) + _, err := cli.Client.MakeRequest(ctx, http.MethodPut, reqURL, &req, nil) return err } @@ -153,7 +153,7 @@ type ReqSetRatelimit = RatelimitOverride // https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#set-ratelimit func (cli *Client) SetUserRatelimit(ctx context.Context, userID id.UserID, req ReqSetRatelimit) error { reqURL := cli.BuildAdminURL("v1", "users", userID, "override_ratelimit") - _, err := cli.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) + _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) return err } @@ -163,7 +163,7 @@ type RespUserRatelimit = RatelimitOverride // // https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#get-status-of-ratelimit func (cli *Client) GetUserRatelimit(ctx context.Context, userID id.UserID) (resp RespUserRatelimit, err error) { - _, err = cli.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v1", "users", userID, "override_ratelimit"), nil, &resp) + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v1", "users", userID, "override_ratelimit"), nil, &resp) return } @@ -171,6 +171,6 @@ func (cli *Client) GetUserRatelimit(ctx context.Context, userID id.UserID) (resp // // https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#delete-ratelimit func (cli *Client) DeleteUserRatelimit(ctx context.Context, userID id.UserID) (err error) { - _, err = cli.MakeRequest(ctx, http.MethodDelete, cli.BuildAdminURL("v1", "users", userID, "override_ratelimit"), nil, nil) + _, err = cli.Client.MakeRequest(ctx, http.MethodDelete, cli.BuildAdminURL("v1", "users", userID, "override_ratelimit"), nil, nil) return } From 49d2f391835481fcf100ea39ca0418b635c7445c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 24 May 2025 14:35:00 +0300 Subject: [PATCH 086/581] format: add markdown link utilities --- format/markdown.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/format/markdown.go b/format/markdown.go index f6181ed9..3d9979b4 100644 --- a/format/markdown.go +++ b/format/markdown.go @@ -18,6 +18,7 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format/mdext" + "maunium.net/go/mautrix/id" ) const paragraphStart = "

" @@ -41,7 +42,7 @@ func UnwrapSingleParagraph(html string) string { return html } -var mdEscapeRegex = regexp.MustCompile("([\\\\`*_[\\]])") +var mdEscapeRegex = regexp.MustCompile("([\\\\`*_[\\]()])") func EscapeMarkdown(text string) string { text = mdEscapeRegex.ReplaceAllString(text, "\\$1") @@ -50,7 +51,23 @@ func EscapeMarkdown(text string) string { return text } +type uriAble interface { + String() string + URI() *id.MatrixURI +} + +func MarkdownMention(id uriAble) string { + return MarkdownLink(id.String(), id.URI().MatrixToURL()) +} + +func MarkdownLink(name string, url string) string { + return fmt.Sprintf("[%s](%s)", EscapeMarkdown(name), EscapeMarkdown(url)) +} + func SafeMarkdownCode[T ~string](textInput T) string { + if textInput == "" { + return "` `" + } text := strings.ReplaceAll(string(textInput), "\n", " ") backtickCount := exstrings.LongestSequenceOf(text, '`') if backtickCount == 0 { From 50f0b5fa7d581a2d26ae3a8a27944ddc0c3a47cb Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 24 May 2025 14:42:06 +0300 Subject: [PATCH 087/581] synapseadmin: add support for synchronous room delete --- synapseadmin/roomapi.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/synapseadmin/roomapi.go b/synapseadmin/roomapi.go index b2d82fb3..fa391b73 100644 --- a/synapseadmin/roomapi.go +++ b/synapseadmin/roomapi.go @@ -127,6 +127,14 @@ type RespDeleteRoom struct { DeleteID string `json:"delete_id"` } +type RespDeleteRoomStatus struct { + Status string `json:"status,omitempty"` + KickedUsers []id.UserID `json:"kicked_users,omitempty"` + FailedToKickUsers []id.UserID `json:"failed_to_kick_users,omitempty"` + LocalAliases []id.RoomAlias `json:"local_aliases,omitempty"` + NewRoomID id.RoomID `json:"new_room_id,omitempty"` +} + // DeleteRoom deletes a room from the server, optionally blocking it and/or purging all data from the database. // // This calls the async version of the endpoint, which will return immediately and delete the room in the background. @@ -139,6 +147,27 @@ func (cli *Client) DeleteRoom(ctx context.Context, roomID id.RoomID, req ReqDele return resp, err } +// DeleteRoomSync deletes a room from the server, optionally blocking it and/or purging all data from the database. +// +// This calls the synchronous version of the endpoint, which will block until the room is deleted. +// +// https://element-hq.github.io/synapse/latest/admin_api/rooms.html#version-1-old-version +func (cli *Client) DeleteRoomSync(ctx context.Context, roomID id.RoomID, req ReqDeleteRoom) (resp RespDeleteRoomStatus, err error) { + reqURL := cli.BuildAdminURL("v1", "rooms", roomID) + httpClient := &http.Client{} + _, err = cli.Client.MakeFullRequest(ctx, mautrix.FullRequest{ + Method: http.MethodDelete, + URL: reqURL, + RequestJSON: &req, + ResponseJSON: &resp, + MaxAttempts: 1, + // Use a fresh HTTP client without timeouts + Client: httpClient, + }) + httpClient.CloseIdleConnections() + return +} + type RespRoomsMembers struct { Members []id.UserID `json:"members"` Total int `json:"total"` From 68565a1f18c80e261c2a818b1517fa310c06317a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 24 May 2025 15:35:33 +0300 Subject: [PATCH 088/581] client: add wrapper for /relations endpoints --- client.go | 6 ++++++ requests.go | 41 +++++++++++++++++++++++++++++++++++++++++ responses.go | 7 +++++++ 3 files changed, 54 insertions(+) diff --git a/client.go b/client.go index c8e366b0..bf3bb16e 100644 --- a/client.go +++ b/client.go @@ -2088,6 +2088,12 @@ func (cli *Client) GetUnredactedEventContent(ctx context.Context, roomID id.Room return } +func (cli *Client) GetRelations(ctx context.Context, roomID id.RoomID, eventID id.EventID, req *ReqGetRelations) (resp *RespGetRelations, err error) { + urlPath := cli.BuildURLWithQuery(append(ClientURLPath{"v1", "rooms", roomID, "relations", eventID}, req.PathSuffix()...), req.Query()) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + func (cli *Client) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID) (err error) { return cli.SendReceipt(ctx, roomID, eventID, event.ReceiptTypeRead, nil) } diff --git a/requests.go b/requests.go index 1bed6c7e..42d257fb 100644 --- a/requests.go +++ b/requests.go @@ -543,3 +543,44 @@ type ReqReport struct { Reason string `json:"reason,omitempty"` Score int `json:"score,omitempty"` } + +type ReqGetRelations struct { + RelationType event.RelationType + EventType event.Type + + Dir Direction + From string + To string + Limit int + Recurse bool +} + +func (rgr *ReqGetRelations) PathSuffix() ClientURLPath { + if rgr.RelationType != "" { + if rgr.EventType.Type != "" { + return ClientURLPath{rgr.RelationType, rgr.EventType.Type} + } + return ClientURLPath{rgr.RelationType} + } + return ClientURLPath{} +} + +func (rgr *ReqGetRelations) Query() map[string]string { + query := map[string]string{} + if rgr.Dir != 0 { + query["dir"] = string(rgr.Dir) + } + if rgr.From != "" { + query["from"] = rgr.From + } + if rgr.To != "" { + query["to"] = rgr.To + } + if rgr.Limit > 0 { + query["limit"] = strconv.Itoa(rgr.Limit) + } + if rgr.Recurse { + query["recurse"] = "true" + } + return query +} diff --git a/responses.go b/responses.go index ee7f4703..20d02af5 100644 --- a/responses.go +++ b/responses.go @@ -709,3 +709,10 @@ type RespOpenIDToken struct { MatrixServerName string `json:"matrix_server_name"` TokenType string `json:"token_type"` // Always "Bearer" } + +type RespGetRelations struct { + Chunk []*event.Event `json:"chunk"` + NextBatch string `json:"next_batch,omitempty"` + PrevBatch string `json:"prev_batch,omitempty"` + RecursionDepth int `json:"recursion_depth,omitempty"` +} From e9dfee45c0d8682e711c7e7d027a7d1e1463bad8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 24 May 2025 16:09:09 +0300 Subject: [PATCH 089/581] event: add missing letter to docstring --- event/content.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/event/content.go b/event/content.go index 2347898e..b56e35f2 100644 --- a/event/content.go +++ b/event/content.go @@ -123,7 +123,7 @@ var TypeMap = map[Type]reflect.Type{ // When being marshaled into JSON, the data in Parsed will be marshaled first and then recursively merged // with the data in Raw. Values in Raw are preferred, but nested objects will be recursed into before merging, // rather than overriding the whole object with the one in Raw). -// If one of them is nil, the only the other is used. If both (Parsed and Raw) are nil, VeryRaw is used instead. +// If one of them is nil, then only the other is used. If both (Parsed and Raw) are nil, VeryRaw is used instead. type Content struct { VeryRaw json.RawMessage Raw map[string]interface{} From ec15b79493651cbc45c5a7b5e98fe7e0bcd4cad4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 24 May 2025 16:18:39 +0300 Subject: [PATCH 090/581] commands: add event id to logger --- commands/processor.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/commands/processor.go b/commands/processor.go index da802fd9..24670d2f 100644 --- a/commands/processor.go +++ b/commands/processor.go @@ -43,7 +43,11 @@ func NewProcessor[MetaType any](cli *mautrix.Client) *Processor[MetaType] { } func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) { - log := *zerolog.Ctx(ctx) + log := zerolog.Ctx(ctx).With(). + Stringer("sender", evt.Sender). + Stringer("room_id", evt.RoomID). + Stringer("event_id", evt.ID). + Logger() defer func() { panicErr := recover() if panicErr != nil { @@ -98,9 +102,7 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) logWith := log.With(). Str("command", parsed.Command). - Array("handler", handlerChain). - Stringer("sender", evt.Sender). - Stringer("room_id", evt.RoomID) + Array("handler", handlerChain) if len(parsed.ParentCommands) > 0 { logWith = logWith.Strs("parent_commands", parsed.ParentCommands) } From da9e72e61680d6837170bd04cb783f36296e2060 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 24 May 2025 16:21:46 +0300 Subject: [PATCH 091/581] commands: add separate field for logger in event --- commands/event.go | 2 ++ commands/processor.go | 1 + 2 files changed, 3 insertions(+) diff --git a/commands/event.go b/commands/event.go index 8d51eadd..96d5e921 100644 --- a/commands/event.go +++ b/commands/event.go @@ -36,6 +36,7 @@ type Event[MetaType any] struct { RawArgs string Ctx context.Context + Log *zerolog.Logger Proc *Processor[MetaType] Handler *Handler[MetaType] Meta MetaType @@ -77,6 +78,7 @@ func ParseEvent[MetaType any](ctx context.Context, evt *event.Event) *Event[Meta Command: strings.ToLower(parts[0]), Args: parts[1:], RawArgs: strings.TrimLeft(strings.TrimPrefix(text, parts[0]), " "), + Log: zerolog.Ctx(ctx), Ctx: ctx, } } diff --git a/commands/processor.go b/commands/processor.go index 24670d2f..c4940526 100644 --- a/commands/processor.go +++ b/commands/processor.go @@ -111,6 +111,7 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) } log = logWith.Logger() parsed.Ctx = log.WithContext(ctx) + parsed.Log = &log log.Debug().Msg("Processing command") handler.Func(parsed) From 89fad2f462145ea13fdd05c062de9b5bd3fc193d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 24 May 2025 16:29:40 +0300 Subject: [PATCH 092/581] commands: add reaction button system --- commands/event.go | 23 +++++++- commands/processor.go | 10 +++- commands/reactions.go | 125 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 commands/reactions.go diff --git a/commands/event.go b/commands/event.go index 96d5e921..65ddd3da 100644 --- a/commands/event.go +++ b/commands/event.go @@ -40,6 +40,8 @@ type Event[MetaType any] struct { Proc *Processor[MetaType] Handler *Handler[MetaType] Meta MetaType + + redactedBy id.EventID } var IDHTMLParser = &format.HTMLParser{ @@ -71,7 +73,14 @@ func ParseEvent[MetaType any](ctx context.Context, evt *event.Event) *Event[Meta if len(text) == 0 { return nil } + return RawTextToEvent[MetaType](ctx, evt, text) +} + +func RawTextToEvent[MetaType any](ctx context.Context, evt *event.Event, text string) *Event[MetaType] { parts := strings.Fields(text) + if len(parts) == 0 { + parts = []string{""} + } return &Event[MetaType]{ Event: evt, RawInput: text, @@ -91,6 +100,7 @@ type ReplyOpts struct { SendAsText bool Edit id.EventID OverrideMentions *event.Mentions + Extra map[string]any } func (evt *Event[MetaType]) Reply(msg string, args ...any) id.EventID { @@ -117,7 +127,14 @@ func (evt *Event[MetaType]) Respond(msg string, opts ReplyOpts) id.EventID { if opts.OverrideMentions != nil { content.Mentions = opts.OverrideMentions } - resp, err := evt.Proc.Client.SendMessageEvent(evt.Ctx, evt.RoomID, event.EventMessage, content) + var wrapped any = &content + if opts.Extra != nil { + wrapped = &event.Content{ + Parsed: &content, + Raw: opts.Extra, + } + } + resp, err := evt.Proc.Client.SendMessageEvent(evt.Ctx, evt.RoomID, event.EventMessage, wrapped) if err != nil { zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reply") return "" @@ -135,11 +152,15 @@ func (evt *Event[MetaType]) React(emoji string) id.EventID { } func (evt *Event[MetaType]) Redact() id.EventID { + if evt.redactedBy != "" { + return evt.redactedBy + } resp, err := evt.Proc.Client.RedactEvent(evt.Ctx, evt.RoomID, evt.ID) if err != nil { zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to redact command") return "" } + evt.redactedBy = resp.EventID return resp.EventID } diff --git a/commands/processor.go b/commands/processor.go index c4940526..9341329b 100644 --- a/commands/processor.go +++ b/commands/processor.go @@ -26,6 +26,8 @@ type Processor[MetaType any] struct { LogArgs bool PreValidator PreValidator[MetaType] Meta MetaType + + ReactionCommandPrefix string } // UnknownCommandName is the name of the fallback handler which is used if no other handler is found. @@ -65,7 +67,13 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) } } }() - parsed := ParseEvent[MetaType](ctx, evt) + var parsed *Event[MetaType] + switch evt.Type { + case event.EventReaction: + parsed = proc.ParseReaction(ctx, evt) + case event.EventMessage: + parsed = ParseEvent[MetaType](ctx, evt) + } if parsed == nil || !proc.PreValidator.Validate(parsed) { return } diff --git a/commands/reactions.go b/commands/reactions.go new file mode 100644 index 00000000..0df372e5 --- /dev/null +++ b/commands/reactions.go @@ -0,0 +1,125 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "context" + "strings" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" +) + +const ReactionCommandsKey = "fi.mau.reaction_commands" +const ReactionMultiUseKey = "fi.mau.reaction_multi_use" + +func (proc *Processor[MetaType]) ParseReaction(ctx context.Context, evt *event.Event) *Event[MetaType] { + content, ok := evt.Content.Parsed.(*event.ReactionEventContent) + if !ok { + return nil + } + evtID := content.RelatesTo.EventID + if evtID == "" || !strings.HasPrefix(content.RelatesTo.Key, proc.ReactionCommandPrefix) { + return nil + } + targetEvt, err := proc.Client.GetEvent(ctx, evt.RoomID, evtID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("target_event_id", evtID).Msg("Failed to get target event for reaction") + return nil + } else if targetEvt.Sender != proc.Client.UserID || targetEvt.Unsigned.RedactedBecause != nil { + return nil + } + if targetEvt.Type == event.EventEncrypted { + if proc.Client.Crypto == nil { + zerolog.Ctx(ctx).Warn(). + Stringer("target_event_id", evtID). + Msg("Received reaction to encrypted event, but don't have crypto helper in client") + return nil + } + _ = targetEvt.Content.ParseRaw(targetEvt.Type) + targetEvt, err = proc.Client.Crypto.Decrypt(ctx, targetEvt) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Stringer("target_event_id", evtID). + Msg("Failed to decrypt target event for reaction") + return nil + } + } + reactionCommands, ok := targetEvt.Content.Raw[ReactionCommandsKey].(map[string]any) + if !ok { + zerolog.Ctx(ctx).Trace(). + Stringer("target_event_id", evtID). + Msg("Reaction target event doesn't have commands key") + return nil + } + isMultiUse, _ := targetEvt.Content.Raw[ReactionMultiUseKey].(bool) + rawCmd, ok := reactionCommands[content.RelatesTo.Key] + if !ok { + zerolog.Ctx(ctx).Debug(). + Stringer("target_event_id", evtID). + Str("reaction_key", content.RelatesTo.Key). + Msg("Reaction command not found in target event") + return nil + } + cmdString, ok := rawCmd.(string) + if !ok { + zerolog.Ctx(ctx).Debug(). + Stringer("target_event_id", evtID). + Str("reaction_key", content.RelatesTo.Key). + Msg("Reaction command data is invalid") + return nil + } + wrappedEvt := RawTextToEvent[MetaType](ctx, evt, cmdString) + wrappedEvt.Proc = proc + wrappedEvt.Redact() + if !isMultiUse { + DeleteAllReactions(ctx, proc.Client, evt) + } + if cmdString == "" { + return nil + } + return wrappedEvt +} + +func DeleteAllReactionsCommandFunc[MetaType any](ce *Event[MetaType]) { + DeleteAllReactions(ce.Ctx, ce.Proc.Client, ce.Event) +} + +func DeleteAllReactions(ctx context.Context, client *mautrix.Client, evt *event.Event) { + rel, ok := evt.Content.Parsed.(event.Relatable) + if !ok { + return + } + relation := rel.OptionalGetRelatesTo() + if relation == nil { + return + } + targetEvt := relation.GetReplyTo() + if targetEvt == "" { + targetEvt = relation.GetAnnotationID() + } + if targetEvt == "" { + return + } + relations, err := client.GetRelations(ctx, evt.RoomID, targetEvt, &mautrix.ReqGetRelations{ + RelationType: event.RelAnnotation, + EventType: event.EventReaction, + Limit: 20, + }) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get reactions to delete") + return + } + for _, relEvt := range relations.Chunk { + _, err = client.RedactEvent(ctx, relEvt.RoomID, relEvt.ID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("event_id", relEvt.ID).Msg("Failed to redact reaction event") + } + } +} From 306b48bd6814f1744b234f0b38a600ca4ba3271d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 26 May 2025 19:34:51 +0300 Subject: [PATCH 093/581] bridgev2/ghost: ensure GetGhostByID can't return nil --- bridgev2/ghost.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bridgev2/ghost.go b/bridgev2/ghost.go index e4e007cd..087e0b64 100644 --- a/bridgev2/ghost.go +++ b/bridgev2/ghost.go @@ -85,7 +85,13 @@ func (br *Bridge) GetGhostByMXID(ctx context.Context, mxid id.UserID) (*Ghost, e func (br *Bridge) GetGhostByID(ctx context.Context, id networkid.UserID) (*Ghost, error) { br.cacheLock.Lock() defer br.cacheLock.Unlock() - return br.unlockedGetGhostByID(ctx, id, false) + ghost, err := br.unlockedGetGhostByID(ctx, id, false) + if err != nil { + return nil, err + } else if ghost == nil { + panic(fmt.Errorf("unlockedGetGhostByID(ctx, %q, false) returned nil", id)) + } + return ghost, nil } func (br *Bridge) GetExistingGhostByID(ctx context.Context, id networkid.UserID) (*Ghost, error) { From c5ef0f9d90addf7ba1b5658ff18cd331987fa21a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 26 May 2025 20:24:33 +0300 Subject: [PATCH 094/581] bridgev2/userlogin: ensure Client is filled in NewLogin --- bridgev2/userlogin.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bridgev2/userlogin.go b/bridgev2/userlogin.go index bf8f3bc6..9be3da3f 100644 --- a/bridgev2/userlogin.go +++ b/bridgev2/userlogin.go @@ -230,6 +230,9 @@ func (user *User) NewLogin(ctx context.Context, data *database.UserLogin, params err = params.LoadUserLogin(ul.Log.WithContext(context.Background()), ul) if err != nil { return nil, err + } else if ul.Client == nil { + ul.Log.Error().Msg("LoadUserLogin didn't fill Client in NewLogin") + return nil, fmt.Errorf("client not filled by LoadUserLogin") } if doInsert { err = user.Bridge.DB.UserLogin.Insert(ctx, ul.UserLogin) From 6ed660557b57d36ead3d240e34615a7c574d7495 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 26 May 2025 21:14:37 +0300 Subject: [PATCH 095/581] federation/signingkey: store raw response for validation --- federation/serverauth_test.go | 29 ++++++++++++++++++++++++ federation/signingkey.go | 42 +++++------------------------------ 2 files changed, 34 insertions(+), 37 deletions(-) create mode 100644 federation/serverauth_test.go diff --git a/federation/serverauth_test.go b/federation/serverauth_test.go new file mode 100644 index 00000000..d79dce36 --- /dev/null +++ b/federation/serverauth_test.go @@ -0,0 +1,29 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/federation" +) + +func TestServerKeyResponse_VerifySelfSignature(t *testing.T) { + cli := federation.NewClient("", nil, nil) + ctx := context.Background() + for _, name := range []string{"matrix.org", "maunium.net", "continuwuity.org"} { + t.Run(name, func(t *testing.T) { + resp, err := cli.ServerKeys(ctx, "matrix.org") + require.NoError(t, err) + assert.True(t, resp.VerifySelfSignature()) + }) + } +} diff --git a/federation/signingkey.go b/federation/signingkey.go index 87c12a5e..a8362247 100644 --- a/federation/signingkey.go +++ b/federation/signingkey.go @@ -11,7 +11,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "maps" "strings" "time" @@ -82,7 +81,7 @@ type ServerKeyResponse struct { Signatures map[string]map[id.KeyID]string `json:"signatures,omitempty"` ValidUntilTS jsontime.UnixMilli `json:"valid_until_ts"` - Extra map[string]any `json:"-"` + Raw json.RawMessage `json:"-"` } func (skr *ServerKeyResponse) HasKey(keyID id.KeyID) bool { @@ -96,7 +95,7 @@ func (skr *ServerKeyResponse) HasKey(keyID id.KeyID) bool { func (skr *ServerKeyResponse) VerifySelfSignature() bool { for keyID, key := range skr.VerifyKeys { - if !VerifyJSON(skr.ServerName, keyID, key.Key, skr) { + if !VerifyJSON(skr.ServerName, keyID, key.Key, skr.Raw) { return false } } @@ -128,7 +127,7 @@ func VerifyJSON(serverName string, keyID id.KeyID, key id.SigningKey, data any) } func VerifyJSONRaw(key id.SigningKey, sig string, message json.RawMessage) bool { - sigBytes, err := base64.RawURLEncoding.DecodeString(sig) + sigBytes, err := base64.RawStdEncoding.DecodeString(sig) if err != nil { return false } @@ -142,40 +141,9 @@ func VerifyJSONRaw(key id.SigningKey, sig string, message json.RawMessage) bool type marshalableSKR ServerKeyResponse -func (skr *ServerKeyResponse) MarshalJSON() ([]byte, error) { - if skr.Extra == nil { - return json.Marshal((*marshalableSKR)(skr)) - } - marshalable := maps.Clone(skr.Extra) - marshalable["server_name"] = skr.ServerName - marshalable["verify_keys"] = skr.VerifyKeys - marshalable["old_verify_keys"] = skr.OldVerifyKeys - marshalable["signatures"] = skr.Signatures - marshalable["valid_until_ts"] = skr.ValidUntilTS - return json.Marshal(skr.Extra) -} - func (skr *ServerKeyResponse) UnmarshalJSON(data []byte) error { - err := json.Unmarshal(data, (*marshalableSKR)(skr)) - if err != nil { - return err - } - var extra map[string]any - err = json.Unmarshal(data, &extra) - if err != nil { - return err - } - delete(extra, "server_name") - delete(extra, "verify_keys") - delete(extra, "old_verify_keys") - delete(extra, "signatures") - delete(extra, "valid_until_ts") - if len(extra) > 0 { - skr.Extra = extra - } else { - skr.Extra = nil - } - return nil + skr.Raw = data + return json.Unmarshal(data, (*marshalableSKR)(skr)) } type ServerVerifyKey struct { From 92311e5c9852b2459c5489854dc4fb9a54dd19d6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 26 May 2025 21:14:47 +0300 Subject: [PATCH 096/581] federation/client: fix QueryKeys return format --- federation/client.go | 2 +- federation/signingkey.go | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/federation/client.go b/federation/client.go index 93ed759c..4f26f4b4 100644 --- a/federation/client.go +++ b/federation/client.go @@ -52,7 +52,7 @@ func (c *Client) ServerKeys(ctx context.Context, serverName string) (resp *Serve return } -func (c *Client) QueryKeys(ctx context.Context, serverName string, req *ReqQueryKeys) (resp *ServerKeyResponse, err error) { +func (c *Client) QueryKeys(ctx context.Context, serverName string, req *ReqQueryKeys) (resp *QueryKeysResponse, err error) { err = c.MakeRequest(ctx, serverName, false, http.MethodPost, KeyURLPath{"v2", "query"}, req, &resp) return } diff --git a/federation/signingkey.go b/federation/signingkey.go index a8362247..c13e5f35 100644 --- a/federation/signingkey.go +++ b/federation/signingkey.go @@ -84,6 +84,10 @@ type ServerKeyResponse struct { Raw json.RawMessage `json:"-"` } +type QueryKeysResponse struct { + ServerKeys []*ServerKeyResponse `json:"server_keys"` +} + func (skr *ServerKeyResponse) HasKey(keyID id.KeyID) bool { if skr == nil { return false From a3d5da315fb828e76c01e6e9dca7062d54220cb7 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 26 May 2025 21:58:35 +0300 Subject: [PATCH 097/581] federation: use errors in signature verification --- federation/client.go | 5 +++-- federation/serverauth.go | 11 +++++++---- federation/serverauth_test.go | 2 +- federation/signingkey.go | 33 ++++++++++++++++++++------------- 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/federation/client.go b/federation/client.go index 4f26f4b4..7c460d44 100644 --- a/federation/client.go +++ b/federation/client.go @@ -10,6 +10,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net/http" "net/url" @@ -398,10 +399,10 @@ type signableRequest struct { Content json.RawMessage `json:"content,omitempty"` } -func (r *signableRequest) Verify(key id.SigningKey, sig string) bool { +func (r *signableRequest) Verify(key id.SigningKey, sig string) error { message, err := json.Marshal(r) if err != nil { - return false + return fmt.Errorf("failed to marshal data: %w", err) } return VerifyJSONRaw(key, sig, message) } diff --git a/federation/serverauth.go b/federation/serverauth.go index 22ce8403..92860cc8 100644 --- a/federation/serverauth.go +++ b/federation/serverauth.go @@ -200,7 +200,10 @@ func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.Res Msg("Failed to query keys to authenticate request (cached error)") } return nil, &errFailedToQueryKeys - } else if !resp.VerifySelfSignature() { + } else if err := resp.VerifySelfSignature(); err != nil { + log.Trace().Err(err). + Str("server_name", parsed.Origin). + Msg("Failed to validate self-signatures of server keys") return nil, &errInvalidSelfSignatures } key, ok := resp.VerifyKeys[parsed.KeyID] @@ -226,15 +229,15 @@ func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.Res return nil, &errInvalidJSONBody } } - valid := (&signableRequest{ + err = (&signableRequest{ Method: r.Method, URI: r.URL.RawPath, Origin: parsed.Origin, Destination: destination, Content: reqBody, }).Verify(key.Key, parsed.Signature) - if !valid { - log.Trace().Msg("Request has invalid signature") + if err != nil { + log.Trace().Err(err).Msg("Request has invalid signature") return nil, &errInvalidRequestSignature } ctx := context.WithValue(r.Context(), contextKeyDestinationServer, destination) diff --git a/federation/serverauth_test.go b/federation/serverauth_test.go index d79dce36..9fa15459 100644 --- a/federation/serverauth_test.go +++ b/federation/serverauth_test.go @@ -23,7 +23,7 @@ func TestServerKeyResponse_VerifySelfSignature(t *testing.T) { t.Run(name, func(t *testing.T) { resp, err := cli.ServerKeys(ctx, "matrix.org") require.NoError(t, err) - assert.True(t, resp.VerifySelfSignature()) + assert.NoError(t, resp.VerifySelfSignature()) }) } } diff --git a/federation/signingkey.go b/federation/signingkey.go index c13e5f35..5b111947 100644 --- a/federation/signingkey.go +++ b/federation/signingkey.go @@ -10,6 +10,7 @@ import ( "crypto/ed25519" "encoding/base64" "encoding/json" + "errors" "fmt" "strings" "time" @@ -97,50 +98,56 @@ func (skr *ServerKeyResponse) HasKey(keyID id.KeyID) bool { return false } -func (skr *ServerKeyResponse) VerifySelfSignature() bool { +func (skr *ServerKeyResponse) VerifySelfSignature() error { for keyID, key := range skr.VerifyKeys { - if !VerifyJSON(skr.ServerName, keyID, key.Key, skr.Raw) { - return false + if err := VerifyJSON(skr.ServerName, keyID, key.Key, skr.Raw); err != nil { + return fmt.Errorf("failed to verify self signature for key %s: %w", keyID, err) } } - return true + return nil } -func VerifyJSON(serverName string, keyID id.KeyID, key id.SigningKey, data any) bool { +func VerifyJSON(serverName string, keyID id.KeyID, key id.SigningKey, data any) error { var err error message, ok := data.(json.RawMessage) if !ok { message, err = json.Marshal(data) if err != nil { - return false + return fmt.Errorf("failed to marshal data: %w", err) } } sigVal := gjson.GetBytes(message, exgjson.Path("signatures", serverName, string(keyID))) if sigVal.Type != gjson.String { - return false + return ErrSignatureNotFound } message, err = sjson.DeleteBytes(message, "signatures") if err != nil { - return false + return fmt.Errorf("failed to delete signatures: %w", err) } message, err = sjson.DeleteBytes(message, "unsigned") if err != nil { - return false + return fmt.Errorf("failed to delete unsigned: %w", err) } return VerifyJSONRaw(key, sigVal.Str, message) } -func VerifyJSONRaw(key id.SigningKey, sig string, message json.RawMessage) bool { +var ErrSignatureNotFound = errors.New("signature not found") +var ErrInvalidSignature = errors.New("invalid signature") + +func VerifyJSONRaw(key id.SigningKey, sig string, message json.RawMessage) error { sigBytes, err := base64.RawStdEncoding.DecodeString(sig) if err != nil { - return false + return fmt.Errorf("failed to decode signature: %w", err) } keyBytes, err := base64.RawStdEncoding.DecodeString(string(key)) if err != nil { - return false + return fmt.Errorf("failed to decode key: %w", err) } message = canonicaljson.CanonicalJSONAssumeValid(message) - return ed25519.Verify(keyBytes, message, sigBytes) + if !ed25519.Verify(keyBytes, message, sigBytes) { + return ErrInvalidSignature + } + return nil } type marshalableSKR ServerKeyResponse From c7fbfd150f9b2761dcc72eac09bd7f31c9fe51d1 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Mon, 26 May 2025 20:37:28 +0100 Subject: [PATCH 098/581] federation/serverauth: fix URI passed to signableRequest (#381) --- federation/serverauth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/federation/serverauth.go b/federation/serverauth.go index 92860cc8..f46c7991 100644 --- a/federation/serverauth.go +++ b/federation/serverauth.go @@ -231,7 +231,7 @@ func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.Res } err = (&signableRequest{ Method: r.Method, - URI: r.URL.RawPath, + URI: r.URL.EscapedPath(), Origin: parsed.Origin, Destination: destination, Content: reqBody, From e7322f04b80ed68bb5f57657538b06fd91413c61 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 27 May 2025 09:14:12 +0300 Subject: [PATCH 099/581] bridgev2: fix handling some cases of context cancellation --- bridgev2/disappear.go | 2 +- bridgev2/portal.go | 12 +++++++++++- bridgev2/userlogin.go | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/bridgev2/disappear.go b/bridgev2/disappear.go index d7b2182b..0eea8bc3 100644 --- a/bridgev2/disappear.go +++ b/bridgev2/disappear.go @@ -85,7 +85,7 @@ func (dl *DisappearLoop) Add(ctx context.Context, dm *database.DisappearingMessa Msg("Failed to save disappearing message") } if !dm.DisappearAt.IsZero() && dm.DisappearAt.Before(dl.NextCheck) { - go dl.sleepAndDisappear(context.WithoutCancel(ctx), dm) + go dl.sleepAndDisappear(zerolog.Ctx(ctx).WithContext(dl.br.BackgroundCtx), dm) } } diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 63081f57..d769e9f1 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -3226,6 +3226,10 @@ func (portal *Portal) getInitialMemberList(ctx context.Context, members *ChatMem members.PowerLevels.Apply("", pl) members.memberListToMap(ctx) for _, member := range members.MemberMap { + if ctx.Err() != nil { + err = ctx.Err() + return + } if member.Membership != event.MembershipJoin && member.Membership != "" { continue } @@ -3403,6 +3407,9 @@ func (portal *Portal) syncParticipants(ctx context.Context, members *ChatMemberL } } for _, member := range members.MemberMap { + if ctx.Err() != nil { + return ctx.Err() + } if member.Sender != "" && member.UserInfo != nil { ghost, err := portal.Bridge.GetGhostByID(ctx, member.Sender) if err != nil { @@ -3742,6 +3749,9 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo } portal.UpdateInfo(ctx, info, source, nil, time.Time{}) + if ctx.Err() != nil { + return ctx.Err() + } powerLevels := &event.PowerLevelsEventContent{ Events: map[string]int{ @@ -3866,7 +3876,7 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo } portal.Bridge.WakeupBackfillQueue() } - withoutCancelCtx := context.WithoutCancel(ctx) + withoutCancelCtx := zerolog.Ctx(ctx).WithContext(portal.Bridge.BackgroundCtx) if portal.Parent != nil { if portal.Parent.MXID != "" { portal.addToParentSpaceAndSave(ctx, true) diff --git a/bridgev2/userlogin.go b/bridgev2/userlogin.go index 9be3da3f..396cf899 100644 --- a/bridgev2/userlogin.go +++ b/bridgev2/userlogin.go @@ -301,7 +301,7 @@ func (ul *UserLogin) Delete(ctx context.Context, state status.BridgeState, opts if !opts.unlocked { ul.Bridge.cacheLock.Unlock() } - backgroundCtx := context.WithoutCancel(ctx) + backgroundCtx := zerolog.Ctx(ctx).WithContext(ul.Bridge.BackgroundCtx) if !opts.BlockingCleanup { go ul.deleteSpace(backgroundCtx) } else { From 34afb98ef05d0e9414221f784834a67a39850484 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 20 May 2025 16:57:49 +0530 Subject: [PATCH 100/581] event: fix parsing some url preview responses --- event/beeper.go | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/event/beeper.go b/event/beeper.go index 19c6253e..891204e5 100644 --- a/event/beeper.go +++ b/event/beeper.go @@ -9,7 +9,9 @@ package event import ( "encoding/base32" "encoding/binary" + "encoding/json" "fmt" + "strconv" "maunium.net/go/mautrix/id" ) @@ -81,6 +83,25 @@ type BeeperRoomKeyAckEventContent struct { FirstMessageIndex int `json:"first_message_index"` } +type IntOrString int + +func (ios *IntOrString) UnmarshalJSON(data []byte) error { + if len(data) > 0 && data[0] == '"' { + var str string + err := json.Unmarshal(data, &str) + if err != nil { + return err + } + intVal, err := strconv.Atoi(str) + if err != nil { + return err + } + *ios = IntOrString(intVal) + return nil + } + return json.Unmarshal(data, (*int)(ios)) +} + type LinkPreview struct { CanonicalURL string `json:"og:url,omitempty"` Title string `json:"og:title,omitempty"` @@ -90,10 +111,10 @@ type LinkPreview struct { ImageURL id.ContentURIString `json:"og:image,omitempty"` - ImageSize int `json:"matrix:image:size,omitempty"` - ImageWidth int `json:"og:image:width,omitempty"` - ImageHeight int `json:"og:image:height,omitempty"` - ImageType string `json:"og:image:type,omitempty"` + ImageSize IntOrString `json:"matrix:image:size,omitempty"` + ImageWidth IntOrString `json:"og:image:width,omitempty"` + ImageHeight IntOrString `json:"og:image:height,omitempty"` + ImageType string `json:"og:image:type,omitempty"` } // BeeperLinkPreview contains the data for a bundled URL preview as specified in MSC4095 From 140b20cab90a8b8d085ecba7535ee5fe3eb6ed6d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 20 May 2025 16:58:36 +0530 Subject: [PATCH 101/581] id: add utilities for validating server names --- id/servername.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 id/servername.go diff --git a/id/servername.go b/id/servername.go new file mode 100644 index 00000000..591f394a --- /dev/null +++ b/id/servername.go @@ -0,0 +1,58 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package id + +import ( + "regexp" + "strconv" +) + +type ParsedServerNameType int + +const ( + ServerNameDNS ParsedServerNameType = iota + ServerNameIPv4 + ServerNameIPv6 +) + +type ParsedServerName struct { + Type ParsedServerNameType + Host string + Port int +} + +var ServerNameRegex = regexp.MustCompile(`^(?:\[([0-9A-Fa-f:.]{2,45})]|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|([0-9A-Za-z.-]{1,255}))(\d{1,5})?$`) + +func ValidateServerName(serverName string) bool { + return len(serverName) <= 255 && len(serverName) > 0 && ServerNameRegex.MatchString(serverName) +} + +func ParseServerName(serverName string) *ParsedServerName { + if len(serverName) > 255 || len(serverName) < 1 { + return nil + } + match := ServerNameRegex.FindStringSubmatch(serverName) + if len(match) != 5 { + return nil + } + port, _ := strconv.Atoi(match[4]) + parsed := &ParsedServerName{ + Port: port, + } + switch { + case match[1] != "": + parsed.Type = ServerNameIPv6 + parsed.Host = match[1] + case match[2] != "": + parsed.Type = ServerNameIPv4 + parsed.Host = match[2] + case match[3] != "": + parsed.Type = ServerNameDNS + parsed.Host = match[3] + } + return parsed +} From cdb99239d36c5227566d1396ca821a940820694d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 27 May 2025 14:05:40 +0530 Subject: [PATCH 102/581] bridgev2: add interfaces for reading up to stream order (#379) --- bridgev2/matrixinterface.go | 4 ++++ bridgev2/networkinterface.go | 5 +++++ bridgev2/portal.go | 30 ++++++++++++++++++++++-------- bridgev2/simplevent/receipt.go | 6 ++++++ 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index 4ccba353..ae1b99d7 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -171,6 +171,10 @@ type MatrixAPI interface { MuteRoom(ctx context.Context, roomID id.RoomID, until time.Time) error } +type StreamOrderReadingMatrixAPI interface { + MarkStreamOrderRead(ctx context.Context, roomID id.RoomID, streamOrder int64, ts time.Time) error +} + type MarkAsDMMatrixAPI interface { MarkAsDM(ctx context.Context, roomID id.RoomID, otherUser id.UserID) error } diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 76db8cc8..14d502e3 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -1124,6 +1124,11 @@ type RemoteReadReceipt interface { GetReadUpTo() time.Time } +type RemoteReadReceiptWithStreamOrder interface { + RemoteReadReceipt + GetReadUpToStreamOrder() int64 +} + type RemoteDeliveryReceipt interface { RemoteEvent GetReceiptTargets() []networkid.MessageID diff --git a/bridgev2/portal.go b/bridgev2/portal.go index d769e9f1..1b12c0da 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -2693,17 +2693,31 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL log.Err(err).Time("read_up_to", readUpTo).Msg("Failed to get target message for read receipt") } } - if lastTarget == nil { - log.Warn().Msg("No target message found for read receipt") - return - } sender := evt.GetSender() intent := portal.GetIntentFor(ctx, sender, source, RemoteEventReadReceipt) - err = intent.MarkRead(ctx, portal.MXID, lastTarget.MXID, getEventTS(evt)) - if err != nil { - log.Err(err).Stringer("target_mxid", lastTarget.MXID).Msg("Failed to bridge read receipt") + var addTargetLog func(evt *zerolog.Event) *zerolog.Event + if lastTarget == nil { + sevt, evtOK := evt.(RemoteReadReceiptWithStreamOrder) + soIntent, soIntentOK := intent.(StreamOrderReadingMatrixAPI) + if !evtOK || !soIntentOK || sevt.GetReadUpToStreamOrder() == 0 { + log.Warn().Msg("No target message found for read receipt") + return + } + targetStreamOrder := sevt.GetReadUpToStreamOrder() + addTargetLog = func(evt *zerolog.Event) *zerolog.Event { + return evt.Int64("target_stream_order", targetStreamOrder) + } + err = soIntent.MarkStreamOrderRead(ctx, portal.MXID, targetStreamOrder, getEventTS(evt)) } else { - log.Debug().Stringer("target_mxid", lastTarget.MXID).Msg("Bridged read receipt") + addTargetLog = func(evt *zerolog.Event) *zerolog.Event { + return evt.Stringer("target_mxid", lastTarget.MXID) + } + err = intent.MarkRead(ctx, portal.MXID, lastTarget.MXID, getEventTS(evt)) + } + if err != nil { + addTargetLog(log.Err(err)).Msg("Failed to bridge read receipt") + } else { + addTargetLog(log.Debug()).Msg("Bridged read receipt") } if sender.IsFromMe { portal.Bridge.DisappearLoop.StartAll(ctx, portal.MXID) diff --git a/bridgev2/simplevent/receipt.go b/bridgev2/simplevent/receipt.go index 3565986b..41614e40 100644 --- a/bridgev2/simplevent/receipt.go +++ b/bridgev2/simplevent/receipt.go @@ -19,6 +19,8 @@ type Receipt struct { LastTarget networkid.MessageID Targets []networkid.MessageID ReadUpTo time.Time + + ReadUpToStreamOrder int64 } var ( @@ -38,6 +40,10 @@ func (evt *Receipt) GetReadUpTo() time.Time { return evt.ReadUpTo } +func (evt *Receipt) GetReadUpToStreamOrder() int64 { + return evt.ReadUpToStreamOrder +} + type MarkUnread struct { EventMeta Unread bool From 8a745c0d03ec1f7318b8a962c7278544b537d417 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 27 May 2025 11:37:51 +0300 Subject: [PATCH 103/581] bridgev2/portal: allow always using deterministic ids for replies --- bridgev2/portal.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 1b12c0da..15412788 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1885,7 +1885,7 @@ func (portal *Portal) getRelationMeta(ctx context.Context, currentMsg networkid. if err != nil { log.Err(err).Msg("Failed to get reply target message from database") } else if replyTo == nil { - if isBatchSend { + if isBatchSend || portal.Bridge.Config.OutgoingMessageReID { // This is somewhat evil replyTo = &database.Message{ MXID: portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, replyToPtr.MessageID, ptr.Val(replyToPtr.PartID)), @@ -1900,7 +1900,7 @@ func (portal *Portal) getRelationMeta(ctx context.Context, currentMsg networkid. if err != nil { log.Err(err).Msg("Failed to get thread root message from database") } else if threadRoot == nil { - if isBatchSend { + if isBatchSend || portal.Bridge.Config.OutgoingMessageReID { threadRoot = &database.Message{ MXID: portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, *threadRootPtr, ""), } From 5c8ea2c2691ea15b8d4228ebcfd8462dcc8b2365 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 27 May 2025 15:54:46 +0300 Subject: [PATCH 104/581] synapseadmin: add wrapper for room delete status --- synapseadmin/roomapi.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/synapseadmin/roomapi.go b/synapseadmin/roomapi.go index fa391b73..a91f653f 100644 --- a/synapseadmin/roomapi.go +++ b/synapseadmin/roomapi.go @@ -147,6 +147,12 @@ func (cli *Client) DeleteRoom(ctx context.Context, roomID id.RoomID, req ReqDele return resp, err } +func (cli *Client) DeleteRoomStatus(ctx context.Context, deleteID string) (resp RespDeleteRoomStatus, err error) { + reqURL := cli.BuildAdminURL("v2", "rooms", "delete_status", deleteID) + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) + return +} + // DeleteRoomSync deletes a room from the server, optionally blocking it and/or purging all data from the database. // // This calls the synchronous version of the endpoint, which will block until the room is deleted. From 0589b8757b438a582abc17697668a2b395ef1f90 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 27 May 2025 15:57:07 +0300 Subject: [PATCH 105/581] synapseadmin: fix response structs again --- synapseadmin/roomapi.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/synapseadmin/roomapi.go b/synapseadmin/roomapi.go index a91f653f..a09ba174 100644 --- a/synapseadmin/roomapi.go +++ b/synapseadmin/roomapi.go @@ -127,14 +127,19 @@ type RespDeleteRoom struct { DeleteID string `json:"delete_id"` } -type RespDeleteRoomStatus struct { - Status string `json:"status,omitempty"` +type RespDeleteRoomResult struct { KickedUsers []id.UserID `json:"kicked_users,omitempty"` FailedToKickUsers []id.UserID `json:"failed_to_kick_users,omitempty"` LocalAliases []id.RoomAlias `json:"local_aliases,omitempty"` NewRoomID id.RoomID `json:"new_room_id,omitempty"` } +type RespDeleteRoomStatus struct { + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + ShutdownRoom RespDeleteRoomResult `json:"shutdown_room,omitempty"` +} + // DeleteRoom deletes a room from the server, optionally blocking it and/or purging all data from the database. // // This calls the async version of the endpoint, which will return immediately and delete the room in the background. @@ -158,7 +163,7 @@ func (cli *Client) DeleteRoomStatus(ctx context.Context, deleteID string) (resp // This calls the synchronous version of the endpoint, which will block until the room is deleted. // // https://element-hq.github.io/synapse/latest/admin_api/rooms.html#version-1-old-version -func (cli *Client) DeleteRoomSync(ctx context.Context, roomID id.RoomID, req ReqDeleteRoom) (resp RespDeleteRoomStatus, err error) { +func (cli *Client) DeleteRoomSync(ctx context.Context, roomID id.RoomID, req ReqDeleteRoom) (resp RespDeleteRoomResult, err error) { reqURL := cli.BuildAdminURL("v1", "rooms", roomID) httpClient := &http.Client{} _, err = cli.Client.MakeFullRequest(ctx, mautrix.FullRequest{ From 50cc3d4d470508f253e6aa9a31b6a3e462a328e7 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 27 May 2025 16:37:51 +0300 Subject: [PATCH 106/581] bridgev2/queue: fix context used for queueing remote events --- bridgev2/queue.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/queue.go b/bridgev2/queue.go index 2981bdce..3d329b22 100644 --- a/bridgev2/queue.go +++ b/bridgev2/queue.go @@ -156,7 +156,7 @@ func (ul *UserLogin) QueueRemoteEvent(evt RemoteEvent) { func (br *Bridge) QueueRemoteEvent(login *UserLogin, evt RemoteEvent) { log := login.Log - ctx := log.WithContext(context.TODO()) + ctx := log.WithContext(br.BackgroundCtx) maybeUncertain, ok := evt.(RemoteEventWithUncertainPortalReceiver) isUncertain := ok && maybeUncertain.PortalReceiverIsUncertain() key := evt.GetPortalKey() From a3092e5195fad2ef1cd71ecbda5c750ed023c9c8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 27 May 2025 16:45:39 +0300 Subject: [PATCH 107/581] bridgev2/portal: don't do initial backfill in background --- bridgev2/portal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 15412788..784b1590 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -3943,7 +3943,7 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo } } } - if portal.Bridge.Config.Backfill.Enabled && portal.RoomType != database.RoomTypeSpace { + if portal.Bridge.Config.Backfill.Enabled && portal.RoomType != database.RoomTypeSpace && !portal.Bridge.Background { portal.doForwardBackfill(ctx, source, nil, backfillBundle) } return nil From f5746ee0f68d7a98e615b02ba58ede4ae0103a42 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 27 May 2025 18:04:52 +0300 Subject: [PATCH 108/581] event: add omitempty for mod policy entity Only one of hash and entity should be set --- event/state.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/event/state.go b/event/state.go index 006ed2a5..028691e1 100644 --- a/event/state.go +++ b/event/state.go @@ -231,7 +231,7 @@ func (ph *PolicyHashes) DecodeSHA256() *[32]byte { // ModPolicyContent represents the content of a m.room.rule.user, m.room.rule.room, and m.room.rule.server state event. // https://spec.matrix.org/v1.2/client-server-api/#moderation-policy-lists type ModPolicyContent struct { - Entity string `json:"entity"` + Entity string `json:"entity,omitempty"` Reason string `json:"reason"` Recommendation PolicyRecommendation `json:"recommendation"` UnstableHashes *PolicyHashes `json:"org.matrix.msc4205.hashes,omitempty"` From d89130ba76e87f8cd7f818eb141f92d46adaacf5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 May 2025 21:12:48 +0300 Subject: [PATCH 109/581] bridgev2/provisioning: fix returning wait errors Closes #382 --- bridgev2/matrix/provisioning.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index d05b005e..83f56fa0 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -500,10 +500,7 @@ func (prov *ProvisioningAPI) PostLoginWait(w http.ResponseWriter, r *http.Reques nextStep, err := login.Process.(bridgev2.LoginProcessDisplayAndWait).Wait(r.Context()) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to wait") - jsonResponse(w, http.StatusInternalServerError, &mautrix.RespError{ - Err: "Failed to wait", - ErrCode: "M_UNKNOWN", - }) + RespondWithError(w, err, "Internal error waiting for login") return } login.NextStep = nextStep From 64f55ac3a7eb9fba7fc0ad74e2900135253fea52 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 May 2025 21:24:15 +0300 Subject: [PATCH 110/581] bridgev2/provisioning: use exhttp utilities for writing responses --- bridgev2/matrix/provisioning.go | 196 ++++++++------------------------ 1 file changed, 49 insertions(+), 147 deletions(-) diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 83f56fa0..2a84bdf2 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -21,6 +21,7 @@ import ( "github.com/rs/xid" "github.com/rs/zerolog" "github.com/rs/zerolog/hlog" + "go.mau.fi/util/exhttp" "go.mau.fi/util/exstrings" "go.mau.fi/util/jsontime" "go.mau.fi/util/requestlog" @@ -118,7 +119,7 @@ func (prov *ProvisioningAPI) Init() { prov.Router = prov.br.AS.Router.PathPrefix(prov.br.Config.Provisioning.Prefix).Subrouter() prov.Router.Use(hlog.NewHandler(prov.log)) prov.Router.Use(hlog.RequestIDHandler("request_id", "Request-Id")) - prov.Router.Use(corsMiddleware) + prov.Router.Use(exhttp.CORSMiddleware) prov.Router.Use(requestlog.AccessLogger(false)) prov.Router.Use(prov.AuthMiddleware) prov.Router.Path("/v3/whoami").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(prov.GetWhoami) @@ -152,25 +153,6 @@ func (prov *ProvisioningAPI) Init() { } } -func corsMiddleware(handler http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "X-Requested-With, Content-Type, Authorization") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusOK) - return - } - handler.ServeHTTP(w, r) - }) -} - -func jsonResponse(w http.ResponseWriter, status int, response any) { - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(response) -} - func (prov *ProvisioningAPI) checkMatrixAuth(ctx context.Context, userID id.UserID, token string) error { prov.matrixAuthCacheLock.Lock() defer prov.matrixAuthCacheLock.Unlock() @@ -216,15 +198,9 @@ func (prov *ProvisioningAPI) DebugAuthMiddleware(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if auth == "" { - jsonResponse(w, http.StatusUnauthorized, &mautrix.RespError{ - Err: "Missing auth token", - ErrCode: mautrix.MMissingToken.ErrCode, - }) + mautrix.MMissingToken.WithMessage("Missing auth token").Write(w) } else if !exstrings.ConstantTimeEqual(auth, prov.br.Config.Provisioning.SharedSecret) { - jsonResponse(w, http.StatusUnauthorized, &mautrix.RespError{ - Err: "Invalid auth token", - ErrCode: mautrix.MUnknownToken.ErrCode, - }) + mautrix.MUnknownToken.WithMessage("Invalid auth token").Write(w) } else { h.ServeHTTP(w, r) } @@ -238,10 +214,7 @@ func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { auth = prov.GetAuthFromRequest(r) } if auth == "" { - jsonResponse(w, http.StatusUnauthorized, &mautrix.RespError{ - Err: "Missing auth token", - ErrCode: mautrix.MMissingToken.ErrCode, - }) + mautrix.MMissingToken.WithMessage("Missing auth token").Write(w) return } userID := id.UserID(r.URL.Query().Get("user_id")) @@ -258,29 +231,20 @@ func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { if err != nil { zerolog.Ctx(r.Context()).Warn().Err(err). Msg("Provisioning API request contained invalid auth") - jsonResponse(w, http.StatusUnauthorized, &mautrix.RespError{ - Err: "Invalid auth token", - ErrCode: mautrix.MUnknownToken.ErrCode, - }) + mautrix.MUnknownToken.WithMessage("Invalid auth token").Write(w) return } } user, err := prov.br.Bridge.GetUserByMXID(r.Context(), userID) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to get user") - jsonResponse(w, http.StatusInternalServerError, &mautrix.RespError{ - Err: "Failed to get user", - ErrCode: "M_UNKNOWN", - }) + mautrix.MUnknown.WithMessage("Failed to get user").Write(w) return } // TODO handle user being nil? // TODO per-endpoint permissions? if !user.Permissions.Login { - jsonResponse(w, http.StatusForbidden, &mautrix.RespError{ - Err: "User does not have login permissions", - ErrCode: mautrix.MForbidden.ErrCode, - }) + mautrix.MForbidden.WithMessage("User does not have login permissions").Write(w) return } @@ -292,10 +256,7 @@ func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { prov.loginsLock.RUnlock() if !ok { zerolog.Ctx(r.Context()).Warn().Str("login_id", loginID).Msg("Login not found") - jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ - Err: "Login not found", - ErrCode: mautrix.MNotFound.ErrCode, - }) + mautrix.MNotFound.WithMessage("Login not found").Write(w) return } login.Lock.Lock() @@ -307,10 +268,7 @@ func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { Str("request_step_id", stepID). Str("expected_step_id", login.NextStep.StepID). Msg("Step ID does not match") - jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ - Err: "Step ID does not match", - ErrCode: mautrix.MBadState.ErrCode, - }) + mautrix.MBadState.WithMessage("Step ID does not match").Write(w) return } stepType := mux.Vars(r)["stepType"] @@ -319,10 +277,7 @@ func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { Str("request_step_type", stepType). Str("expected_step_type", string(login.NextStep.Type)). Msg("Step type does not match") - jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ - Err: "Step type does not match", - ErrCode: mautrix.MBadState.ErrCode, - }) + mautrix.MBadState.WithMessage("Step type does not match").Write(w) return } ctx = context.WithValue(ctx, provisioningLoginProcessKey, login) @@ -391,7 +346,7 @@ func (prov *ProvisioningAPI) GetWhoami(w http.ResponseWriter, r *http.Request) { SpaceRoom: login.SpaceRoom, } } - jsonResponse(w, http.StatusOK, resp) + exhttp.WriteJSONResponse(w, http.StatusOK, resp) } type RespLoginFlows struct { @@ -404,7 +359,7 @@ type RespSubmitLogin struct { } func (prov *ProvisioningAPI) GetLoginFlows(w http.ResponseWriter, r *http.Request) { - jsonResponse(w, http.StatusOK, &RespLoginFlows{ + exhttp.WriteJSONResponse(w, http.StatusOK, &RespLoginFlows{ Flows: prov.net.GetLoginFlows(), }) } @@ -445,7 +400,7 @@ func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Reque Override: overrideLogin, } prov.loginsLock.Unlock() - jsonResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: loginID, LoginStep: firstStep}) + exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: loginID, LoginStep: firstStep}) } func (prov *ProvisioningAPI) handleCompleteStep(ctx context.Context, login *ProvLogin, step *bridgev2.LoginStep) { @@ -467,10 +422,7 @@ func (prov *ProvisioningAPI) PostLoginSubmitInput(w http.ResponseWriter, r *http err := json.NewDecoder(r.Body).Decode(¶ms) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") - jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ - Err: "Failed to decode request body", - ErrCode: mautrix.MNotJSON.ErrCode, - }) + mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) return } login := r.Context().Value(provisioningLoginProcessKey).(*ProvLogin) @@ -492,7 +444,7 @@ func (prov *ProvisioningAPI) PostLoginSubmitInput(w http.ResponseWriter, r *http if nextStep.Type == bridgev2.LoginStepTypeComplete { prov.handleCompleteStep(r.Context(), login, nextStep) } - jsonResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: nextStep}) + exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: nextStep}) } func (prov *ProvisioningAPI) PostLoginWait(w http.ResponseWriter, r *http.Request) { @@ -507,7 +459,7 @@ func (prov *ProvisioningAPI) PostLoginWait(w http.ResponseWriter, r *http.Reques if nextStep.Type == bridgev2.LoginStepTypeComplete { prov.handleCompleteStep(r.Context(), login, nextStep) } - jsonResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: nextStep}) + exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: nextStep}) } func (prov *ProvisioningAPI) PostLogout(w http.ResponseWriter, r *http.Request) { @@ -524,15 +476,12 @@ func (prov *ProvisioningAPI) PostLogout(w http.ResponseWriter, r *http.Request) } else { userLogin := prov.br.Bridge.GetCachedUserLoginByID(userLoginID) if userLogin == nil || userLogin.UserMXID != user.MXID { - jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ - Err: "Login not found", - ErrCode: mautrix.MNotFound.ErrCode, - }) + mautrix.MNotFound.WithMessage("Login not found").Write(w) return } userLogin.Logout(r.Context()) } - jsonResponse(w, http.StatusOK, json.RawMessage("{}")) + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) } type RespGetLogins struct { @@ -541,7 +490,7 @@ type RespGetLogins struct { func (prov *ProvisioningAPI) GetLogins(w http.ResponseWriter, r *http.Request) { user := prov.GetUser(r) - jsonResponse(w, http.StatusOK, &RespGetLogins{LoginIDs: user.GetUserLoginIDs()}) + exhttp.WriteJSONResponse(w, http.StatusOK, &RespGetLogins{LoginIDs: user.GetUserLoginIDs()}) } func (prov *ProvisioningAPI) GetExplicitLoginForRequest(w http.ResponseWriter, r *http.Request) (*bridgev2.UserLogin, bool) { @@ -551,15 +500,18 @@ func (prov *ProvisioningAPI) GetExplicitLoginForRequest(w http.ResponseWriter, r } userLogin := prov.br.Bridge.GetCachedUserLoginByID(userLoginID) if userLogin == nil || userLogin.UserMXID != prov.GetUser(r).MXID { - jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ - Err: "Login not found", - ErrCode: mautrix.MNotFound.ErrCode, - }) + mautrix.MNotFound.WithMessage("Login not found").Write(w) return nil, true } return userLogin, false } +var ErrNotLoggedIn = mautrix.RespError{ + Err: "Not logged in", + ErrCode: "FI.MAU.NOT_LOGGED_IN", + StatusCode: http.StatusBadRequest, +} + func (prov *ProvisioningAPI) GetLoginForRequest(w http.ResponseWriter, r *http.Request) *bridgev2.UserLogin { userLogin, failed := prov.GetExplicitLoginForRequest(w, r) if userLogin != nil || failed { @@ -567,10 +519,7 @@ func (prov *ProvisioningAPI) GetLoginForRequest(w http.ResponseWriter, r *http.R } userLogin = prov.GetUser(r).GetDefaultLogin() if userLogin == nil { - jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ - Err: "Not logged in", - ErrCode: "FI.MAU.NOT_LOGGED_IN", - }) + ErrNotLoggedIn.Write(w) return nil } return userLogin @@ -585,11 +534,7 @@ func RespondWithError(w http.ResponseWriter, err error, message string) { if errors.As(err, &we) { we.Write(w) } else { - mautrix.RespError{ - Err: message, - ErrCode: "M_UNKNOWN", - StatusCode: http.StatusInternalServerError, - }.Write(w) + mautrix.MUnknown.WithMessage(message).Write(w) } } @@ -609,10 +554,7 @@ func (prov *ProvisioningAPI) doResolveIdentifier(w http.ResponseWriter, r *http. } api, ok := login.Client.(bridgev2.IdentifierResolvingNetworkAPI) if !ok { - jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{ - Err: "This bridge does not support resolving identifiers", - ErrCode: mautrix.MUnrecognized.ErrCode, - }) + mautrix.MUnrecognized.WithMessage("This bridge does not support resolving identifiers").Write(w) return } resp, err := api.ResolveIdentifier(r.Context(), mux.Vars(r)["identifier"], createChat) @@ -621,10 +563,7 @@ func (prov *ProvisioningAPI) doResolveIdentifier(w http.ResponseWriter, r *http. RespondWithError(w, err, "Internal error resolving identifier") return } else if resp == nil { - jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ - ErrCode: mautrix.MNotFound.ErrCode, - Err: "Identifier not found", - }) + mautrix.MNotFound.WithMessage("Identifier not found").Write(w) return } apiResp := &RespResolveIdentifier{ @@ -647,10 +586,7 @@ func (prov *ProvisioningAPI) doResolveIdentifier(w http.ResponseWriter, r *http. resp.Chat.Portal, err = prov.br.Bridge.GetPortalByKey(r.Context(), resp.Chat.PortalKey) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to get portal") - jsonResponse(w, http.StatusInternalServerError, &mautrix.RespError{ - Err: "Failed to get portal", - ErrCode: "M_UNKNOWN", - }) + mautrix.MUnknown.WithMessage("Failed to get portal").Write(w) return } } @@ -659,16 +595,13 @@ func (prov *ProvisioningAPI) doResolveIdentifier(w http.ResponseWriter, r *http. err = resp.Chat.Portal.CreateMatrixRoom(r.Context(), login, resp.Chat.PortalInfo) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to create portal room") - jsonResponse(w, http.StatusInternalServerError, &mautrix.RespError{ - Err: "Failed to create portal room", - ErrCode: "M_UNKNOWN", - }) + mautrix.MUnknown.WithMessage("Failed to create portal room").Write(w) return } } apiResp.DMRoomID = resp.Chat.Portal.MXID } - jsonResponse(w, status, apiResp) + exhttp.WriteJSONResponse(w, status, apiResp) } type RespGetContactList struct { @@ -723,10 +656,7 @@ func (prov *ProvisioningAPI) GetContactList(w http.ResponseWriter, r *http.Reque } api, ok := login.Client.(bridgev2.ContactListingNetworkAPI) if !ok { - jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{ - Err: "This bridge does not support listing contacts", - ErrCode: mautrix.MUnrecognized.ErrCode, - }) + mautrix.MUnrecognized.WithMessage("This bridge does not support listing contacts").Write(w) return } resp, err := api.GetContactList(r.Context()) @@ -735,7 +665,7 @@ func (prov *ProvisioningAPI) GetContactList(w http.ResponseWriter, r *http.Reque RespondWithError(w, err, "Internal error fetching contact list") return } - jsonResponse(w, http.StatusOK, &RespGetContactList{ + exhttp.WriteJSONResponse(w, http.StatusOK, &RespGetContactList{ Contacts: prov.processResolveIdentifiers(r.Context(), resp), }) } @@ -753,10 +683,7 @@ func (prov *ProvisioningAPI) PostSearchUsers(w http.ResponseWriter, r *http.Requ err := json.NewDecoder(r.Body).Decode(&req) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") - jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ - Err: "Failed to decode request body", - ErrCode: mautrix.MNotJSON.ErrCode, - }) + mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) return } login := prov.GetLoginForRequest(w, r) @@ -765,10 +692,7 @@ func (prov *ProvisioningAPI) PostSearchUsers(w http.ResponseWriter, r *http.Requ } api, ok := login.Client.(bridgev2.UserSearchingNetworkAPI) if !ok { - jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{ - Err: "This bridge does not support searching for users", - ErrCode: mautrix.MUnrecognized.ErrCode, - }) + mautrix.MUnrecognized.WithMessage("This bridge does not support searching for users").Write(w) return } resp, err := api.SearchUsers(r.Context(), req.Query) @@ -777,7 +701,7 @@ func (prov *ProvisioningAPI) PostSearchUsers(w http.ResponseWriter, r *http.Requ RespondWithError(w, err, "Internal error fetching contact list") return } - jsonResponse(w, http.StatusOK, &RespSearchUsers{ + exhttp.WriteJSONResponse(w, http.StatusOK, &RespSearchUsers{ Results: prov.processResolveIdentifiers(r.Context(), resp), }) } @@ -795,10 +719,7 @@ func (prov *ProvisioningAPI) PostCreateGroup(w http.ResponseWriter, r *http.Requ if login == nil { return } - jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{ - Err: "Creating groups is not yet implemented", - ErrCode: mautrix.MUnrecognized.ErrCode, - }) + mautrix.MUnrecognized.WithMessage("Creating groups is not yet implemented").Write(w) } type ReqExportCredentials struct { @@ -817,10 +738,7 @@ func (prov *ProvisioningAPI) PostInitSessionTransfer(w http.ResponseWriter, r *h err := json.NewDecoder(r.Body).Decode(&req) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") - jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ - Err: "Failed to decode request body", - ErrCode: mautrix.MNotJSON.ErrCode, - }) + mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) return } @@ -834,19 +752,13 @@ func (prov *ProvisioningAPI) PostInitSessionTransfer(w http.ResponseWriter, r *h } } if loginToExport == nil { - jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ - Err: "No matching user login found", - ErrCode: mautrix.MNotFound.ErrCode, - }) + mautrix.MNotFound.WithMessage("No matching user login found").Write(w) return } client, ok := loginToExport.Client.(bridgev2.CredentialExportingNetworkAPI) if !ok { - jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ - Err: "Client does not support credential exporting", - ErrCode: mautrix.MInvalidParam.ErrCode, - }) + mautrix.MUnrecognized.WithMessage("This bridge does not support exporting credentials").Write(w) return } @@ -858,10 +770,9 @@ func (prov *ProvisioningAPI) PostInitSessionTransfer(w http.ResponseWriter, r *h // Disconnect now so we don't use the same network session in two places at once client.Disconnect() - resp := RespExportCredentials{ + exhttp.WriteJSONResponse(w, http.StatusOK, &RespExportCredentials{ Credentials: client.ExportCredentials(r.Context()), - } - jsonResponse(w, http.StatusOK, resp) + }) } func (prov *ProvisioningAPI) PostFinishSessionTransfer(w http.ResponseWriter, r *http.Request) { @@ -872,10 +783,7 @@ func (prov *ProvisioningAPI) PostFinishSessionTransfer(w http.ResponseWriter, r err := json.NewDecoder(r.Body).Decode(&req) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") - jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ - Err: "Failed to decode request body", - ErrCode: mautrix.MNotJSON.ErrCode, - }) + mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) return } @@ -889,16 +797,10 @@ func (prov *ProvisioningAPI) PostFinishSessionTransfer(w http.ResponseWriter, r } } if loginToExport == nil { - jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ - Err: "No matching user login found", - ErrCode: mautrix.MNotFound.ErrCode, - }) + mautrix.MNotFound.WithMessage("No matching user login found").Write(w) return } else if _, ok := prov.sessionTransfers[loginToExport.ID]; !ok { - jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ - Err: "No matching credential export found", - ErrCode: mautrix.MNotJSON.ErrCode, - }) + mautrix.MBadState.WithMessage("No matching credential export found").Write(w) return } @@ -909,5 +811,5 @@ func (prov *ProvisioningAPI) PostFinishSessionTransfer(w http.ResponseWriter, r loginToExport.Client.LogoutRemote(r.Context()) delete(prov.sessionTransfers, req.RemoteID) - jsonResponse(w, http.StatusOK, struct{}{}) + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) } From 53d027c06ffb1cc85df7e66555b4a2b8b2e9ec2b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 May 2025 21:34:46 +0300 Subject: [PATCH 111/581] appservice: replace custom response utilities with RespError and exhttp --- appservice/http.go | 73 +++++++++++------------------------------ appservice/protocol.go | 51 +--------------------------- appservice/websocket.go | 8 +++-- 3 files changed, 25 insertions(+), 107 deletions(-) diff --git a/appservice/http.go b/appservice/http.go index 66c7bc5b..1ebe6e56 100644 --- a/appservice/http.go +++ b/appservice/http.go @@ -1,4 +1,4 @@ -// Copyright (c) 2023 Tulir Asokan +// Copyright (c) 2025 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -19,6 +19,7 @@ import ( "github.com/gorilla/mux" "github.com/rs/zerolog" + "go.mau.fi/util/exhttp" "go.mau.fi/util/exstrings" "maunium.net/go/mautrix" @@ -79,17 +80,9 @@ func (as *AppService) Stop() { func (as *AppService) CheckServerToken(w http.ResponseWriter, r *http.Request) (isValid bool) { authHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authHeader, "Bearer ") { - Error{ - ErrorCode: ErrUnknownToken, - HTTPStatus: http.StatusForbidden, - Message: "Missing access token", - }.Write(w) + mautrix.MMissingToken.WithMessage("Missing access token").Write(w) } else if !exstrings.ConstantTimeEqual(authHeader[len("Bearer "):], as.Registration.ServerToken) { - Error{ - ErrorCode: ErrUnknownToken, - HTTPStatus: http.StatusForbidden, - Message: "Incorrect access token", - }.Write(w) + mautrix.MUnknownToken.WithMessage("Invalid access token").Write(w) } else { isValid = true } @@ -105,21 +98,13 @@ func (as *AppService) PutTransaction(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) txnID := vars["txnID"] if len(txnID) == 0 { - Error{ - ErrorCode: ErrNoTransactionID, - HTTPStatus: http.StatusBadRequest, - Message: "Missing transaction ID", - }.Write(w) + mautrix.MInvalidParam.WithMessage("Missing transaction ID").Write(w) return } defer r.Body.Close() body, err := io.ReadAll(r.Body) if err != nil || len(body) == 0 { - Error{ - ErrorCode: ErrNotJSON, - HTTPStatus: http.StatusBadRequest, - Message: "Missing request body", - }.Write(w) + mautrix.MNotJSON.WithMessage("Failed to read response body").Write(w) return } log := as.Log.With().Str("transaction_id", txnID).Logger() @@ -128,7 +113,7 @@ func (as *AppService) PutTransaction(w http.ResponseWriter, r *http.Request) { ctx = log.WithContext(ctx) if as.txnIDC.IsProcessed(txnID) { // Duplicate transaction ID: no-op - WriteBlankOK(w) + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) log.Debug().Msg("Ignoring duplicate transaction") return } @@ -137,14 +122,10 @@ func (as *AppService) PutTransaction(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &txn) if err != nil { log.Error().Err(err).Msg("Failed to parse transaction content") - Error{ - ErrorCode: ErrBadJSON, - HTTPStatus: http.StatusBadRequest, - Message: "Failed to parse body JSON", - }.Write(w) + mautrix.MBadJSON.WithMessage("Failed to parse transaction content").Write(w) } else { as.handleTransaction(ctx, txnID, &txn) - WriteBlankOK(w) + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) } } @@ -263,12 +244,9 @@ func (as *AppService) GetRoom(w http.ResponseWriter, r *http.Request) { roomAlias := vars["roomAlias"] ok := as.QueryHandler.QueryAlias(roomAlias) if ok { - WriteBlankOK(w) + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) } else { - Error{ - ErrorCode: ErrUnknown, - HTTPStatus: http.StatusNotFound, - }.Write(w) + mautrix.MNotFound.WithMessage("Alias not found").Write(w) } } @@ -282,12 +260,9 @@ func (as *AppService) GetUser(w http.ResponseWriter, r *http.Request) { userID := id.UserID(vars["userID"]) ok := as.QueryHandler.QueryUser(userID) if ok { - WriteBlankOK(w) + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) } else { - Error{ - ErrorCode: ErrUnknown, - HTTPStatus: http.StatusNotFound, - }.Write(w) + mautrix.MNotFound.WithMessage("User not found").Write(w) } } @@ -297,11 +272,7 @@ func (as *AppService) PostPing(w http.ResponseWriter, r *http.Request) { } body, err := io.ReadAll(r.Body) if err != nil || len(body) == 0 || !json.Valid(body) { - Error{ - ErrorCode: ErrNotJSON, - HTTPStatus: http.StatusBadRequest, - Message: "Missing request body", - }.Write(w) + mautrix.MNotJSON.WithMessage("Invalid or missing request body").Write(w) return } @@ -309,27 +280,21 @@ func (as *AppService) PostPing(w http.ResponseWriter, r *http.Request) { _ = json.Unmarshal(body, &txn) as.Log.Debug().Str("txn_id", txn.TxnID).Msg("Received ping from homeserver") - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write([]byte("{}")) + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) } func (as *AppService) GetLive(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Content-Type", "application/json") if as.Live { - w.WriteHeader(http.StatusOK) + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) } else { - w.WriteHeader(http.StatusInternalServerError) + exhttp.WriteEmptyJSONResponse(w, http.StatusInternalServerError) } - w.Write([]byte("{}")) } func (as *AppService) GetReady(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Content-Type", "application/json") if as.Ready { - w.WriteHeader(http.StatusOK) + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) } else { - w.WriteHeader(http.StatusInternalServerError) + exhttp.WriteEmptyJSONResponse(w, http.StatusInternalServerError) } - w.Write([]byte("{}")) } diff --git a/appservice/protocol.go b/appservice/protocol.go index 7a9891ef..7c493bcb 100644 --- a/appservice/protocol.go +++ b/appservice/protocol.go @@ -1,4 +1,4 @@ -// Copyright (c) 2023 Tulir Asokan +// Copyright (c) 2025 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -7,9 +7,7 @@ package appservice import ( - "encoding/json" "fmt" - "net/http" "strings" "github.com/rs/zerolog" @@ -103,50 +101,3 @@ func (txn *Transaction) ContentString() string { // EventListener is a function that receives events. type EventListener func(evt *event.Event) - -// WriteBlankOK writes a blank OK message as a reply to a HTTP request. -func WriteBlankOK(w http.ResponseWriter) { - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("{}")) -} - -// Respond responds to a HTTP request with a JSON object. -func Respond(w http.ResponseWriter, data interface{}) error { - w.Header().Add("Content-Type", "application/json") - dataStr, err := json.Marshal(data) - if err != nil { - return err - } - _, err = w.Write(dataStr) - return err -} - -// Error represents a Matrix protocol error. -type Error struct { - HTTPStatus int `json:"-"` - ErrorCode ErrorCode `json:"errcode"` - Message string `json:"error"` -} - -func (err Error) Write(w http.ResponseWriter) { - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(err.HTTPStatus) - _ = Respond(w, &err) -} - -// ErrorCode is the machine-readable code in an Error. -type ErrorCode string - -// Native ErrorCodes -const ( - ErrUnknownToken ErrorCode = "M_UNKNOWN_TOKEN" - ErrBadJSON ErrorCode = "M_BAD_JSON" - ErrNotJSON ErrorCode = "M_NOT_JSON" - ErrUnknown ErrorCode = "M_UNKNOWN" -) - -// Custom ErrorCodes -const ( - ErrNoTransactionID ErrorCode = "NET.MAUNIUM.NO_TRANSACTION_ID" -) diff --git a/appservice/websocket.go b/appservice/websocket.go index 598d70d1..3d5bd232 100644 --- a/appservice/websocket.go +++ b/appservice/websocket.go @@ -1,4 +1,4 @@ -// Copyright (c) 2023 Tulir Asokan +// Copyright (c) 2025 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -23,6 +23,8 @@ import ( "github.com/rs/zerolog" "github.com/tidwall/gjson" "github.com/tidwall/sjson" + + "maunium.net/go/mautrix" ) type WebsocketRequest struct { @@ -371,12 +373,12 @@ func (as *AppService) StartWebsocket(baseURL string, onConnect func()) error { "X-Mautrix-Websocket-Version": []string{"3"}, }) if resp != nil && resp.StatusCode >= 400 { - var errResp Error + var errResp mautrix.RespError err = json.NewDecoder(resp.Body).Decode(&errResp) if err != nil { return fmt.Errorf("websocket request returned HTTP %d with non-JSON body", resp.StatusCode) } else { - return fmt.Errorf("websocket request returned %s (HTTP %d): %s", errResp.ErrorCode, resp.StatusCode, errResp.Message) + return fmt.Errorf("websocket request returned %s (HTTP %d): %s", errResp.ErrCode, resp.StatusCode, errResp.Err) } } else if err != nil { return fmt.Errorf("failed to open websocket: %w", err) From f73480446c6f5377fa0d419012fa4a3d24fab0f4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 May 2025 21:39:34 +0300 Subject: [PATCH 112/581] mediaproxy: remove deprecated custom ResponseError struct --- mediaproxy/mediaproxy.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/mediaproxy/mediaproxy.go b/mediaproxy/mediaproxy.go index d76439a1..1300a305 100644 --- a/mediaproxy/mediaproxy.go +++ b/mediaproxy/mediaproxy.go @@ -8,7 +8,6 @@ package mediaproxy import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -223,16 +222,6 @@ func (mp *MediaProxy) RegisterRoutes(router *mux.Router) { mp.KeyServer.Register(router) } -// Deprecated: use mautrix.RespError instead -type ResponseError struct { - Status int - Data any -} - -func (err *ResponseError) Error() string { - return fmt.Sprintf("HTTP %d: %v", err.Status, err.Data) -} - var ErrInvalidMediaIDSyntax = errors.New("invalid media ID syntax") func queryToMap(vals url.Values) map[string]string { @@ -247,17 +236,11 @@ func (mp *MediaProxy) getMedia(w http.ResponseWriter, r *http.Request) GetMediaR mediaID := mux.Vars(r)["mediaID"] resp, err := mp.GetMedia(r.Context(), mediaID, queryToMap(r.URL.Query())) if err != nil { - //lint:ignore SA1019 deprecated types need to be supported until they're removed - var respError *ResponseError var mautrixRespError mautrix.RespError if errors.Is(err, ErrInvalidMediaIDSyntax) { mautrix.MNotFound.WithMessage("This is a media proxy at %q, other media downloads are not available here", mp.serverName).Write(w) } else if errors.As(err, &mautrixRespError) { mautrixRespError.Write(w) - } else if errors.As(err, &respError) { - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(respError.Status) - _ = json.NewEncoder(w).Encode(respError.Data) } else { zerolog.Ctx(r.Context()).Err(err).Str("media_id", mediaID).Msg("Failed to get media URL") mautrix.MNotFound.WithMessage("Media not found").Write(w) From 842f21b24f9fe25f09b0484bdee85c4ac5ffe411 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 May 2025 22:25:17 +0300 Subject: [PATCH 113/581] bridgev2/provisioning: add log when explicitly specified login ID is not found --- bridgev2/matrix/provisioning.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 2a84bdf2..571e3c7f 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -500,6 +500,9 @@ func (prov *ProvisioningAPI) GetExplicitLoginForRequest(w http.ResponseWriter, r } userLogin := prov.br.Bridge.GetCachedUserLoginByID(userLoginID) if userLogin == nil || userLogin.UserMXID != prov.GetUser(r).MXID { + hlog.FromRequest(r).Warn(). + Str("login_id", string(userLoginID)). + Msg("Tried to use non-existent login, returning 404") mautrix.MNotFound.WithMessage("Login not found").Write(w) return nil, true } From 3473f918645d1dbd22a97a27a7f9b4a2b7a7156f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 29 May 2025 13:58:05 +0300 Subject: [PATCH 114/581] bridgev2/portal: add some default log context fields for remote events --- bridgev2/networkinterface.go | 13 +++++++++++++ bridgev2/portal.go | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 14d502e3..2b99e4e6 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -77,6 +77,19 @@ type EventSender struct { ForceDMUser bool } +func (es EventSender) MarshalZerologObject(evt *zerolog.Event) { + evt.Str("user_id", string(es.Sender)) + if string(es.SenderLogin) != string(es.Sender) { + evt.Str("sender_login", string(es.SenderLogin)) + } + if es.IsFromMe { + evt.Bool("is_from_me", true) + } + if es.ForceDMUser { + evt.Bool("force_dm_user", true) + } +} + type ConvertedMessage struct { ReplyTo *networkid.MessageOptionalPartID ThreadRoot *networkid.MessageID diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 784b1590..126c64a9 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -372,6 +372,24 @@ func (portal *Portal) getEventCtxWithLog(rawEvt any, idx int) context.Context { Str("source_id", string(evt.source.ID)). Stringer("bridge_evt_type", evt.evtType) logWith = evt.evt.AddLogContext(logWith) + if remoteSender := evt.evt.GetSender(); remoteSender.Sender != "" || remoteSender.IsFromMe { + logWith = logWith.Object("remote_sender", remoteSender) + } + if remoteMsg, ok := evt.evt.(RemoteMessage); ok { + if remoteMsgID := remoteMsg.GetID(); remoteMsgID != "" { + logWith = logWith.Str("remote_message_id", string(remoteMsgID)) + } + } + if remoteMsg, ok := evt.evt.(RemoteEventWithTargetMessage); ok { + if targetMsgID := remoteMsg.GetTargetMessage(); targetMsgID != "" { + logWith = logWith.Str("remote_target_message_id", string(targetMsgID)) + } + } + if remoteMsg, ok := evt.evt.(RemoteEventWithStreamOrder); ok { + if remoteStreamOrder := remoteMsg.GetStreamOrder(); remoteStreamOrder != 0 { + logWith = logWith.Int64("remote_stream_order", remoteStreamOrder) + } + } case *portalCreateEvent: return evt.ctx } From e859fd8333411060120815e7ec0523a4bae54457 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Fri, 30 May 2025 15:09:43 +0100 Subject: [PATCH 115/581] bridgev2/bridgeconfig: add missing copy for session transfer config --- bridgev2/bridgeconfig/upgrade.go | 1 + 1 file changed, 1 insertion(+) diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index 18b98263..3e19bf8f 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -109,6 +109,7 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.Str, "provisioning", "shared_secret") } helper.Copy(up.Bool, "provisioning", "debug_endpoints") + helper.Copy(up.Bool, "provisioning", "enable_session_transfers") helper.Copy(up.Bool, "direct_media", "enabled") helper.Copy(up.Str|up.Null, "direct_media", "media_id_prefix") From 1b1b83298c33820c61a79151bc422b86e1da2183 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 30 May 2025 12:11:23 +0300 Subject: [PATCH 116/581] client,bridgev2: use time.After instead of sleep --- bridgev2/disappear.go | 6 +++++- client.go | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/bridgev2/disappear.go b/bridgev2/disappear.go index 0eea8bc3..1d063088 100644 --- a/bridgev2/disappear.go +++ b/bridgev2/disappear.go @@ -91,7 +91,11 @@ func (dl *DisappearLoop) Add(ctx context.Context, dm *database.DisappearingMessa func (dl *DisappearLoop) sleepAndDisappear(ctx context.Context, dms ...*database.DisappearingMessage) { for _, msg := range dms { - time.Sleep(time.Until(msg.DisappearAt)) + select { + case <-time.After(time.Until(msg.DisappearAt)): + case <-ctx.Done(): + return + } resp, err := dl.br.Bot.SendMessage(ctx, msg.RoomID, event.EventRedaction, &event.Content{ Parsed: &event.RedactionEventContent{ Redacts: msg.EventID, diff --git a/client.go b/client.go index bf3bb16e..5e2189e9 100644 --- a/client.go +++ b/client.go @@ -555,7 +555,11 @@ func (cli *Client) doRetry(req *http.Request, cause error, retries int, backoff log.Warn().Err(cause). Int("retry_in_seconds", int(backoff.Seconds())). Msg("Request failed, retrying") - time.Sleep(backoff) + select { + case <-time.After(backoff): + case <-req.Context().Done(): + return nil, nil, req.Context().Err() + } if cli.UpdateRequestOnRetry != nil { req = cli.UpdateRequestOnRetry(req, cause) } From 788621f7e03503283124bf23103be39b2a4139b4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 31 May 2025 17:59:25 +0300 Subject: [PATCH 117/581] bridgev2/crypto: fix ghost ID format in db queries --- bridgev2/matrix/crypto.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bridgev2/matrix/crypto.go b/bridgev2/matrix/crypto.go index 6e6416a9..47226625 100644 --- a/bridgev2/matrix/crypto.go +++ b/bridgev2/matrix/crypto.go @@ -14,6 +14,7 @@ import ( "fmt" "os" "runtime/debug" + "strings" "sync" "time" @@ -77,7 +78,7 @@ func (helper *CryptoHelper) Init(ctx context.Context) error { dbutil.ZeroLogger(helper.bridge.Log.With().Str("db_section", "crypto").Logger()), string(helper.bridge.Bridge.ID), helper.bridge.AS.BotMXID(), - fmt.Sprintf("@%s:%s", helper.bridge.Config.AppService.FormatUsername("%"), helper.bridge.AS.HomeserverDomain), + fmt.Sprintf("@%s:%s", strings.ReplaceAll(helper.bridge.Config.AppService.FormatUsername("%"), "_", `\_`), helper.bridge.AS.HomeserverDomain), helper.bridge.Config.Encryption.PickleKey, ) From 8fb04d1806970a1b9387ca423c01affe22ca56ee Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 2 Jun 2025 20:04:19 +0300 Subject: [PATCH 118/581] id/matrixuri: fix parsing url-encoded matrix URIs --- id/matrixuri.go | 11 +++++++++-- id/matrixuri_test.go | 4 ++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/id/matrixuri.go b/id/matrixuri.go index 2637d876..8f5ec849 100644 --- a/id/matrixuri.go +++ b/id/matrixuri.go @@ -210,7 +210,11 @@ func ProcessMatrixURI(uri *url.URL) (*MatrixURI, error) { if len(parts[1]) == 0 { return nil, ErrEmptySecondSegment } - parsed.MXID1 = parts[1] + var err error + parsed.MXID1, err = url.PathUnescape(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to url decode second segment %q: %w", parts[1], err) + } // Step 6: if the first part is a room and the URI has 4 segments, construct a second level identifier if parsed.Sigil1 == '!' && len(parts) == 4 { @@ -226,7 +230,10 @@ func ProcessMatrixURI(uri *url.URL) (*MatrixURI, error) { if len(parts[3]) == 0 { return nil, ErrEmptyFourthSegment } - parsed.MXID2 = parts[3] + parsed.MXID2, err = url.PathUnescape(parts[3]) + if err != nil { + return nil, fmt.Errorf("failed to url decode fourth segment %q: %w", parts[3], err) + } } // Step 7: parse the query and extract via and action items diff --git a/id/matrixuri_test.go b/id/matrixuri_test.go index 8b1096cb..90a0754d 100644 --- a/id/matrixuri_test.go +++ b/id/matrixuri_test.go @@ -77,8 +77,12 @@ func TestParseMatrixURI_RoomID(t *testing.T) { parsedVia, err := id.ParseMatrixURI("matrix:roomid/7NdBVvkd4aLSbgKt9RXl:example.org?via=maunium.net&via=matrix.org") require.NoError(t, err) require.NotNil(t, parsedVia) + parsedEncoded, err := id.ParseMatrixURI("matrix:roomid/7NdBVvkd4aLSbgKt9RXl%3Aexample.org") + require.NoError(t, err) + require.NotNil(t, parsedEncoded) assert.Equal(t, roomIDLink, *parsed) + assert.Equal(t, roomIDLink, *parsedEncoded) assert.Equal(t, roomIDViaLink, *parsedVia) } From 522a373c688150e473a1b386b83023705ac88519 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 3 Jun 2025 00:32:07 +0300 Subject: [PATCH 119/581] id: validate server names in UserID.ParseAndValidate --- id/userid.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/id/userid.go b/id/userid.go index 59136013..6d9f4080 100644 --- a/id/userid.go +++ b/id/userid.go @@ -30,10 +30,11 @@ func NewEncodedUserID(localpart, homeserver string) UserID { } var ( - ErrInvalidUserID = errors.New("is not a valid user ID") - ErrNoncompliantLocalpart = errors.New("contains characters that are not allowed") - ErrUserIDTooLong = errors.New("the given user ID is longer than 255 characters") - ErrEmptyLocalpart = errors.New("empty localparts are not allowed") + ErrInvalidUserID = errors.New("is not a valid user ID") + ErrNoncompliantLocalpart = errors.New("contains characters that are not allowed") + ErrUserIDTooLong = errors.New("the given user ID is longer than 255 characters") + ErrEmptyLocalpart = errors.New("empty localparts are not allowed") + ErrNoncompliantServerPart = errors.New("is not a valid server name") ) // ParseCommonIdentifier parses a common identifier according to https://spec.matrix.org/v1.9/appendices/#common-identifier-format @@ -113,6 +114,9 @@ func (userID UserID) ParseAndValidate() (localpart, homeserver string, err error if err == nil && len(userID) > UserIDMaxLength { err = ErrUserIDTooLong } + if err == nil && !ValidateServerName(homeserver) { + err = fmt.Errorf("%q %q", homeserver, ErrNoncompliantServerPart) + } return } From d804b5d96187c90d8bd18b466e7d1fb4aee3a510 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 4 Jun 2025 14:48:22 +0300 Subject: [PATCH 120/581] client: add support for stable version of room summary endpoint --- client.go | 8 ++++++-- versions.go | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/client.go b/client.go index 5e2189e9..6f746015 100644 --- a/client.go +++ b/client.go @@ -1062,13 +1062,17 @@ func (cli *Client) GetMutualRooms(ctx context.Context, otherUserID id.UserID, ex } func (cli *Client) GetRoomSummary(ctx context.Context, roomIDOrAlias string, via ...string) (resp *RespRoomSummary, err error) { + urlPath := ClientURLPath{"unstable", "im.nheko.summary", "summary", roomIDOrAlias} + if cli.SpecVersions.ContainsGreaterOrEqual(SpecV115) { + urlPath = ClientURLPath{"v1", "room_summary", roomIDOrAlias} + } // TODO add version check after one is added to MSC3266 - urlPath := cli.BuildURLWithFullQuery(ClientURLPath{"unstable", "im.nheko.summary", "summary", roomIDOrAlias}, func(q url.Values) { + fullURL := cli.BuildURLWithFullQuery(urlPath, func(q url.Values) { if len(via) > 0 { q["via"] = via } }) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + _, err = cli.MakeRequest(ctx, http.MethodGet, fullURL, nil, &resp) return } diff --git a/versions.go b/versions.go index 7e752986..f87bddda 100644 --- a/versions.go +++ b/versions.go @@ -115,6 +115,8 @@ var ( SpecV111 = MustParseSpecVersion("v1.11") SpecV112 = MustParseSpecVersion("v1.12") SpecV113 = MustParseSpecVersion("v1.13") + SpecV114 = MustParseSpecVersion("v1.14") + SpecV115 = MustParseSpecVersion("v1.15") ) func (svf SpecVersionFormat) String() string { From baf4cc3ee43e541ba583bf3974c42c1b95b7d4e1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 4 Jun 2025 16:13:16 +0300 Subject: [PATCH 121/581] bridgev2/portal: log start time when event handling takes long --- bridgev2/portal.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 126c64a9..5cc7bb4a 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -329,7 +329,9 @@ func (portal *Portal) handleSingleEventAsync(idx int, rawEvt any) { handleDuration = time.Since(start) close(doneCh) if backgrounded.Load() { - log.Debug().Stringer("duration", handleDuration). + log.Debug(). + Time("started_at", start). + Stringer("duration", handleDuration). Msg("Event that took too long finally finished handling") } }) @@ -339,15 +341,21 @@ func (portal *Portal) handleSingleEventAsync(idx int, rawEvt any) { select { case <-doneCh: if i > 0 { - log.Debug().Stringer("duration", handleDuration). + log.Debug(). + Time("started_at", start). + Stringer("duration", handleDuration). Msg("Event that took long finished handling") } return case <-tick.C: - log.Warn().Msg("Event handling is taking long") + log.Warn(). + Time("started_at", start). + Msg("Event handling is taking long") } } - log.Warn().Msg("Event handling is taking too long, continuing in background") + log.Warn(). + Time("started_at", start). + Msg("Event handling is taking too long, continuing in background") backgrounded.Store(true) } } From 1e10d9460ad216e3aadbd1a98871807eb5846995 Mon Sep 17 00:00:00 2001 From: Brad Murray Date: Wed, 4 Jun 2025 11:37:00 -0400 Subject: [PATCH 122/581] bridgev2/status: add RESTART UserAction (#384) --- bridgev2/status/bridgestate.go | 1 + 1 file changed, 1 insertion(+) diff --git a/bridgev2/status/bridgestate.go b/bridgev2/status/bridgestate.go index 005a4f62..01a235a0 100644 --- a/bridgev2/status/bridgestate.go +++ b/bridgev2/status/bridgestate.go @@ -78,6 +78,7 @@ type BridgeStateUserAction string const ( UserActionOpenNative BridgeStateUserAction = "OPEN_NATIVE" UserActionRelogin BridgeStateUserAction = "RELOGIN" + UserActionRestart BridgeStateUserAction = "RESTART" ) type RemoteProfile struct { From d228995d718ae930a4c51df5fcafcfa05227c53e Mon Sep 17 00:00:00 2001 From: Toni Spets Date: Thu, 5 Jun 2025 07:25:48 +0300 Subject: [PATCH 123/581] bridgev2: Configurable disconnect timeout (#383) Let the caller decide if they want to have a timeout or not. For standalone bridges using the Bridge struct the behavior is kept the same by waiting for five seconds when UserLogin DisconnectWithTimeout() is called. --- bridgev2/bridge.go | 19 +++++++++++++------ bridgev2/matrix/mxmain/main.go | 2 +- bridgev2/userlogin.go | 31 ++++++++++++++++++++++--------- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index 05a67b6a..5e3b74b7 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -137,7 +137,7 @@ func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, pa if err != nil { return err } - defer br.Stop() + defer br.StopWithTimeout(5 * time.Second) select { case <-time.After(20 * time.Second): case <-ctx.Done(): @@ -145,7 +145,7 @@ func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, pa return nil } - defer br.stop(true) + defer br.stop(true, 5*time.Second) login, err := br.GetExistingUserLoginByID(ctx, loginID) if err != nil { return fmt.Errorf("failed to get user login: %w", err) @@ -156,7 +156,7 @@ func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, pa if !ok { br.Log.Warn().Msg("Network connector doesn't implement background mode, using fallback mechanism for RunOnce") login.Client.Connect(ctx) - defer login.Disconnect(nil) + defer login.DisconnectWithTimeout(5 * time.Second) select { case <-time.After(20 * time.Second): case <-ctx.Done(): @@ -319,10 +319,14 @@ func (br *Bridge) StartLogins(ctx context.Context) error { } func (br *Bridge) Stop() { - br.stop(false) + br.stop(false, 0) } -func (br *Bridge) stop(isRunOnce bool) { +func (br *Bridge) StopWithTimeout(timeout time.Duration) { + br.stop(false, timeout) +} + +func (br *Bridge) stop(isRunOnce bool, timeout time.Duration) { br.Log.Info().Msg("Shutting down bridge") br.DisappearLoop.Stop() br.stopBackfillQueue.Set() @@ -332,7 +336,10 @@ func (br *Bridge) stop(isRunOnce bool) { var wg sync.WaitGroup wg.Add(len(br.userLoginsByID)) for _, login := range br.userLoginsByID { - go login.Disconnect(wg.Done) + go func() { + login.DisconnectWithTimeout(timeout) + wg.Done() + }() } br.cacheLock.Unlock() wg.Wait() diff --git a/bridgev2/matrix/mxmain/main.go b/bridgev2/matrix/mxmain/main.go index 63334ba5..e6219c50 100644 --- a/bridgev2/matrix/mxmain/main.go +++ b/bridgev2/matrix/mxmain/main.go @@ -421,7 +421,7 @@ func (br *BridgeMain) TriggerStop(exitCode int) { // Stop cleanly stops the bridge. This is called by [Run] and does not need to be called manually. func (br *BridgeMain) Stop() { - br.Bridge.Stop() + br.Bridge.StopWithTimeout(5 * time.Second) } // InitVersion formats the bridge version and build time nicely for things like diff --git a/bridgev2/userlogin.go b/bridgev2/userlogin.go index 396cf899..e83e66c2 100644 --- a/bridgev2/userlogin.go +++ b/bridgev2/userlogin.go @@ -279,7 +279,8 @@ func (ul *UserLogin) Delete(ctx context.Context, state status.BridgeState, opts if opts.LogoutRemote { ul.Client.LogoutRemote(ctx) } else { - ul.Disconnect(nil) + // we probably shouldn't delete the login if disconnect isn't finished + ul.Disconnect() } var portals []*database.UserPortal var err error @@ -508,10 +509,11 @@ func (ul *UserLogin) FillBridgeState(state status.BridgeState) status.BridgeStat return state } -func (ul *UserLogin) Disconnect(done func()) { - if done != nil { - defer done() - } +func (ul *UserLogin) Disconnect() { + ul.DisconnectWithTimeout(0) +} + +func (ul *UserLogin) DisconnectWithTimeout(timeout time.Duration) { client := ul.Client if client != nil { ul.Client = nil @@ -520,10 +522,21 @@ func (ul *UserLogin) Disconnect(done func()) { client.Disconnect() close(disconnected) }() - select { - case <-disconnected: - case <-time.After(5 * time.Second): - ul.Log.Warn().Msg("Client disconnection timed out") + + var timeoutC <-chan time.Time + if timeout > 0 { + timeoutC = time.After(timeout) + } + for { + select { + case <-disconnected: + return + case <-time.After(2 * time.Second): + ul.Log.Warn().Msg("Client disconnection taking long") + case <-timeoutC: + ul.Log.Error().Msg("Client disconnection timed out") + return + } } } } From d04d524209dbf1ce36a9379b687859cf8a6e8e01 Mon Sep 17 00:00:00 2001 From: Brad Murray Date: Thu, 5 Jun 2025 13:38:19 -0400 Subject: [PATCH 124/581] crypto/verificationhelper: add method to verification done callback (#385) --- crypto/verificationhelper/callbacks_test.go | 2 +- crypto/verificationhelper/reciprocate.go | 4 ++-- crypto/verificationhelper/verificationhelper.go | 14 +++++++------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crypto/verificationhelper/callbacks_test.go b/crypto/verificationhelper/callbacks_test.go index 18cb964f..3b943f28 100644 --- a/crypto/verificationhelper/callbacks_test.go +++ b/crypto/verificationhelper/callbacks_test.go @@ -109,7 +109,7 @@ func (c *baseVerificationCallbacks) VerificationCancelled(ctx context.Context, t } } -func (c *baseVerificationCallbacks) VerificationDone(ctx context.Context, txnID id.VerificationTransactionID) { +func (c *baseVerificationCallbacks) VerificationDone(ctx context.Context, txnID id.VerificationTransactionID, method event.VerificationMethod) { c.doneTransactions[txnID] = struct{}{} } diff --git a/crypto/verificationhelper/reciprocate.go b/crypto/verificationhelper/reciprocate.go index 9cb84c24..d8827b8b 100644 --- a/crypto/verificationhelper/reciprocate.go +++ b/crypto/verificationhelper/reciprocate.go @@ -182,7 +182,7 @@ func (vh *VerificationHelper) HandleScannedQRData(ctx context.Context, data []by if err = vh.store.DeleteVerification(ctx, txn.TransactionID); err != nil { return err } - vh.verificationDone(ctx, txn.TransactionID) + vh.verificationDone(ctx, txn.TransactionID, txn.StartEventContent.Method) } else { return vh.store.SaveVerificationTransaction(ctx, txn) } @@ -263,7 +263,7 @@ func (vh *VerificationHelper) ConfirmQRCodeScanned(ctx context.Context, txnID id if err = vh.store.DeleteVerification(ctx, txn.TransactionID); err != nil { return err } - vh.verificationDone(ctx, txn.TransactionID) + vh.verificationDone(ctx, txn.TransactionID, txn.StartEventContent.Method) } else { return vh.store.SaveVerificationTransaction(ctx, txn) } diff --git a/crypto/verificationhelper/verificationhelper.go b/crypto/verificationhelper/verificationhelper.go index 8d99dacc..9d843ea8 100644 --- a/crypto/verificationhelper/verificationhelper.go +++ b/crypto/verificationhelper/verificationhelper.go @@ -41,7 +41,7 @@ type RequiredCallbacks interface { VerificationCancelled(ctx context.Context, txnID id.VerificationTransactionID, code event.VerificationCancelCode, reason string) // VerificationDone is called when the verification is done. - VerificationDone(ctx context.Context, txnID id.VerificationTransactionID) + VerificationDone(ctx context.Context, txnID id.VerificationTransactionID, method event.VerificationMethod) } type ShowSASCallbacks interface { @@ -70,14 +70,14 @@ type VerificationHelper struct { verificationRequested func(ctx context.Context, txnID id.VerificationTransactionID, from id.UserID, fromDevice id.DeviceID) verificationReady func(ctx context.Context, txnID id.VerificationTransactionID, otherDeviceID id.DeviceID, supportsSAS, supportsScanQRCode bool, qrCode *QRCode) verificationCancelledCallback func(ctx context.Context, txnID id.VerificationTransactionID, code event.VerificationCancelCode, reason string) - verificationDone func(ctx context.Context, txnID id.VerificationTransactionID) + verificationDone func(ctx context.Context, txnID id.VerificationTransactionID, method event.VerificationMethod) // showSAS is a callback that will be called after the SAS verification // dance is complete and we want the client to show the emojis/decimals showSAS func(ctx context.Context, txnID id.VerificationTransactionID, emojis []rune, emojiDescriptions []string, decimals []int) - // qrCodeScaned is a callback that will be called when the other device + // qrCodeScanned is a callback that will be called when the other device // scanned the QR code we are showing - qrCodeScaned func(ctx context.Context, txnID id.VerificationTransactionID) + qrCodeScanned func(ctx context.Context, txnID id.VerificationTransactionID) } var _ mautrix.VerificationHelper = (*VerificationHelper)(nil) @@ -120,7 +120,7 @@ func NewVerificationHelper(client *mautrix.Client, mach *crypto.OlmMachine, stor } else { helper.supportedMethods = append(helper.supportedMethods, event.VerificationMethodQRCodeShow) helper.supportedMethods = append(helper.supportedMethods, event.VerificationMethodReciprocate) - helper.qrCodeScaned = c.QRCodeScanned + helper.qrCodeScanned = c.QRCodeScanned } } if supportsQRScan { @@ -839,7 +839,7 @@ func (vh *VerificationHelper) onVerificationStart(ctx context.Context, txn Verif return } txn.VerificationState = VerificationStateOurQRScanned - vh.qrCodeScaned(ctx, txn.TransactionID) + vh.qrCodeScanned(ctx, txn.TransactionID) if err := vh.store.SaveVerificationTransaction(ctx, txn); err != nil { log.Err(err).Msg("failed to save verification transaction") } @@ -875,7 +875,7 @@ func (vh *VerificationHelper) onVerificationDone(ctx context.Context, txn Verifi if err := vh.store.DeleteVerification(ctx, txn.TransactionID); err != nil { log.Err(err).Msg("Delete verification failed") } - vh.verificationDone(ctx, txn.TransactionID) + vh.verificationDone(ctx, txn.TransactionID, txn.StartEventContent.Method) } else if err := vh.store.SaveVerificationTransaction(ctx, txn); err != nil { log.Err(err).Msg("failed to save verification transaction") } From d296f7b6604bb2f95b8f548ec38ce87e6e6f6c50 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 6 Jun 2025 14:08:04 +0300 Subject: [PATCH 125/581] bridgev2/provisioning: ensure that Start returns a non-nil first step --- bridgev2/matrix/provisioning.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 571e3c7f..e897bae8 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -390,6 +390,10 @@ func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Reque zerolog.Ctx(r.Context()).Err(err).Msg("Failed to start login") RespondWithError(w, err, "Internal error starting login") return + } else if firstStep == nil { + zerolog.Ctx(r.Context()).Error().Msg("Bridge returned nil first step in Start with no error") + RespondWithError(w, err, "Internal error starting login") + return } loginID := xid.New().String() prov.loginsLock.Lock() From 40fd8dfcbd71fce9163f6510671f920f9c7aad43 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 8 Jun 2025 00:05:59 +0300 Subject: [PATCH 126/581] event/relations: use unstable prefix for reply room ID field --- event/relations.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/event/relations.go b/event/relations.go index e855a7e6..2316cbc7 100644 --- a/event/relations.go +++ b/event/relations.go @@ -34,7 +34,7 @@ type RelatesTo struct { type InReplyTo struct { EventID id.EventID `json:"event_id,omitempty"` - UnstableRoomID id.RoomID `json:"room_id,omitempty"` + UnstableRoomID id.RoomID `json:"com.beeper.cross_room_id,omitempty"` } func (rel *RelatesTo) Copy() *RelatesTo { From 07567f6f96d6dc420e37f561a3474c3bcdf98b1e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 8 Jun 2025 00:12:53 +0300 Subject: [PATCH 127/581] bridgev2/portal: include room id in cross-room replies --- bridgev2/bridgeconfig/config.go | 1 + bridgev2/bridgeconfig/upgrade.go | 1 + bridgev2/matrix/mxmain/example-config.yaml | 3 +++ bridgev2/portal.go | 23 +++++++++++++++++++--- bridgev2/portalbackfill.go | 2 +- bridgev2/portalinternal.go | 20 +++++++++++++++++-- 6 files changed, 44 insertions(+), 6 deletions(-) diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index 37517818..bd6f53c3 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -72,6 +72,7 @@ type BridgeConfig struct { OnlyBridgeTags []event.RoomTag `yaml:"only_bridge_tags"` MuteOnlyOnCreate bool `yaml:"mute_only_on_create"` DeduplicateMatrixMessages bool `yaml:"deduplicate_matrix_messages"` + CrossRoomReplies bool `yaml:"cross_room_replies"` OutgoingMessageReID bool `yaml:"outgoing_message_re_id"` CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"` Relay RelayConfig `yaml:"relay"` diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index 3e19bf8f..fa4b4493 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -38,6 +38,7 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.List, "bridge", "only_bridge_tags") helper.Copy(up.Bool, "bridge", "mute_only_on_create") helper.Copy(up.Bool, "bridge", "deduplicate_matrix_messages") + helper.Copy(up.Bool, "bridge", "cross_room_replies") helper.Copy(up.Bool, "bridge", "cleanup_on_logout", "enabled") helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "private") helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "relayed") diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index a9d05fd1..dad3f8a8 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -40,6 +40,9 @@ bridge: mute_only_on_create: true # Should the bridge check the db to ensure that incoming events haven't been handled before deduplicate_matrix_messages: false + # Should cross-room reply metadata be bridged? + # Most Matrix clients don't support this and servers may reject such messages too. + cross_room_replies: false # What should be done to portal rooms when a user logs out or is logged out? # Permitted values: diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 5cc7bb4a..333c1889 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1943,7 +1943,7 @@ func (portal *Portal) getRelationMeta(ctx context.Context, currentMsg networkid. return } -func (portal *Portal) applyRelationMeta(content *event.MessageEventContent, replyTo, threadRoot, prevThreadEvent *database.Message) { +func (portal *Portal) applyRelationMeta(ctx context.Context, content *event.MessageEventContent, replyTo, threadRoot, prevThreadEvent *database.Message) { if content.Mentions == nil { content.Mentions = &event.Mentions{} } @@ -1951,7 +1951,24 @@ func (portal *Portal) applyRelationMeta(content *event.MessageEventContent, repl content.GetRelatesTo().SetThread(threadRoot.MXID, prevThreadEvent.MXID) } if replyTo != nil { - content.GetRelatesTo().SetReplyTo(replyTo.MXID) + crossRoom := replyTo.Room != portal.PortalKey + if !crossRoom || portal.Bridge.Config.CrossRoomReplies { + content.GetRelatesTo().SetReplyTo(replyTo.MXID) + } + if crossRoom && portal.Bridge.Config.CrossRoomReplies { + targetPortal, err := portal.Bridge.GetExistingPortalByKey(ctx, replyTo.Room) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Object("target_portal_key", replyTo.Room). + Msg("Failed to get cross-room reply portal") + } else if targetPortal == nil || targetPortal.MXID == "" { + zerolog.Ctx(ctx).Warn(). + Object("target_portal_key", replyTo.Room). + Msg("Cross-room reply portal not found") + } else { + content.RelatesTo.InReplyTo.UnstableRoomID = targetPortal.MXID + } + } content.Mentions.Add(replyTo.SenderMXID) } } @@ -1975,7 +1992,7 @@ func (portal *Portal) sendConvertedMessage( replyTo, threadRoot, prevThreadEvent := portal.getRelationMeta(ctx, id, converted.ReplyTo, converted.ThreadRoot, false) output := make([]*database.Message, 0, len(converted.Parts)) for i, part := range converted.Parts { - portal.applyRelationMeta(part.Content, replyTo, threadRoot, prevThreadEvent) + portal.applyRelationMeta(ctx, part.Content, replyTo, threadRoot, prevThreadEvent) dbMessage := &database.Message{ ID: id, PartID: part.ID, diff --git a/bridgev2/portalbackfill.go b/bridgev2/portalbackfill.go index a5dfb42a..3953a043 100644 --- a/bridgev2/portalbackfill.go +++ b/bridgev2/portalbackfill.go @@ -333,7 +333,7 @@ func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin var firstPart *database.Message for i, part := range msg.Parts { partIDs = append(partIDs, part.ID) - portal.applyRelationMeta(part.Content, replyTo, threadRoot, prevThreadEvent) + portal.applyRelationMeta(ctx, part.Content, replyTo, threadRoot, prevThreadEvent) evtID := portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, msg.ID, part.ID) dbMessage := &database.Message{ ID: msg.ID, diff --git a/bridgev2/portalinternal.go b/bridgev2/portalinternal.go index e0f4ee5a..fd6724f4 100644 --- a/bridgev2/portalinternal.go +++ b/bridgev2/portalinternal.go @@ -89,10 +89,22 @@ func (portal *PortalInternals) CheckMessageContentCaps(ctx context.Context, caps return (*Portal)(portal).checkMessageContentCaps(ctx, caps, content, evt) } +func (portal *PortalInternals) ParseInputTransactionID(origSender *OrigSender, evt *event.Event) networkid.RawTransactionID { + return (*Portal)(portal).parseInputTransactionID(origSender, evt) +} + func (portal *PortalInternals) HandleMatrixMessage(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) { (*Portal)(portal).handleMatrixMessage(ctx, sender, origSender, evt) } +func (portal *PortalInternals) PendingMessageTimeoutLoop(ctx context.Context, cfg *OutgoingTimeoutConfig) { + (*Portal)(portal).pendingMessageTimeoutLoop(ctx, cfg) +} + +func (portal *PortalInternals) CheckPendingMessages(ctx context.Context, cfg *OutgoingTimeoutConfig) { + (*Portal)(portal).checkPendingMessages(ctx, cfg) +} + func (portal *PortalInternals) HandleMatrixEdit(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, content *event.MessageEventContent, caps *event.RoomFeatures) { (*Portal)(portal).handleMatrixEdit(ctx, sender, origSender, evt, content, caps) } @@ -129,8 +141,8 @@ func (portal *PortalInternals) GetRelationMeta(ctx context.Context, currentMsg n return (*Portal)(portal).getRelationMeta(ctx, currentMsg, replyToPtr, threadRootPtr, isBatchSend) } -func (portal *PortalInternals) ApplyRelationMeta(content *event.MessageEventContent, replyTo, threadRoot, prevThreadEvent *database.Message) { - (*Portal)(portal).applyRelationMeta(content, replyTo, threadRoot, prevThreadEvent) +func (portal *PortalInternals) ApplyRelationMeta(ctx context.Context, content *event.MessageEventContent, replyTo, threadRoot, prevThreadEvent *database.Message) { + (*Portal)(portal).applyRelationMeta(ctx, content, replyTo, threadRoot, prevThreadEvent) } func (portal *PortalInternals) SendConvertedMessage(ctx context.Context, id networkid.MessageID, intent MatrixAPI, senderID networkid.UserID, converted *ConvertedMessage, ts time.Time, streamOrder int64, logContext func(*zerolog.Event) *zerolog.Event) []*database.Message { @@ -241,6 +253,10 @@ func (portal *PortalInternals) UpdateAvatar(ctx context.Context, avatar *Avatar, return (*Portal)(portal).updateAvatar(ctx, avatar, sender, ts) } +func (portal *PortalInternals) GetBridgeInfoStateKey() string { + return (*Portal)(portal).getBridgeInfoStateKey() +} + func (portal *PortalInternals) GetBridgeInfo() (string, event.BridgeEventContent) { return (*Portal)(portal).getBridgeInfo() } From 8fb41765e2ff525e94ee9ff01bcff19c42ab3036 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 9 Jun 2025 13:52:24 +0300 Subject: [PATCH 128/581] event: add custom soft fail fields --- event/events.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/event/events.go b/event/events.go index 92cc39ae..07a2c7cb 100644 --- a/event/events.go +++ b/event/events.go @@ -151,10 +151,14 @@ type Unsigned struct { BeeperHSSuborder int16 `json:"com.beeper.hs.suborder,omitempty"` BeeperHSOrderString *BeeperEncodedOrder `json:"com.beeper.hs.order_string,omitempty"` BeeperFromBackup bool `json:"com.beeper.from_backup,omitempty"` + + MauSoftFailed bool `json:"fi.mau.soft_failed,omitempty"` + MauRejectionReason string `json:"fi.mau.rejection_reason,omitempty"` } func (us *Unsigned) IsEmpty() bool { return us.PrevContent == nil && us.PrevSender == "" && us.ReplacesState == "" && us.Age == 0 && us.TransactionID == "" && us.RedactedBecause == nil && us.InviteRoomState == nil && us.Relations == nil && - us.BeeperHSOrder == 0 && us.BeeperHSSuborder == 0 && us.BeeperHSOrderString.IsZero() + us.BeeperHSOrder == 0 && us.BeeperHSSuborder == 0 && us.BeeperHSOrderString.IsZero() && + !us.MauSoftFailed && us.MauRejectionReason == "" } From 05f371a48092429a04c6ebb3b81ea3a10368228b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 9 Jun 2025 14:00:13 +0300 Subject: [PATCH 129/581] event: add membership field to unsigned --- event/events.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/event/events.go b/event/events.go index 07a2c7cb..a763cc31 100644 --- a/event/events.go +++ b/event/events.go @@ -140,6 +140,7 @@ type StrippedState struct { type Unsigned struct { PrevContent *Content `json:"prev_content,omitempty"` PrevSender id.UserID `json:"prev_sender,omitempty"` + Membership Membership `json:"membership,omitempty"` ReplacesState id.EventID `json:"replaces_state,omitempty"` Age int64 `json:"age,omitempty"` TransactionID string `json:"transaction_id,omitempty"` @@ -157,7 +158,7 @@ type Unsigned struct { } func (us *Unsigned) IsEmpty() bool { - return us.PrevContent == nil && us.PrevSender == "" && us.ReplacesState == "" && us.Age == 0 && + return us.PrevContent == nil && us.PrevSender == "" && us.ReplacesState == "" && us.Age == 0 && us.Membership == "" && us.TransactionID == "" && us.RedactedBecause == nil && us.InviteRoomState == nil && us.Relations == nil && us.BeeperHSOrder == 0 && us.BeeperHSSuborder == 0 && us.BeeperHSOrderString.IsZero() && !us.MauSoftFailed && us.MauRejectionReason == "" From a154718b5d4cd43a61dd56de141edbab9eed9aa4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 9 Jun 2025 17:24:46 +0530 Subject: [PATCH 130/581] bridgev2/portal: allow specifying extra fields for portal members (#386) --- bridgev2/ghost.go | 23 ++++++++------ bridgev2/portal.go | 76 +++++++++++++++++++++++++++++----------------- 2 files changed, 61 insertions(+), 38 deletions(-) diff --git a/bridgev2/ghost.go b/bridgev2/ghost.go index 087e0b64..f06c0363 100644 --- a/bridgev2/ghost.go +++ b/bridgev2/ghost.go @@ -185,6 +185,18 @@ func (ghost *Ghost) UpdateAvatar(ctx context.Context, avatar *Avatar) bool { return true } +func (ghost *Ghost) getExtraProfileMeta() *event.BeeperProfileExtra { + bridgeName := ghost.Bridge.Network.GetName() + return &event.BeeperProfileExtra{ + RemoteID: string(ghost.ID), + Identifiers: ghost.Identifiers, + Service: bridgeName.BeeperBridgeType, + Network: bridgeName.NetworkID, + IsBridgeBot: false, + IsNetworkBot: ghost.IsBot, + } +} + func (ghost *Ghost) UpdateContactInfo(ctx context.Context, identifiers []string, isBot *bool) bool { if identifiers != nil { slices.Sort(identifiers) @@ -200,16 +212,7 @@ func (ghost *Ghost) UpdateContactInfo(ctx context.Context, identifiers []string, if isBot != nil { ghost.IsBot = *isBot } - bridgeName := ghost.Bridge.Network.GetName() - meta := &event.BeeperProfileExtra{ - RemoteID: string(ghost.ID), - Identifiers: ghost.Identifiers, - Service: bridgeName.BeeperBridgeType, - Network: bridgeName.NetworkID, - IsBridgeBot: false, - IsNetworkBot: ghost.IsBot, - } - err := ghost.Intent.SetExtraProfileMeta(ctx, meta) + err := ghost.Intent.SetExtraProfileMeta(ctx, ghost.getExtraProfileMeta()) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to set extra profile metadata") } else { diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 333c1889..1a57d211 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -2926,7 +2926,8 @@ type ChatMember struct { PowerLevel *int UserInfo *UserInfo - PrevMembership event.Membership + MemberEventExtra map[string]any + PrevMembership event.Membership } type ChatMemberList struct { @@ -3347,7 +3348,13 @@ func (portal *Portal) updateOtherUser(ctx context.Context, members *ChatMemberLi return false } -func (portal *Portal) syncParticipants(ctx context.Context, members *ChatMemberList, source *UserLogin, sender MatrixAPI, ts time.Time) error { +func (portal *Portal) syncParticipants( + ctx context.Context, + members *ChatMemberList, + source *UserLogin, + sender MatrixAPI, + ts time.Time, +) error { members.memberListToMap(ctx) var loginsInPortal []*UserLogin var err error @@ -3371,7 +3378,7 @@ func (portal *Portal) syncParticipants(ctx context.Context, members *ChatMemberL } delete(currentMembers, portal.Bridge.Bot.GetMXID()) powerChanged := members.PowerLevels.Apply(portal.Bridge.Bot.GetMXID(), currentPower) - syncUser := func(extraUserID id.UserID, member ChatMember, hasIntent bool) bool { + syncUser := func(extraUserID id.UserID, member ChatMember, intent MatrixAPI) bool { if member.Membership == "" { member.Membership = event.MembershipJoin } @@ -3400,58 +3407,71 @@ func (portal *Portal) syncParticipants(ctx context.Context, members *ChatMemberL Displayname: currentMember.Displayname, AvatarURL: currentMember.AvatarURL, } - wrappedContent := &event.Content{Parsed: content, Raw: make(map[string]any)} + wrappedContent := &event.Content{Parsed: content, Raw: maps.Clone(member.MemberEventExtra)} + if wrappedContent.Raw == nil { + wrappedContent.Raw = make(map[string]any) + } thisEvtSender := sender if member.Membership == event.MembershipJoin { content.Membership = event.MembershipInvite - if hasIntent { + if intent != nil { wrappedContent.Raw["fi.mau.will_auto_accept"] = true } if thisEvtSender.GetMXID() == extraUserID { thisEvtSender = portal.Bridge.Bot } } + addLogContext := func(e *zerolog.Event) *zerolog.Event { + return e.Stringer("target_user_id", extraUserID). + Stringer("sender_user_id", thisEvtSender.GetMXID()). + Str("prev_membership", string(currentMember.Membership)) + } if currentMember != nil && currentMember.Membership == event.MembershipBan && member.Membership != event.MembershipLeave { unbanContent := *content unbanContent.Membership = event.MembershipLeave wrappedUnbanContent := &event.Content{Parsed: &unbanContent} _, err = portal.sendStateWithIntentOrBot(ctx, thisEvtSender, event.StateMember, extraUserID.String(), wrappedUnbanContent, ts) if err != nil { - log.Err(err). - Stringer("target_user_id", extraUserID). - Stringer("sender_user_id", thisEvtSender.GetMXID()). - Str("prev_membership", string(currentMember.Membership)). - Str("membership", string(member.Membership)). + addLogContext(log.Err(err)). + Str("new_membership", string(unbanContent.Membership)). Msg("Failed to unban user to update membership") } else { - log.Trace(). - Stringer("target_user_id", extraUserID). - Stringer("sender_user_id", thisEvtSender.GetMXID()). - Str("prev_membership", string(currentMember.Membership)). - Str("membership", string(member.Membership)). + addLogContext(log.Trace()). + Str("new_membership", string(unbanContent.Membership)). Msg("Unbanned user to update membership") + currentMember.Membership = event.MembershipLeave } } _, err = portal.sendStateWithIntentOrBot(ctx, thisEvtSender, event.StateMember, extraUserID.String(), wrappedContent, ts) if err != nil { - log.Err(err). - Stringer("target_user_id", extraUserID). - Stringer("sender_user_id", thisEvtSender.GetMXID()). - Str("prev_membership", string(currentMember.Membership)). - Str("membership", string(member.Membership)). + addLogContext(log.Err(err)). + Str("new_membership", string(content.Membership)). Msg("Failed to update user membership") } else { - log.Trace(). - Stringer("target_user_id", extraUserID). - Stringer("sender_user_id", thisEvtSender.GetMXID()). - Str("prev_membership", string(currentMember.Membership)). - Str("membership", string(member.Membership)). - Msg("Updating membership in room") + addLogContext(log.Trace()). + Str("new_membership", string(content.Membership)). + Msg("Updated membership in room") + currentMember.Membership = content.Membership + + if intent != nil && content.Membership == event.MembershipInvite && member.Membership == event.MembershipJoin { + content.Membership = event.MembershipJoin + wrappedJoinContent := &event.Content{Parsed: content, Raw: member.MemberEventExtra} + _, err = intent.SendState(ctx, portal.MXID, event.StateMember, intent.GetMXID().String(), wrappedJoinContent, ts) + if err != nil { + addLogContext(log.Err(err)). + Str("new_membership", string(content.Membership)). + Msg("Failed to join with intent") + } else { + addLogContext(log.Trace()). + Str("new_membership", string(content.Membership)). + Msg("Joined room with intent") + } + } } return true } syncIntent := func(intent MatrixAPI, member ChatMember) { - if !syncUser(intent.GetMXID(), member, true) { + if !syncUser(intent.GetMXID(), member, intent) { return } if member.Membership == event.MembershipJoin || member.Membership == "" { @@ -3480,7 +3500,7 @@ func (portal *Portal) syncParticipants(ctx context.Context, members *ChatMemberL syncIntent(intent, member) } if extraUserID != "" { - syncUser(extraUserID, member, false) + syncUser(extraUserID, member, nil) } } if powerChanged { From 72bacbb666fb97adacabd5198c120910bd869d2f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 9 Jun 2025 19:30:37 +0300 Subject: [PATCH 131/581] appservice/intent: ensure registered when sending own member state event --- appservice/intent.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/appservice/intent.go b/appservice/intent.go index 30313273..d6cda137 100644 --- a/appservice/intent.go +++ b/appservice/intent.go @@ -228,6 +228,8 @@ func (intent *IntentAPI) SendStateEvent(ctx context.Context, roomID id.RoomID, e if err := intent.EnsureJoined(ctx, roomID); err != nil { return nil, err } + } else if err := intent.EnsureRegistered(ctx); err != nil { + return nil, err } contentJSON = intent.AddDoublePuppetValue(contentJSON) return intent.Client.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON) From 99cfa0b53ae6900f8f4b7ad33b71016122542910 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 10 Jun 2025 15:03:31 +0300 Subject: [PATCH 132/581] bridgev2/matrixinvite: save portal after setting mxid --- bridgev2/matrixinvite.go | 17 ++++++++++++----- bridgev2/portalinternal.go | 4 ++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/bridgev2/matrixinvite.go b/bridgev2/matrixinvite.go index 25c35eb7..0f1601d1 100644 --- a/bridgev2/matrixinvite.go +++ b/bridgev2/matrixinvite.go @@ -177,10 +177,7 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen return } - didSetPortal := portal.setMXIDToExistingRoom(evt.RoomID) - if resp.PortalInfo != nil { - portal.UpdateInfo(ctx, resp.PortalInfo, sourceLogin, nil, time.Time{}) - } + didSetPortal := portal.setMXIDToExistingRoom(ctx, evt.RoomID) if didSetPortal { message := "Private chat portal created" err = br.givePowerToBot(ctx, evt.RoomID, invitedGhost.Intent) @@ -190,6 +187,12 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen message += "\n\nWarning: failed to promote bot" hasWarning = true } + if resp.PortalInfo != nil { + portal.UpdateInfo(ctx, resp.PortalInfo, sourceLogin, nil, time.Time{}) + } else { + portal.UpdateCapabilities(ctx, sourceLogin, true) + portal.UpdateBridgeInfo(ctx) + } // TODO this might become unnecessary if UpdateInfo starts taking care of it _, err = br.Bot.SendState(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.Content{ Parsed: &event.ElementFunctionalMembersContent{ @@ -242,7 +245,7 @@ func (br *Bridge) givePowerToBot(ctx context.Context, roomID id.RoomID, userWith return nil } -func (portal *Portal) setMXIDToExistingRoom(roomID id.RoomID) bool { +func (portal *Portal) setMXIDToExistingRoom(ctx context.Context, roomID id.RoomID) bool { portal.roomCreateLock.Lock() defer portal.roomCreateLock.Unlock() if portal.MXID != "" { @@ -253,5 +256,9 @@ func (portal *Portal) setMXIDToExistingRoom(roomID id.RoomID) bool { portal.Bridge.cacheLock.Lock() portal.Bridge.portalsByMXID[portal.MXID] = portal portal.Bridge.cacheLock.Unlock() + err := portal.Save(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after updating mxid") + } return true } diff --git a/bridgev2/portalinternal.go b/bridgev2/portalinternal.go index fd6724f4..bde0b170 100644 --- a/bridgev2/portalinternal.go +++ b/bridgev2/portalinternal.go @@ -361,6 +361,6 @@ func (portal *PortalInternals) ToggleSpace(ctx context.Context, spaceID id.RoomI return (*Portal)(portal).toggleSpace(ctx, spaceID, canonical, remove) } -func (portal *PortalInternals) SetMXIDToExistingRoom(roomID id.RoomID) bool { - return (*Portal)(portal).setMXIDToExistingRoom(roomID) +func (portal *PortalInternals) SetMXIDToExistingRoom(ctx context.Context, roomID id.RoomID) bool { + return (*Portal)(portal).setMXIDToExistingRoom(ctx, roomID) } From 12502e213a3f5de840e1e9f26a938e9ec2586e7f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 10 Jun 2025 17:46:23 +0300 Subject: [PATCH 133/581] bridgev2/userlogin: never set client to nil --- bridgev2/userlogin.go | 47 +++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/bridgev2/userlogin.go b/bridgev2/userlogin.go index e83e66c2..2016dd59 100644 --- a/bridgev2/userlogin.go +++ b/bridgev2/userlogin.go @@ -37,6 +37,7 @@ type UserLogin struct { spaceCreateLock sync.Mutex deleteLock sync.Mutex + disconnectOnce sync.Once } func (br *Bridge) loadUserLogin(ctx context.Context, user *User, dbUserLogin *database.UserLogin) (*UserLogin, error) { @@ -514,29 +515,31 @@ func (ul *UserLogin) Disconnect() { } func (ul *UserLogin) DisconnectWithTimeout(timeout time.Duration) { - client := ul.Client - if client != nil { - ul.Client = nil - disconnected := make(chan struct{}) - go func() { - client.Disconnect() - close(disconnected) - }() + ul.disconnectOnce.Do(func() { + ul.disconnectInternal(timeout) + }) +} - var timeoutC <-chan time.Time - if timeout > 0 { - timeoutC = time.After(timeout) - } - for { - select { - case <-disconnected: - return - case <-time.After(2 * time.Second): - ul.Log.Warn().Msg("Client disconnection taking long") - case <-timeoutC: - ul.Log.Error().Msg("Client disconnection timed out") - return - } +func (ul *UserLogin) disconnectInternal(timeout time.Duration) { + disconnected := make(chan struct{}) + go func() { + ul.Client.Disconnect() + close(disconnected) + }() + + var timeoutC <-chan time.Time + if timeout > 0 { + timeoutC = time.After(timeout) + } + for { + select { + case <-disconnected: + return + case <-time.After(2 * time.Second): + ul.Log.Warn().Msg("Client disconnection taking long") + case <-timeoutC: + ul.Log.Error().Msg("Client disconnection timed out") + return } } } From 9c67d238d739bbaac7b9cd8e6854b0faf88bc1fa Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 10 Jun 2025 18:53:40 +0300 Subject: [PATCH 134/581] bridgev2/portal: check only for me flag in delete chat events --- bridgev2/portal.go | 46 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 1a57d211..62705ff5 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -2872,17 +2872,57 @@ func (portal *Portal) handleRemoteChatResync(ctx context.Context, source *UserLo } func (portal *Portal) handleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) { + log := zerolog.Ctx(ctx) if portal.Receiver == "" && evt.DeleteOnlyForMe() { - // TODO check if there are other users + logins, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) + if err != nil { + log.Err(err).Msg("Failed to check if portal has other logins") + return + } + var ownUP *database.UserPortal + logins = slices.DeleteFunc(logins, func(up *database.UserPortal) bool { + if up.LoginID == source.ID { + ownUP = up + return true + } + return false + }) + if len(logins) > 0 { + log.Debug().Msg("Not deleting portal with other logins in remote chat delete event") + if ownUP != nil { + err = portal.Bridge.DB.UserPortal.Delete(ctx, ownUP) + if err != nil { + log.Err(err).Msg("Failed to delete own user portal row from database") + } else { + log.Debug().Msg("Deleted own user portal row from database") + } + } + _, err = portal.sendStateWithIntentOrBot( + ctx, + source.User.DoublePuppet(ctx), + event.StateMember, + source.UserMXID.String(), + &event.Content{Parsed: &event.MemberEventContent{Membership: event.MembershipLeave}}, + getEventTS(evt), + ) + if err != nil { + log.Err(err).Msg("Failed to send leave state event for user after remote chat delete") + } else { + log.Debug().Msg("Sent leave state event for user after remote chat delete") + } + return + } } err := portal.Delete(ctx) if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to delete portal from database") + log.Err(err).Msg("Failed to delete portal from database") return } err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, false) if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to delete Matrix room") + log.Err(err).Msg("Failed to delete Matrix room") + } else { + log.Info().Msg("Deleted room after remote chat delete event") } } From 1038f6a73cd9122b714fdc0e6d61198ad86eb1b9 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 10 Jun 2025 19:33:02 +0300 Subject: [PATCH 135/581] bridgev2: fix more background contexts --- bridgev2/portal.go | 2 +- bridgev2/userlogin.go | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 62705ff5..6dd5711f 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -302,7 +302,7 @@ func (portal *Portal) queueEvent(ctx context.Context, evt portalEvent) { func (portal *Portal) eventLoop() { if cfg := portal.Bridge.Network.GetCapabilities().OutgoingMessageTimeouts; cfg != nil { - ctx, cancel := context.WithCancel(portal.Log.WithContext(context.Background())) + ctx, cancel := context.WithCancel(portal.Log.WithContext(portal.Bridge.BackgroundCtx)) go portal.pendingMessageTimeoutLoop(ctx, cfg) defer cancel() } diff --git a/bridgev2/userlogin.go b/bridgev2/userlogin.go index 2016dd59..05574e71 100644 --- a/bridgev2/userlogin.go +++ b/bridgev2/userlogin.go @@ -228,7 +228,8 @@ func (user *User) NewLogin(ctx context.Context, data *database.UserLogin, params } ul.BridgeState = user.Bridge.NewBridgeStateQueue(ul) } - err = params.LoadUserLogin(ul.Log.WithContext(context.Background()), ul) + noCancelCtx := ul.Log.WithContext(user.Bridge.BackgroundCtx) + err = params.LoadUserLogin(noCancelCtx, ul) if err != nil { return nil, err } else if ul.Client == nil { @@ -236,14 +237,14 @@ func (user *User) NewLogin(ctx context.Context, data *database.UserLogin, params return nil, fmt.Errorf("client not filled by LoadUserLogin") } if doInsert { - err = user.Bridge.DB.UserLogin.Insert(ctx, ul.UserLogin) + err = user.Bridge.DB.UserLogin.Insert(noCancelCtx, ul.UserLogin) if err != nil { return nil, err } user.Bridge.userLoginsByID[ul.ID] = ul user.logins[ul.ID] = ul } else { - err = ul.Save(ctx) + err = ul.Save(noCancelCtx) if err != nil { return nil, err } From 15d0b63eb6ab11a62d4b5287cc09a991716b5744 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 11 Jun 2025 15:34:34 +0300 Subject: [PATCH 136/581] bridgev2/provisioning: check for nil steps in submit and wait calls --- bridgev2/matrix/provisioning.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index e897bae8..0a11aa79 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -364,6 +364,8 @@ func (prov *ProvisioningAPI) GetLoginFlows(w http.ResponseWriter, r *http.Reques }) } +var ErrNilStep = errors.New("bridge returned nil step with no error") + func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Request) { overrideLogin, failed := prov.GetExplicitLoginForRequest(w, r) if failed { @@ -386,14 +388,13 @@ func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Reque } else { firstStep, err = login.Start(r.Context()) } + if err == nil && firstStep == nil { + err = ErrNilStep + } if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to start login") RespondWithError(w, err, "Internal error starting login") return - } else if firstStep == nil { - zerolog.Ctx(r.Context()).Error().Msg("Bridge returned nil first step in Start with no error") - RespondWithError(w, err, "Internal error starting login") - return } loginID := xid.New().String() prov.loginsLock.Lock() @@ -439,6 +440,9 @@ func (prov *ProvisioningAPI) PostLoginSubmitInput(w http.ResponseWriter, r *http default: panic("Impossible state") } + if err == nil && nextStep == nil { + err = ErrNilStep + } if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to submit input") RespondWithError(w, err, "Internal error submitting input") @@ -454,6 +458,9 @@ func (prov *ProvisioningAPI) PostLoginSubmitInput(w http.ResponseWriter, r *http func (prov *ProvisioningAPI) PostLoginWait(w http.ResponseWriter, r *http.Request) { login := r.Context().Value(provisioningLoginProcessKey).(*ProvLogin) nextStep, err := login.Process.(bridgev2.LoginProcessDisplayAndWait).Wait(r.Context()) + if err == nil && nextStep == nil { + err = ErrNilStep + } if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to wait") RespondWithError(w, err, "Internal error waiting for login") From b8921397b82f3eb24765c8bf1dd2a4c563cc73bf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 11 Jun 2025 19:10:19 +0300 Subject: [PATCH 137/581] event,requests: add MSC4293 redact events field to member events --- event/member.go | 2 ++ requests.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/event/member.go b/event/member.go index d0ff2a7c..02b7cae9 100644 --- a/event/member.go +++ b/event/member.go @@ -42,6 +42,8 @@ type MemberEventContent struct { ThirdPartyInvite *ThirdPartyInvite `json:"third_party_invite,omitempty"` Reason string `json:"reason,omitempty"` MSC3414File *EncryptedFileInfo `json:"org.matrix.msc3414.file,omitempty"` + + MSC4293RedactEvents bool `json:"org.matrix.msc4293.redact_events,omitempty"` } type ThirdPartyInvite struct { diff --git a/requests.go b/requests.go index 42d257fb..8363aeda 100644 --- a/requests.go +++ b/requests.go @@ -193,6 +193,8 @@ type ReqKickUser struct { type ReqBanUser struct { Reason string `json:"reason,omitempty"` UserID id.UserID `json:"user_id"` + + MSC4293RedactEvents bool `json:"org.matrix.msc4293.redact_events,omitempty"` } // ReqUnbanUser is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidunban From c540f30ef9ef6f3db6449475034516619d70848c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 12 Jun 2025 13:32:00 +0300 Subject: [PATCH 138/581] dependencies: update --- go.mod | 16 ++++++++-------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index ebc7a61c..1e1ea939 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module maunium.net/go/mautrix go 1.23.0 -toolchain go1.24.3 +toolchain go1.24.4 require ( filippo.io/edwards25519 v1.1.0 @@ -17,13 +17,13 @@ require ( github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 - github.com/yuin/goldmark v1.7.11 - go.mau.fi/util v0.8.7 + github.com/yuin/goldmark v1.7.12 + go.mau.fi/util v0.8.8-0.20250612103042-2aa072eb60f0 go.mau.fi/zeroconfig v0.1.3 - golang.org/x/crypto v0.38.0 - golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 - golang.org/x/net v0.40.0 - golang.org/x/sync v0.14.0 + golang.org/x/crypto v0.39.0 + golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 + golang.org/x/net v0.41.0 + golang.org/x/sync v0.15.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 ) @@ -38,6 +38,6 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/text v0.26.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index a3c7542d..357085e6 100644 --- a/go.sum +++ b/go.sum @@ -51,28 +51,28 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo= -github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.8.7 h1:ywKarPxouJQEEijTs4mPlxC7F4AWEKokEpWc+2TYy6c= -go.mau.fi/util v0.8.7/go.mod h1:j6R3cENakc1f8HpQeFl0N15UiSTcNmIfDBNJUbL71RY= +github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= +github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mau.fi/util v0.8.8-0.20250612103042-2aa072eb60f0 h1:EcDJfYWX6aVT3c6nWTg9Qly41rNKabzzERt7OFzVerA= +go.mau.fi/util v0.8.8-0.20250612103042-2aa072eb60f0/go.mod h1:Y/kS3loxTEhy8Vill513EtPXr+CRDdae+Xj2BXXMy/c= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= -golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= -golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4= +golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= From c888801751a65223817daef8840cd0a7bae02b86 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 14 Jun 2025 12:21:05 +0300 Subject: [PATCH 139/581] bridgev2/matrixinvite: allow redirecting DM creations to another user --- bridgev2/matrixinvite.go | 20 ++++++++++++++++++++ bridgev2/networkinterface.go | 3 +++ 2 files changed, 23 insertions(+) diff --git a/bridgev2/matrixinvite.go b/bridgev2/matrixinvite.go index 0f1601d1..a57c91b8 100644 --- a/bridgev2/matrixinvite.go +++ b/bridgev2/matrixinvite.go @@ -187,6 +187,26 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen message += "\n\nWarning: failed to promote bot" hasWarning = true } + if resp.DMRedirectedTo != "" && resp.DMRedirectedTo != invitedGhost.ID { + log.Debug(). + Str("dm_redirected_to_id", string(resp.DMRedirectedTo)). + Msg("Created DM was redirected to another user ID") + _, err = invitedGhost.Intent.SendState(ctx, portal.MXID, event.StateMember, invitedGhost.Intent.GetMXID().String(), &event.Content{ + Parsed: &event.MemberEventContent{ + Membership: event.MembershipLeave, + Reason: "Direct chat redirected to another internal user ID", + }, + }, time.Time{}) + if err != nil { + log.Err(err).Msg("Failed to make incorrect ghost leave new DM room") + } + otherUserGhost, err := br.GetGhostByID(ctx, resp.DMRedirectedTo) + if err != nil { + log.Err(err).Msg("Failed to get ghost of real portal other user ID") + } else { + invitedGhost = otherUserGhost + } + } if resp.PortalInfo != nil { portal.UpdateInfo(ctx, resp.PortalInfo, sourceLogin, nil, time.Time{}) } else { diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 2b99e4e6..457a7bd4 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -696,6 +696,9 @@ type CreateChatResponse struct { // Portal and PortalInfo are not required, the caller will fetch them automatically based on PortalKey if necessary. Portal *Portal PortalInfo *ChatInfo + // If a start DM request (CreateChatWithGhost or ResolveIdentifier) returns the DM to a different user, + // this field should have the user ID of said different user. + DMRedirectedTo networkid.UserID } // IdentifierResolvingNetworkAPI is an optional interface that network connectors can implement to support starting new direct chats. From 79969306e740432f8fdf58cb1b87d1f6a1862a70 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 14 Jun 2025 12:23:36 +0300 Subject: [PATCH 140/581] bridgev2/matrix: check stream upload size after writing file --- bridgev2/matrix/intent.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index 7efc1bab..f99437b3 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -393,9 +393,13 @@ func (as *ASIntent) UploadMediaStream( err = fmt.Errorf("failed to get temp file info: %w", err) return } + size = info.Size() + if size > as.Connector.MediaConfig.UploadSize { + return "", nil, fmt.Errorf("file too large (%.2f MB > %.2f MB)", float64(size)/1000/1000, float64(as.Connector.MediaConfig.UploadSize)/1000/1000) + } req := mautrix.ReqUploadMedia{ Content: replFile, - ContentLength: info.Size(), + ContentLength: size, ContentType: res.MimeType, FileName: res.FileName, } From c836dbafdfd9062a3b2a2dcba2f4e037e81e4e81 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 14 Jun 2025 12:31:03 +0300 Subject: [PATCH 141/581] bridgev2/matrixinvite: clean up old portal room if user is not a member --- bridgev2/matrixinvite.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/bridgev2/matrixinvite.go b/bridgev2/matrixinvite.go index a57c91b8..11826b40 100644 --- a/bridgev2/matrixinvite.go +++ b/bridgev2/matrixinvite.go @@ -164,6 +164,34 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen return } } + if portal.MXID != "" { + doCleanup := true + existingPortalMembers, err := br.Matrix.GetMembers(ctx, portal.MXID) + if err != nil { + log.Err(err). + Stringer("old_portal_mxid", portal.MXID). + Msg("Failed to check existing portal members, deleting room") + } else if targetUserMember, ok := existingPortalMembers[sender.MXID]; !ok { + log.Debug(). + Stringer("old_portal_mxid", portal.MXID). + Msg("Inviter has no member event in old portal, deleting room") + } else if targetUserMember.Membership.IsInviteOrJoin() { + doCleanup = false + } else { + log.Debug(). + Stringer("old_portal_mxid", portal.MXID). + Str("membership", string(targetUserMember.Membership)). + Msg("Inviter is not in old portal, deleting room") + } + + if doCleanup { + if err = portal.RemoveMXID(ctx); err != nil { + log.Err(err).Msg("Failed to delete old portal mxid") + } else if err = br.Bot.DeleteRoom(ctx, portal.MXID, true); err != nil { + log.Err(err).Msg("Failed to clean up old portal room") + } + } + } err = invitedGhost.Intent.EnsureInvited(ctx, evt.RoomID, br.Bot.GetMXID()) if err != nil { log.Err(err).Msg("Failed to ensure bot is invited to room") From 1143cfaa85bc75449811918ecc31cbfac5c92c3a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 14 Jun 2025 18:00:35 +0300 Subject: [PATCH 142/581] event: implement fallbacks for per-message profiles --- bridgev2/matrix/intent.go | 4 ++++ bridgev2/queue.go | 1 + event/beeper.go | 31 +++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index f99437b3..2088d5b1 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -58,6 +58,10 @@ func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType }) } if eventType != event.EventReaction && eventType != event.EventRedaction { + msgContent, ok := content.Parsed.(*event.MessageEventContent) + if ok { + msgContent.AddPerMessageProfileFallback() + } if encrypted, err := as.Matrix.StateStore.IsEncrypted(ctx, roomID); err != nil { return nil, fmt.Errorf("failed to check if room is encrypted: %w", err) } else if encrypted { diff --git a/bridgev2/queue.go b/bridgev2/queue.go index 3d329b22..74424290 100644 --- a/bridgev2/queue.go +++ b/bridgev2/queue.go @@ -99,6 +99,7 @@ func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) { if evt.Type == event.EventMessage && sender != nil { msg := evt.Content.AsMessage() msg.RemoveReplyFallback() + msg.RemovePerMessageProfileFallback() if strings.HasPrefix(msg.Body, br.Config.CommandPrefix) || evt.RoomID == sender.ManagementRoom { if !sender.Permissions.Commands { status := WrapErrorInStatus(errors.New("you don't have permission to use commands")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage() diff --git a/event/beeper.go b/event/beeper.go index 891204e5..a85e82fc 100644 --- a/event/beeper.go +++ b/event/beeper.go @@ -11,7 +11,10 @@ import ( "encoding/binary" "encoding/json" "fmt" + "html" + "regexp" "strconv" + "strings" "maunium.net/go/mautrix/id" ) @@ -141,6 +144,34 @@ type BeeperPerMessageProfile struct { Displayname string `json:"displayname,omitempty"` AvatarURL *id.ContentURIString `json:"avatar_url,omitempty"` AvatarFile *EncryptedFileInfo `json:"avatar_file,omitempty"` + HasFallback bool `json:"has_fallback,omitempty"` +} + +func (content *MessageEventContent) AddPerMessageProfileFallback() { + if content.BeeperPerMessageProfile == nil || content.BeeperPerMessageProfile.HasFallback || content.BeeperPerMessageProfile.Displayname == "" { + return + } + content.BeeperPerMessageProfile.HasFallback = true + content.EnsureHasHTML() + content.Body = fmt.Sprintf("%s: %s", content.BeeperPerMessageProfile.Displayname, content.Body) + content.FormattedBody = fmt.Sprintf( + "%s: %s", + html.EscapeString(content.BeeperPerMessageProfile.Displayname), + content.FormattedBody, + ) +} + +var HTMLProfileFallbackRegex = regexp.MustCompile(`([^<]+): `) + +func (content *MessageEventContent) RemovePerMessageProfileFallback() { + if content.BeeperPerMessageProfile == nil || !content.BeeperPerMessageProfile.HasFallback || content.BeeperPerMessageProfile.Displayname == "" { + return + } + content.BeeperPerMessageProfile.HasFallback = false + content.Body = strings.TrimPrefix(content.Body, content.BeeperPerMessageProfile.Displayname+": ") + if content.Format == FormatHTML { + content.FormattedBody = HTMLProfileFallbackRegex.ReplaceAllLiteralString(content.FormattedBody, "") + } } type BeeperEncodedOrder struct { From 1878700a9df6339806d4ef4c4af3c56c6d0a0bef Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 16 Jun 2025 16:42:04 +0300 Subject: [PATCH 143/581] Bump version to v0.24.1 --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ bridgev2/matrix/provisioning.go | 2 +- go.mod | 2 +- go.sum | 4 ++-- version.go | 2 +- 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f214c3..b2eefb3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,36 @@ +## v0.24.1 (2025-06-16) + +* *(commands)* Added framework for using reactions as buttons that execute + command handlers. +* *(client)* Added wrapper for `/relations` endpoints. +* *(client)* Added support for stable version of room summary endpoint. +* *(client)* Fixed parsing URL preview responses where width/height are strings. +* *(federation)* Fixed bugs in server auth. +* *(id)* Added utilities for validating server names. +* *(event)* Fixed incorrect empty `entity` field when sending hashed moderation + policy events. +* *(event)* Added [MSC4293] redact events field to member events. +* *(event)* Added support for fallbacks in [MSC4144] per-message profiles. +* *(format)* Added `MarkdownLink` and `MarkdownMention` utility functions for + generating properly escaped markdown. +* *(synapseadmin)* Added support for synchronous (v1) room delete endpoint. +* *(synapseadmin)* Changed `Client` struct to not embed the `mautrix.Client`. + This is a breaking change if you were relying on accessing non-admin functions + from the admin client. +* *(bridgev2/provisioning)* Fixed `/display_and_wait` not passing through errors + from the network connector properly. +* *(bridgev2/crypto)* Fixed encryption not working if the user's ID had the same + prefix as the bridge ghosts (e.g. `@whatsappbridgeuser:example.com` with a + `@whatsapp_` prefix). +* *(bridgev2)* Fixed portals not being saved after creating a DM portal from a + Matrix DM invite. +* *(bridgev2)* Added config option to determine whether cross-room replies + should be bridged. +* *(appservice)* Fixed `EnsureRegistered` not being called when sending a custom + member event for the controlled user. + +[MSC4293]: https://github.com/matrix-org/matrix-spec-proposals/pull/4293 + ## v0.24.0 (2025-05-16) * *(commands)* Added generic framework for implementing bot commands. diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 0a11aa79..f865a19e 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -120,7 +120,7 @@ func (prov *ProvisioningAPI) Init() { prov.Router.Use(hlog.NewHandler(prov.log)) prov.Router.Use(hlog.RequestIDHandler("request_id", "Request-Id")) prov.Router.Use(exhttp.CORSMiddleware) - prov.Router.Use(requestlog.AccessLogger(false)) + prov.Router.Use(requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true})) prov.Router.Use(prov.AuthMiddleware) prov.Router.Path("/v3/whoami").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(prov.GetWhoami) prov.Router.Path("/v3/login/flows").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(prov.GetLoginFlows) diff --git a/go.mod b/go.mod index 1e1ea939..dcc6616c 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.12 - go.mau.fi/util v0.8.8-0.20250612103042-2aa072eb60f0 + go.mau.fi/util v0.8.8 go.mau.fi/zeroconfig v0.1.3 golang.org/x/crypto v0.39.0 golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 diff --git a/go.sum b/go.sum index 357085e6..779e05db 100644 --- a/go.sum +++ b/go.sum @@ -53,8 +53,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.8.8-0.20250612103042-2aa072eb60f0 h1:EcDJfYWX6aVT3c6nWTg9Qly41rNKabzzERt7OFzVerA= -go.mau.fi/util v0.8.8-0.20250612103042-2aa072eb60f0/go.mod h1:Y/kS3loxTEhy8Vill513EtPXr+CRDdae+Xj2BXXMy/c= +go.mau.fi/util v0.8.8 h1:OnuEEc/sIJFhnq4kFggiImUpcmnmL/xpvQMRu5Fiy5c= +go.mau.fi/util v0.8.8/go.mod h1:Y/kS3loxTEhy8Vill513EtPXr+CRDdae+Xj2BXXMy/c= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= diff --git a/version.go b/version.go index 8366c5bf..193205ee 100644 --- a/version.go +++ b/version.go @@ -7,7 +7,7 @@ import ( "strings" ) -const Version = "v0.24.0" +const Version = "v0.24.1" var GoModVersion = "" var Commit = "" From 26da46dbbf6e927191bf17f75b784060126f5e09 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 17 Jun 2025 22:08:29 +0530 Subject: [PATCH 144/581] bridgev2/portal: return result of handling remote events (#389) --- bridgev2/portal.go | 362 ++++++++++++++++++++++++------------- bridgev2/portalbackfill.go | 22 ++- bridgev2/portalinternal.go | 88 ++++----- bridgev2/queue.go | 21 ++- 4 files changed, 318 insertions(+), 175 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 6dd5711f..21d8550f 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -283,19 +283,21 @@ func (br *Bridge) GetExistingPortalByKey(ctx context.Context, key networkid.Port return br.loadPortal(ctx, db, err, nil) } -func (portal *Portal) queueEvent(ctx context.Context, evt portalEvent) { +func (portal *Portal) queueEvent(ctx context.Context, evt portalEvent) EventHandlingResult { if PortalEventBuffer == 0 { portal.eventsLock.Lock() defer portal.eventsLock.Unlock() portal.eventIdx++ - portal.handleSingleEventAsync(portal.eventIdx, evt) + return portal.handleSingleEventAsync(portal.eventIdx, evt) } else { select { case portal.events <- evt: + return EventHandlingResultQueued default: zerolog.Ctx(ctx).Error(). Str("portal_id", string(portal.ID)). Msg("Portal event channel is full") + return EventHandlingResultFailed } } } @@ -313,19 +315,25 @@ func (portal *Portal) eventLoop() { } } -func (portal *Portal) handleSingleEventAsync(idx int, rawEvt any) { +func (portal *Portal) handleSingleEventAsync(idx int, rawEvt any) (outerRes EventHandlingResult) { ctx := portal.getEventCtxWithLog(rawEvt, idx) if _, isCreate := rawEvt.(*portalCreateEvent); isCreate { - portal.handleSingleEvent(ctx, rawEvt, func() {}) + portal.handleSingleEvent(ctx, rawEvt, func(res EventHandlingResult) { + outerRes = res + }) } else if portal.Bridge.Config.AsyncEvents { - go portal.handleSingleEvent(ctx, rawEvt, func() {}) + outerRes = EventHandlingResultQueued + go portal.handleSingleEvent(ctx, rawEvt, func(res EventHandlingResult) {}) } else { log := zerolog.Ctx(ctx) doneCh := make(chan struct{}) var backgrounded atomic.Bool start := time.Now() var handleDuration time.Duration - go portal.handleSingleEvent(ctx, rawEvt, func() { + // Note: this will not set the success flag if the handler times out + outerRes = EventHandlingResult{Queued: true} + go portal.handleSingleEvent(ctx, rawEvt, func(res EventHandlingResult) { + outerRes = res handleDuration = time.Since(start) close(doneCh) if backgrounded.Load() { @@ -358,6 +366,7 @@ func (portal *Portal) handleSingleEventAsync(idx int, rawEvt any) { Msg("Event handling is taking too long, continuing in background") backgrounded.Store(true) } + return } func (portal *Portal) getEventCtxWithLog(rawEvt any, idx int) context.Context { @@ -404,10 +413,11 @@ func (portal *Portal) getEventCtxWithLog(rawEvt any, idx int) context.Context { return logWith.Logger().WithContext(portal.Bridge.BackgroundCtx) } -func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCallback func()) { +func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCallback func(res EventHandlingResult)) { log := zerolog.Ctx(ctx) + var res EventHandlingResult defer func() { - doneCallback() + doneCallback(res) if err := recover(); err != nil { logEvt := log.Error() if realErr, ok := err.(error); ok { @@ -432,9 +442,11 @@ func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCal case *portalMatrixEvent: portal.handleMatrixEvent(ctx, evt.sender, evt.evt) case *portalRemoteEvent: - portal.handleRemoteEvent(ctx, evt.source, evt.evtType, evt.evt) + res = portal.handleRemoteEvent(ctx, evt.source, evt.evtType, evt.evt) case *portalCreateEvent: - evt.cb(portal.createMatrixRoomInLoop(evt.ctx, evt.source, evt.info, nil)) + err := portal.createMatrixRoomInLoop(evt.ctx, evt.source, evt.info, nil) + res.Success = err == nil + evt.cb(err) default: panic(fmt.Errorf("illegal type %T in eventLoop", evt)) } @@ -627,7 +639,7 @@ func (portal *Portal) handleMatrixReceipts(ctx context.Context, evt *event.Event for userID, receipt := range readReceipts { sender, err := portal.Bridge.GetUserByMXID(ctx, userID) if err != nil { - // TODO log + zerolog.Ctx(ctx).Err(err).Msg("Failed to get user to handle read receipt") return } portal.handleMatrixReadReceipt(ctx, sender, evtID, receipt) @@ -1752,13 +1764,13 @@ func (portal *Portal) handleMatrixRedaction(ctx context.Context, sender *UserLog portal.sendSuccessStatus(ctx, evt, 0, "") } -func (portal *Portal) handleRemoteEvent(ctx context.Context, source *UserLogin, evtType RemoteEventType, evt RemoteEvent) { +func (portal *Portal) handleRemoteEvent(ctx context.Context, source *UserLogin, evtType RemoteEventType, evt RemoteEvent) (res EventHandlingResult) { log := zerolog.Ctx(ctx) if portal.MXID == "" { mcp, ok := evt.(RemoteEventThatMayCreatePortal) if !ok || !mcp.ShouldCreatePortal() { log.Debug().Msg("Dropping event as portal doesn't exist") - return + return EventHandlingResultIgnored } infoProvider, ok := mcp.(RemoteChatResyncWithInfo) var info *ChatInfo @@ -1777,8 +1789,7 @@ func (portal *Portal) handleRemoteEvent(ctx context.Context, source *UserLogin, err = portal.createMatrixRoomInLoop(ctx, source, info, bundle) if err != nil { log.Err(err).Msg("Failed to create portal to handle event") - // TODO error - return + return EventHandlingResultFailed } if evtType == RemoteEventChatResync { log.Debug().Msg("Not handling chat resync event further as portal was created by it") @@ -1786,7 +1797,7 @@ func (portal *Portal) handleRemoteEvent(ctx context.Context, source *UserLogin, if ok { postHandler.PostHandle(ctx, portal) } - return + return EventHandlingResultSuccess } } preHandler, ok := evt.(RemotePreHandler) @@ -1798,33 +1809,33 @@ func (portal *Portal) handleRemoteEvent(ctx context.Context, source *UserLogin, case RemoteEventUnknown: log.Debug().Msg("Ignoring remote event with type unknown") case RemoteEventMessage, RemoteEventMessageUpsert: - portal.handleRemoteMessage(ctx, source, evt.(RemoteMessage)) + res = portal.handleRemoteMessage(ctx, source, evt.(RemoteMessage)) case RemoteEventEdit: - portal.handleRemoteEdit(ctx, source, evt.(RemoteEdit)) + res = portal.handleRemoteEdit(ctx, source, evt.(RemoteEdit)) case RemoteEventReaction: - portal.handleRemoteReaction(ctx, source, evt.(RemoteReaction)) + res = portal.handleRemoteReaction(ctx, source, evt.(RemoteReaction)) case RemoteEventReactionRemove: - portal.handleRemoteReactionRemove(ctx, source, evt.(RemoteReactionRemove)) + res = portal.handleRemoteReactionRemove(ctx, source, evt.(RemoteReactionRemove)) case RemoteEventReactionSync: - portal.handleRemoteReactionSync(ctx, source, evt.(RemoteReactionSync)) + res = portal.handleRemoteReactionSync(ctx, source, evt.(RemoteReactionSync)) case RemoteEventMessageRemove: - portal.handleRemoteMessageRemove(ctx, source, evt.(RemoteMessageRemove)) + res = portal.handleRemoteMessageRemove(ctx, source, evt.(RemoteMessageRemove)) case RemoteEventReadReceipt: - portal.handleRemoteReadReceipt(ctx, source, evt.(RemoteReadReceipt)) + res = portal.handleRemoteReadReceipt(ctx, source, evt.(RemoteReadReceipt)) case RemoteEventMarkUnread: - portal.handleRemoteMarkUnread(ctx, source, evt.(RemoteMarkUnread)) + res = portal.handleRemoteMarkUnread(ctx, source, evt.(RemoteMarkUnread)) case RemoteEventDeliveryReceipt: - portal.handleRemoteDeliveryReceipt(ctx, source, evt.(RemoteDeliveryReceipt)) + res = portal.handleRemoteDeliveryReceipt(ctx, source, evt.(RemoteDeliveryReceipt)) case RemoteEventTyping: - portal.handleRemoteTyping(ctx, source, evt.(RemoteTyping)) + res = portal.handleRemoteTyping(ctx, source, evt.(RemoteTyping)) case RemoteEventChatInfoChange: - portal.handleRemoteChatInfoChange(ctx, source, evt.(RemoteChatInfoChange)) + res = portal.handleRemoteChatInfoChange(ctx, source, evt.(RemoteChatInfoChange)) case RemoteEventChatResync: - portal.handleRemoteChatResync(ctx, source, evt.(RemoteChatResync)) + res = portal.handleRemoteChatResync(ctx, source, evt.(RemoteChatResync)) case RemoteEventChatDelete: - portal.handleRemoteChatDelete(ctx, source, evt.(RemoteChatDelete)) + res = portal.handleRemoteChatDelete(ctx, source, evt.(RemoteChatDelete)) case RemoteEventBackfill: - portal.handleRemoteBackfill(ctx, source, evt.(RemoteBackfill)) + res = portal.handleRemoteBackfill(ctx, source, evt.(RemoteBackfill)) default: log.Warn().Msg("Got remote event with unknown type") } @@ -1832,9 +1843,10 @@ func (portal *Portal) handleRemoteEvent(ctx context.Context, source *UserLogin, if ok { postHandler.PostHandle(ctx, portal) } + return } -func (portal *Portal) getIntentAndUserMXIDFor(ctx context.Context, sender EventSender, source *UserLogin, otherLogins []*UserLogin, evtType RemoteEventType) (intent MatrixAPI, extraUserID id.UserID) { +func (portal *Portal) getIntentAndUserMXIDFor(ctx context.Context, sender EventSender, source *UserLogin, otherLogins []*UserLogin, evtType RemoteEventType) (intent MatrixAPI, extraUserID id.UserID, err error) { var ghost *Ghost if !sender.IsFromMe && sender.ForceDMUser && portal.OtherUserID != "" && sender.Sender != portal.OtherUserID { zerolog.Ctx(ctx).Warn(). @@ -1843,21 +1855,20 @@ func (portal *Portal) getIntentAndUserMXIDFor(ctx context.Context, sender EventS Msg("Overriding event sender with primary other user in DM portal") // Ensure the ghost row exists anyway to prevent foreign key errors when saving messages // TODO it'd probably be better to override the sender in the saved message, but that's more effort - _, err := portal.Bridge.GetGhostByID(ctx, sender.Sender) + _, err = portal.Bridge.GetGhostByID(ctx, sender.Sender) if err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get ghost with original user ID") + return } sender.Sender = portal.OtherUserID } if sender.Sender != "" { - var err error ghost, err = portal.Bridge.GetGhostByID(ctx, sender.Sender) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost for message sender") return - } else { - ghost.UpdateInfoIfNecessary(ctx, source, evtType) } + ghost.UpdateInfoIfNecessary(ctx, source, evtType) } if sender.IsFromMe { intent = source.User.DoublePuppet(ctx) @@ -1892,15 +1903,21 @@ func (portal *Portal) getIntentAndUserMXIDFor(ctx context.Context, sender EventS return } -func (portal *Portal) GetIntentFor(ctx context.Context, sender EventSender, source *UserLogin, evtType RemoteEventType) MatrixAPI { - intent, _ := portal.getIntentAndUserMXIDFor(ctx, sender, source, nil, evtType) +func (portal *Portal) GetIntentFor(ctx context.Context, sender EventSender, source *UserLogin, evtType RemoteEventType) (MatrixAPI, bool) { + intent, _, err := portal.getIntentAndUserMXIDFor(ctx, sender, source, nil, evtType) + if err != nil { + return nil, false + } if intent == nil { // TODO this is very hacky - we should either insert an empty ghost row automatically // (and not fetch it at runtime) or make the message sender column nullable. portal.Bridge.GetGhostByID(ctx, "") intent = portal.Bridge.Bot + if intent == nil { + panic(fmt.Errorf("bridge bot is nil")) + } } - return intent + return intent, true } func (portal *Portal) getRelationMeta(ctx context.Context, currentMsg networkid.MessageID, replyToPtr *networkid.MessageOptionalPartID, threadRootPtr *networkid.MessageID, isBatchSend bool) (replyTo, threadRoot, prevThreadEvent *database.Message) { @@ -1982,7 +1999,7 @@ func (portal *Portal) sendConvertedMessage( ts time.Time, streamOrder int64, logContext func(*zerolog.Event) *zerolog.Event, -) []*database.Message { +) ([]*database.Message, EventHandlingResult) { if logContext == nil { logContext = func(e *zerolog.Event) *zerolog.Event { return e @@ -1991,6 +2008,7 @@ func (portal *Portal) sendConvertedMessage( log := zerolog.Ctx(ctx) replyTo, threadRoot, prevThreadEvent := portal.getRelationMeta(ctx, id, converted.ReplyTo, converted.ThreadRoot, false) output := make([]*database.Message, 0, len(converted.Parts)) + allSuccess := true for i, part := range converted.Parts { portal.applyRelationMeta(ctx, part.Content, replyTo, threadRoot, prevThreadEvent) dbMessage := &database.Message{ @@ -2023,6 +2041,7 @@ func (portal *Portal) sendConvertedMessage( }) if err != nil { logContext(log.Err(err)).Str("part_id", string(part.ID)).Msg("Failed to send message part to Matrix") + allSuccess = false continue } logContext(log.Debug()). @@ -2034,12 +2053,13 @@ func (portal *Portal) sendConvertedMessage( err := portal.Bridge.DB.Message.Insert(ctx, dbMessage) if err != nil { logContext(log.Err(err)).Str("part_id", string(part.ID)).Msg("Failed to save message part to database") + allSuccess = false } if converted.Disappear.Type != database.DisappearingTypeNone && !dbMessage.HasFakeMXID() { if converted.Disappear.Type == database.DisappearingTypeAfterSend && converted.Disappear.DisappearAt.IsZero() { converted.Disappear.DisappearAt = dbMessage.Timestamp.Add(converted.Disappear.Timer) } - go portal.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{ + portal.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{ RoomID: portal.MXID, EventID: dbMessage.MXID, DisappearingSetting: converted.Disappear, @@ -2050,7 +2070,10 @@ func (portal *Portal) sendConvertedMessage( } output = append(output, dbMessage) } - return output + if !allSuccess { + return output, EventHandlingResultFailed + } + return output, EventHandlingResultSuccess } func (portal *Portal) checkPendingMessage(ctx context.Context, evt RemoteMessage) (bool, *database.Message) { @@ -2110,21 +2133,24 @@ func (portal *Portal) checkPendingMessage(ctx context.Context, evt RemoteMessage return true, pending.db } -func (portal *Portal) handleRemoteUpsert(ctx context.Context, source *UserLogin, evt RemoteMessageUpsert, existing []*database.Message) bool { +func (portal *Portal) handleRemoteUpsert(ctx context.Context, source *UserLogin, evt RemoteMessageUpsert, existing []*database.Message) (handleRes EventHandlingResult, continueHandling bool) { log := zerolog.Ctx(ctx) - intent := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventMessageUpsert) - if intent == nil { - return false + intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventMessageUpsert) + if !ok { + return } res, err := evt.HandleExisting(ctx, portal, intent, existing) if err != nil { log.Err(err).Msg("Failed to handle existing message in upsert event after receiving remote echo") + } else { + handleRes = EventHandlingResultSuccess } if res.SaveParts { for _, part := range existing { err = portal.Bridge.DB.Message.Update(ctx, part) if err != nil { log.Err(err).Str("part_id", string(part.PartID)).Msg("Failed to update message part in database") + handleRes = EventHandlingResultFailed } } } @@ -2136,19 +2162,25 @@ func (portal *Portal) handleRemoteUpsert(ctx context.Context, source *UserLogin, Str("action", "handle remote subevent"). Stringer("bridge_evt_type", subType). Logger() - portal.handleRemoteEvent(log.WithContext(ctx), source, subType, subEvt) + subRes := portal.handleRemoteEvent(log.WithContext(ctx), source, subType, subEvt) + if !subRes.Success { + handleRes.Success = false + } } } - return res.ContinueMessageHandling + continueHandling = res.ContinueMessageHandling + return } -func (portal *Portal) handleRemoteMessage(ctx context.Context, source *UserLogin, evt RemoteMessage) { +func (portal *Portal) handleRemoteMessage(ctx context.Context, source *UserLogin, evt RemoteMessage) (res EventHandlingResult) { log := zerolog.Ctx(ctx) upsertEvt, isUpsert := evt.(RemoteMessageUpsert) isUpsert = isUpsert && evt.GetType() == RemoteEventMessageUpsert if wasPending, dbMessage := portal.checkPendingMessage(ctx, evt); wasPending { if isUpsert && dbMessage != nil { - portal.handleRemoteUpsert(ctx, source, upsertEvt, []*database.Message{dbMessage}) + res, _ = portal.handleRemoteUpsert(ctx, source, upsertEvt, []*database.Message{dbMessage}) + } else { + res = EventHandlingResultIgnored } return } @@ -2157,35 +2189,42 @@ func (portal *Portal) handleRemoteMessage(ctx context.Context, source *UserLogin log.Err(err).Msg("Failed to check if message is a duplicate") } else if len(existing) > 0 { if isUpsert { - if portal.handleRemoteUpsert(ctx, source, upsertEvt, existing) { + var continueHandling bool + res, continueHandling = portal.handleRemoteUpsert(ctx, source, upsertEvt, existing) + if continueHandling { log.Debug().Msg("Upsert handler said to continue message handling normally") } else { - return + return res } } else { log.Debug().Stringer("existing_mxid", existing[0].MXID).Msg("Ignoring duplicate message") - return + return EventHandlingResultIgnored } } - intent := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventMessage) - if intent == nil { - return + intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventMessage) + if !ok { + return EventHandlingResultFailed } ts := getEventTS(evt) converted, err := evt.ConvertMessage(ctx, portal, intent) if err != nil { if errors.Is(err, ErrIgnoringRemoteEvent) { log.Debug().Err(err).Msg("Remote message handling was cancelled by convert function") + return EventHandlingResultIgnored } else { log.Err(err).Msg("Failed to convert remote message") portal.sendRemoteErrorNotice(ctx, intent, err, ts, "message") + return EventHandlingResultFailed } - return } - portal.sendConvertedMessage(ctx, evt.GetID(), intent, evt.GetSender().Sender, converted, ts, getStreamOrder(evt), nil) + _, res = portal.sendConvertedMessage(ctx, evt.GetID(), intent, evt.GetSender().Sender, converted, ts, getStreamOrder(evt), nil) if portal.currentlyTypingGhosts.Pop(intent.GetMXID()) { - intent.MarkTyping(ctx, portal.MXID, TypingTypeText, 0) + err = intent.MarkTyping(ctx, portal.MXID, TypingTypeText, 0) + if err != nil { + log.Warn().Err(err).Msg("Failed to send stop typing event after bridging message") + } } + return } func (portal *Portal) sendRemoteErrorNotice(ctx context.Context, intent MatrixAPI, err error, ts time.Time, evtTypeName string) { @@ -2208,7 +2247,7 @@ func (portal *Portal) sendRemoteErrorNotice(ctx context.Context, intent MatrixAP } } -func (portal *Portal) handleRemoteEdit(ctx context.Context, source *UserLogin, evt RemoteEdit) { +func (portal *Portal) handleRemoteEdit(ctx context.Context, source *UserLogin, evt RemoteEdit) EventHandlingResult { log := zerolog.Ctx(ctx) var existing []*database.Message if bundledEvt, ok := evt.(RemoteEventWithBundledParts); ok { @@ -2220,37 +2259,41 @@ func (portal *Portal) handleRemoteEdit(ctx context.Context, source *UserLogin, e existing, err = portal.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, targetID) if err != nil { log.Err(err).Msg("Failed to get edit target message") - return + return EventHandlingResultFailed } } if existing == nil { log.Warn().Msg("Edit target message not found") - return + return EventHandlingResultIgnored } - intent := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventEdit) - if intent == nil { - return + intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventEdit) + if !ok { + return EventHandlingResultFailed } else if intent.GetMXID() != existing[0].SenderMXID { log.Warn(). Stringer("edit_sender_mxid", intent.GetMXID()). Stringer("original_sender_mxid", existing[0].SenderMXID). Msg("Not bridging edit: sender doesn't match original message sender") - return + return EventHandlingResultIgnored } ts := getEventTS(evt) converted, err := evt.ConvertEdit(ctx, portal, intent, existing) if errors.Is(err, ErrIgnoringRemoteEvent) { log.Debug().Err(err).Msg("Remote edit handling was cancelled by convert function") - return + return EventHandlingResultIgnored } else if err != nil { log.Err(err).Msg("Failed to convert remote edit") portal.sendRemoteErrorNotice(ctx, intent, err, ts, "edit") - return + return EventHandlingResultFailed } - portal.sendConvertedEdit(ctx, existing[0].ID, evt.GetSender().Sender, converted, intent, ts, getStreamOrder(evt)) + res := portal.sendConvertedEdit(ctx, existing[0].ID, evt.GetSender().Sender, converted, intent, ts, getStreamOrder(evt)) if portal.currentlyTypingGhosts.Pop(intent.GetMXID()) { - intent.MarkTyping(ctx, portal.MXID, TypingTypeText, 0) + err = intent.MarkTyping(ctx, portal.MXID, TypingTypeText, 0) + if err != nil { + log.Warn().Err(err).Msg("Failed to send stop typing event after bridging edit") + } } + return res } func (portal *Portal) sendConvertedEdit( @@ -2261,8 +2304,9 @@ func (portal *Portal) sendConvertedEdit( intent MatrixAPI, ts time.Time, streamOrder int64, -) { +) EventHandlingResult { log := zerolog.Ctx(ctx) + allSuccess := true for i, part := range converted.ModifiedParts { if part.Content.Mentions == nil { part.Content.Mentions = &event.Mentions{} @@ -2298,6 +2342,7 @@ func (portal *Portal) sendConvertedEdit( }) if err != nil { log.Err(err).Stringer("part_mxid", part.Part.MXID).Msg("Failed to edit message part") + allSuccess = false continue } else { log.Debug(). @@ -2312,6 +2357,7 @@ func (portal *Portal) sendConvertedEdit( err := portal.Bridge.DB.Message.Update(ctx, part.Part) if err != nil { log.Err(err).Int64("part_rowid", part.Part.RowID).Msg("Failed to update message part in database") + allSuccess = false } } for _, part := range converted.DeletedParts { @@ -2325,6 +2371,7 @@ func (portal *Portal) sendConvertedEdit( }) if err != nil { log.Err(err).Stringer("part_mxid", part.MXID).Msg("Failed to redact message part deleted in edit") + allSuccess = false } else { log.Debug(). Stringer("redaction_event_id", resp.EventID). @@ -2335,11 +2382,19 @@ func (portal *Portal) sendConvertedEdit( err = portal.Bridge.DB.Message.Delete(ctx, part.RowID) if err != nil { log.Err(err).Int64("part_rowid", part.RowID).Msg("Failed to delete message part from database") + allSuccess = false } } if converted.AddedParts != nil { - portal.sendConvertedMessage(ctx, targetID, intent, senderID, converted.AddedParts, ts, streamOrder, nil) + _, res := portal.sendConvertedMessage(ctx, targetID, intent, senderID, converted.AddedParts, ts, streamOrder, nil) + if !res.Success { + allSuccess = false + } } + if !allSuccess { + return EventHandlingResultFailed + } + return EventHandlingResultSuccess } func (portal *Portal) getTargetMessagePart(ctx context.Context, evt RemoteEventWithTargetMessage) (*database.Message, error) { @@ -2372,17 +2427,17 @@ func getStreamOrder(evt RemoteEvent) int64 { return 0 } -func (portal *Portal) handleRemoteReactionSync(ctx context.Context, source *UserLogin, evt RemoteReactionSync) { +func (portal *Portal) handleRemoteReactionSync(ctx context.Context, source *UserLogin, evt RemoteReactionSync) EventHandlingResult { log := zerolog.Ctx(ctx) eventTS := getEventTS(evt) targetMessage, err := portal.getTargetMessagePart(ctx, evt) if err != nil { log.Err(err).Msg("Failed to get target message for reaction") - return + return EventHandlingResultFailed } else if targetMessage == nil { // TODO use deterministic event ID as target if applicable? log.Warn().Msg("Target message for reaction not found") - return + return EventHandlingResultIgnored } var existingReactions []*database.Reaction if partTargeter, ok := evt.(RemoteEventWithTargetPart); ok { @@ -2390,6 +2445,10 @@ func (portal *Portal) handleRemoteReactionSync(ctx context.Context, source *User } else { existingReactions, err = portal.Bridge.DB.Reaction.GetAllToMessage(ctx, portal.Receiver, evt.GetTargetMessage()) } + if err != nil { + log.Err(err).Msg("Failed to get existing reactions for reaction sync") + return EventHandlingResultFailed + } existing := make(map[networkid.UserID]map[networkid.EmojiID]*database.Reaction) for _, existingReaction := range existingReactions { if existing[existingReaction.SenderID] == nil { @@ -2398,9 +2457,13 @@ func (portal *Portal) handleRemoteReactionSync(ctx context.Context, source *User existing[existingReaction.SenderID][existingReaction.EmojiID] = existingReaction } - doAddReaction := func(new *BackfillReaction, intent MatrixAPI) MatrixAPI { + doAddReaction := func(new *BackfillReaction, intent MatrixAPI) { if intent == nil { - intent = portal.GetIntentFor(ctx, new.Sender, source, RemoteEventReactionSync) + var ok bool + intent, ok = portal.GetIntentFor(ctx, new.Sender, source, RemoteEventReactionSync) + if !ok { + return + } } portal.sendConvertedReaction( ctx, new.Sender.Sender, intent, targetMessage, new.EmojiID, new.Emoji, @@ -2411,7 +2474,6 @@ func (portal *Portal) handleRemoteReactionSync(ctx context.Context, source *User Time("reaction_ts", new.Timestamp) }, ) - return intent } doRemoveReaction := func(old *database.Reaction, intent MatrixAPI, deleteRow bool) { if intent == nil && old.SenderMXID != "" { @@ -2445,7 +2507,10 @@ func (portal *Portal) handleRemoteReactionSync(ctx context.Context, source *User } } doOverwriteReaction := func(new *BackfillReaction, old *database.Reaction) { - intent := portal.GetIntentFor(ctx, new.Sender, source, RemoteEventReactionSync) + intent, ok := portal.GetIntentFor(ctx, new.Sender, source, RemoteEventReactionSync) + if !ok { + return + } doRemoveReaction(old, intent, false) doAddReaction(new, intent) } @@ -2496,30 +2561,34 @@ func (portal *Portal) handleRemoteReactionSync(ctx context.Context, source *User } } } + return EventHandlingResultSuccess } -func (portal *Portal) handleRemoteReaction(ctx context.Context, source *UserLogin, evt RemoteReaction) { +func (portal *Portal) handleRemoteReaction(ctx context.Context, source *UserLogin, evt RemoteReaction) EventHandlingResult { log := zerolog.Ctx(ctx) targetMessage, err := portal.getTargetMessagePart(ctx, evt) if err != nil { log.Err(err).Msg("Failed to get target message for reaction") - return + return EventHandlingResultFailed } else if targetMessage == nil { // TODO use deterministic event ID as target if applicable? log.Warn().Msg("Target message for reaction not found") - return + return EventHandlingResultIgnored } emoji, emojiID := evt.GetReactionEmoji() existingReaction, err := portal.Bridge.DB.Reaction.GetByID(ctx, portal.Receiver, targetMessage.ID, targetMessage.PartID, evt.GetSender().Sender, emojiID) if err != nil { log.Err(err).Msg("Failed to check if reaction is a duplicate") - return + return EventHandlingResultFailed } else if existingReaction != nil && (emojiID != "" || existingReaction.Emoji == emoji) { log.Debug().Msg("Ignoring duplicate reaction") - return + return EventHandlingResultIgnored } ts := getEventTS(evt) - intent := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventReaction) + intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventReaction) + if !ok { + return EventHandlingResultFailed + } var extra map[string]any if extraContentProvider, ok := evt.(RemoteReactionWithExtraContent); ok { extra = extraContentProvider.GetReactionExtraContent() @@ -2538,14 +2607,14 @@ func (portal *Portal) handleRemoteReaction(ctx context.Context, source *UserLogi log.Err(err).Msg("Failed to redact old reaction") } } - portal.sendConvertedReaction(ctx, evt.GetSender().Sender, intent, targetMessage, emojiID, emoji, ts, dbMetadata, extra, nil) + return portal.sendConvertedReaction(ctx, evt.GetSender().Sender, intent, targetMessage, emojiID, emoji, ts, dbMetadata, extra, nil) } func (portal *Portal) sendConvertedReaction( ctx context.Context, senderID networkid.UserID, intent MatrixAPI, targetMessage *database.Message, emojiID networkid.EmojiID, emoji string, ts time.Time, dbMetadata any, extraContent map[string]any, logContext func(*zerolog.Event) *zerolog.Event, -) { +) EventHandlingResult { if logContext == nil { logContext = func(e *zerolog.Event) *zerolog.Event { return e @@ -2580,7 +2649,7 @@ func (portal *Portal) sendConvertedReaction( }) if err != nil { logContext(log.Err(err)).Msg("Failed to send reaction to Matrix") - return + return EventHandlingResultFailed } logContext(log.Debug()). Stringer("event_id", resp.EventID). @@ -2589,7 +2658,9 @@ func (portal *Portal) sendConvertedReaction( err = portal.Bridge.DB.Reaction.Upsert(ctx, dbReaction) if err != nil { logContext(log.Err(err)).Msg("Failed to save reaction to database") + return EventHandlingResultFailed } + return EventHandlingResultSuccess } func (portal *Portal) getIntentForMXID(ctx context.Context, userID id.UserID) (MatrixAPI, error) { @@ -2608,22 +2679,26 @@ func (portal *Portal) getIntentForMXID(ctx context.Context, userID id.UserID) (M } } -func (portal *Portal) handleRemoteReactionRemove(ctx context.Context, source *UserLogin, evt RemoteReactionRemove) { +func (portal *Portal) handleRemoteReactionRemove(ctx context.Context, source *UserLogin, evt RemoteReactionRemove) EventHandlingResult { log := zerolog.Ctx(ctx) targetReaction, err := portal.getTargetReaction(ctx, evt) if err != nil { log.Err(err).Msg("Failed to get target reaction for removal") - return + return EventHandlingResultFailed } else if targetReaction == nil { log.Warn().Msg("Target reaction not found") - return + return EventHandlingResultIgnored } intent, err := portal.getIntentForMXID(ctx, targetReaction.SenderMXID) if err != nil { log.Err(err).Stringer("sender_mxid", targetReaction.SenderMXID).Msg("Failed to get intent for removing reaction") } if intent == nil { - intent = portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventReactionRemove) + var ok bool + intent, ok = portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventReactionRemove) + if !ok { + return EventHandlingResultFailed + } } ts := getEventTS(evt) _, err = intent.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ @@ -2633,22 +2708,24 @@ func (portal *Portal) handleRemoteReactionRemove(ctx context.Context, source *Us }, &MatrixSendExtra{Timestamp: ts, ReactionMeta: targetReaction}) if err != nil { log.Err(err).Stringer("reaction_mxid", targetReaction.MXID).Msg("Failed to redact reaction") + return EventHandlingResultFailed } err = portal.Bridge.DB.Reaction.Delete(ctx, targetReaction) if err != nil { log.Err(err).Msg("Failed to delete target reaction from database") } + return EventHandlingResultSuccess } -func (portal *Portal) handleRemoteMessageRemove(ctx context.Context, source *UserLogin, evt RemoteMessageRemove) { +func (portal *Portal) handleRemoteMessageRemove(ctx context.Context, source *UserLogin, evt RemoteMessageRemove) EventHandlingResult { log := zerolog.Ctx(ctx) targetParts, err := portal.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, evt.GetTargetMessage()) if err != nil { log.Err(err).Msg("Failed to get target message for removal") - return + return EventHandlingResultFailed } else if len(targetParts) == 0 { log.Debug().Msg("Target message not found") - return + return EventHandlingResultIgnored } onlyForMeProvider, ok := evt.(RemoteDeleteOnlyForMe) onlyForMe := ok && onlyForMeProvider.DeleteOnlyForMe() @@ -2656,7 +2733,10 @@ func (portal *Portal) handleRemoteMessageRemove(ctx context.Context, source *Use // TODO check if there are other user logins before deleting } - intent := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventMessageRemove) + intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventMessageRemove) + if !ok { + return EventHandlingResultFailed + } if intent == portal.Bridge.Bot && len(targetParts) > 0 { senderIntent, err := portal.getIntentForMXID(ctx, targetParts[0].SenderMXID) if err != nil { @@ -2665,15 +2745,17 @@ func (portal *Portal) handleRemoteMessageRemove(ctx context.Context, source *Use intent = senderIntent } } - portal.redactMessageParts(ctx, targetParts, intent, getEventTS(evt)) + res := portal.redactMessageParts(ctx, targetParts, intent, getEventTS(evt)) err = portal.Bridge.DB.Message.DeleteAllParts(ctx, portal.Receiver, evt.GetTargetMessage()) if err != nil { log.Err(err).Msg("Failed to delete target message from database") } + return res } -func (portal *Portal) redactMessageParts(ctx context.Context, parts []*database.Message, intent MatrixAPI, ts time.Time) { +func (portal *Portal) redactMessageParts(ctx context.Context, parts []*database.Message, intent MatrixAPI, ts time.Time) EventHandlingResult { log := zerolog.Ctx(ctx) + var anyFailed bool for _, part := range parts { if part.HasFakeMXID() { continue @@ -2685,6 +2767,7 @@ func (portal *Portal) redactMessageParts(ctx context.Context, parts []*database. }, &MatrixSendExtra{Timestamp: ts, MessageMeta: part}) if err != nil { log.Err(err).Stringer("part_mxid", part.MXID).Msg("Failed to redact message part") + anyFailed = true } else { log.Debug(). Stringer("redaction_event_id", resp.EventID). @@ -2693,9 +2776,13 @@ func (portal *Portal) redactMessageParts(ctx context.Context, parts []*database. Msg("Sent redaction of message part to Matrix") } } + if anyFailed { + return EventHandlingResultFailed + } + return EventHandlingResultSuccess } -func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserLogin, evt RemoteReadReceipt) { +func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserLogin, evt RemoteReadReceipt) EventHandlingResult { log := zerolog.Ctx(ctx) var err error var lastTarget *database.Message @@ -2705,7 +2792,7 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL if err != nil { log.Err(err).Str("last_target_id", string(lastTargetID)). Msg("Failed to get last target message for read receipt") - return + return EventHandlingResultFailed } else if lastTarget == nil { log.Debug().Str("last_target_id", string(lastTargetID)). Msg("Last target message not found") @@ -2724,7 +2811,7 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL if err != nil { log.Err(err).Str("target_id", string(targetID)). Msg("Failed to get target message for read receipt") - return + return EventHandlingResultFailed } else if target != nil && !target.HasFakeMXID() && (lastTarget == nil || target.Timestamp.After(lastTarget.Timestamp)) { lastTarget = target } @@ -2737,14 +2824,17 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL } } sender := evt.GetSender() - intent := portal.GetIntentFor(ctx, sender, source, RemoteEventReadReceipt) + intent, ok := portal.GetIntentFor(ctx, sender, source, RemoteEventReadReceipt) + if !ok { + return EventHandlingResultFailed + } var addTargetLog func(evt *zerolog.Event) *zerolog.Event if lastTarget == nil { sevt, evtOK := evt.(RemoteReadReceiptWithStreamOrder) soIntent, soIntentOK := intent.(StreamOrderReadingMatrixAPI) if !evtOK || !soIntentOK || sevt.GetReadUpToStreamOrder() == 0 { log.Warn().Msg("No target message found for read receipt") - return + return EventHandlingResultIgnored } targetStreamOrder := sevt.GetReadUpToStreamOrder() addTargetLog = func(evt *zerolog.Event) *zerolog.Event { @@ -2759,40 +2849,47 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL } if err != nil { addTargetLog(log.Err(err)).Msg("Failed to bridge read receipt") + return EventHandlingResultFailed } else { addTargetLog(log.Debug()).Msg("Bridged read receipt") } if sender.IsFromMe { portal.Bridge.DisappearLoop.StartAll(ctx, portal.MXID) } + return EventHandlingResultSuccess } -func (portal *Portal) handleRemoteMarkUnread(ctx context.Context, source *UserLogin, evt RemoteMarkUnread) { +func (portal *Portal) handleRemoteMarkUnread(ctx context.Context, source *UserLogin, evt RemoteMarkUnread) EventHandlingResult { if !evt.GetSender().IsFromMe { zerolog.Ctx(ctx).Warn().Msg("Ignoring mark unread event from non-self user") - return + return EventHandlingResultIgnored } dp := source.User.DoublePuppet(ctx) if dp == nil { - return + return EventHandlingResultIgnored } err := dp.MarkUnread(ctx, portal.MXID, evt.GetUnread()) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to bridge mark unread event") + return EventHandlingResultFailed } + return EventHandlingResultSuccess } -func (portal *Portal) handleRemoteDeliveryReceipt(ctx context.Context, source *UserLogin, evt RemoteDeliveryReceipt) { +func (portal *Portal) handleRemoteDeliveryReceipt(ctx context.Context, source *UserLogin, evt RemoteDeliveryReceipt) EventHandlingResult { if portal.RoomType != database.RoomTypeDM || evt.GetSender().Sender != portal.OtherUserID { - return + return EventHandlingResultIgnored + } + intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventDeliveryReceipt) + if !ok { + return EventHandlingResultFailed } - intent := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventDeliveryReceipt) log := zerolog.Ctx(ctx) for _, target := range evt.GetReceiptTargets() { targetParts, err := portal.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, target) if err != nil { log.Err(err).Str("target_id", string(target)).Msg("Failed to get target message for delivery receipt") - continue + return EventHandlingResultFailed } else if len(targetParts) == 0 { continue } else if _, sentByGhost := portal.Bridge.Matrix.ParseGhostMXID(targetParts[0].SenderMXID); sentByGhost { @@ -2811,36 +2908,43 @@ func (portal *Portal) handleRemoteDeliveryReceipt(ctx context.Context, source *U }) } } + return EventHandlingResultSuccess } -func (portal *Portal) handleRemoteTyping(ctx context.Context, source *UserLogin, evt RemoteTyping) { +func (portal *Portal) handleRemoteTyping(ctx context.Context, source *UserLogin, evt RemoteTyping) EventHandlingResult { var typingType TypingType if typedEvt, ok := evt.(RemoteTypingWithType); ok { typingType = typedEvt.GetTypingType() } - intent := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventTyping) + intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventTyping) + if !ok { + return EventHandlingResultFailed + } timeout := evt.GetTimeout() err := intent.MarkTyping(ctx, portal.MXID, typingType, timeout) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to bridge typing event") + return EventHandlingResultFailed } if timeout == 0 { portal.currentlyTypingGhosts.Remove(intent.GetMXID()) } else { portal.currentlyTypingGhosts.Add(intent.GetMXID()) } + return EventHandlingResultSuccess } -func (portal *Portal) handleRemoteChatInfoChange(ctx context.Context, source *UserLogin, evt RemoteChatInfoChange) { +func (portal *Portal) handleRemoteChatInfoChange(ctx context.Context, source *UserLogin, evt RemoteChatInfoChange) EventHandlingResult { info, err := evt.GetChatInfoChange(ctx) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to get chat info change") - return + return EventHandlingResultFailed } portal.ProcessChatInfoChange(ctx, evt.GetSender(), source, info, getEventTS(evt)) + return EventHandlingResultSuccess } -func (portal *Portal) handleRemoteChatResync(ctx context.Context, source *UserLogin, evt RemoteChatResync) { +func (portal *Portal) handleRemoteChatResync(ctx context.Context, source *UserLogin, evt RemoteChatResync) EventHandlingResult { log := zerolog.Ctx(ctx) infoProvider, ok := evt.(RemoteChatResyncWithInfo) if ok { @@ -2869,15 +2973,16 @@ func (portal *Portal) handleRemoteChatResync(ctx context.Context, source *UserLo portal.doForwardBackfill(ctx, source, latestMessage, bundle) } } + return EventHandlingResultSuccess } -func (portal *Portal) handleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) { +func (portal *Portal) handleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) EventHandlingResult { log := zerolog.Ctx(ctx) if portal.Receiver == "" && evt.DeleteOnlyForMe() { logins, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) if err != nil { log.Err(err).Msg("Failed to check if portal has other logins") - return + return EventHandlingResultFailed } var ownUP *database.UserPortal logins = slices.DeleteFunc(logins, func(up *database.UserPortal) bool { @@ -2907,31 +3012,35 @@ func (portal *Portal) handleRemoteChatDelete(ctx context.Context, source *UserLo ) if err != nil { log.Err(err).Msg("Failed to send leave state event for user after remote chat delete") + return EventHandlingResultFailed } else { log.Debug().Msg("Sent leave state event for user after remote chat delete") + return EventHandlingResultSuccess } - return } } err := portal.Delete(ctx) if err != nil { log.Err(err).Msg("Failed to delete portal from database") - return + return EventHandlingResultFailed } err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, false) if err != nil { log.Err(err).Msg("Failed to delete Matrix room") + return EventHandlingResultFailed } else { log.Info().Msg("Deleted room after remote chat delete event") + return EventHandlingResultSuccess } } -func (portal *Portal) handleRemoteBackfill(ctx context.Context, source *UserLogin, backfill RemoteBackfill) { +func (portal *Portal) handleRemoteBackfill(ctx context.Context, source *UserLogin, backfill RemoteBackfill) (res EventHandlingResult) { //data, err := backfill.GetBackfillData(ctx, portal) //if err != nil { // zerolog.Ctx(ctx).Err(err).Msg("Failed to get backfill data") // return //} + return } type ChatInfoChange struct { @@ -2944,7 +3053,10 @@ type ChatInfoChange struct { } func (portal *Portal) ProcessChatInfoChange(ctx context.Context, sender EventSender, source *UserLogin, change *ChatInfoChange, ts time.Time) { - intent := portal.GetIntentFor(ctx, sender, source, RemoteEventChatInfoChange) + intent, ok := portal.GetIntentFor(ctx, sender, source, RemoteEventChatInfoChange) + if !ok { + return + } if change.ChatInfo != nil { portal.UpdateInfo(ctx, change.ChatInfo, source, intent, ts) } @@ -3339,7 +3451,10 @@ func (portal *Portal) getInitialMemberList(ctx context.Context, members *ChatMem ghost.UpdateInfo(ctx, member.UserInfo) } } - intent, extraUserID := portal.getIntentAndUserMXIDFor(ctx, member.EventSender, source, loginsInPortal, 0) + intent, extraUserID, err := portal.getIntentAndUserMXIDFor(ctx, member.EventSender, source, loginsInPortal, 0) + if err != nil { + return nil, nil, err + } if extraUserID != "" { invite = append(invite, extraUserID) if member.PowerLevel != nil { @@ -3535,7 +3650,10 @@ func (portal *Portal) syncParticipants( ghost.UpdateInfo(ctx, member.UserInfo) } } - intent, extraUserID := portal.getIntentAndUserMXIDFor(ctx, member.EventSender, source, loginsInPortal, 0) + intent, extraUserID, err := portal.getIntentAndUserMXIDFor(ctx, member.EventSender, source, loginsInPortal, 0) + if err != nil { + return err + } if intent != nil { syncIntent(intent, member) } diff --git a/bridgev2/portalbackfill.go b/bridgev2/portalbackfill.go index 3953a043..74b75df2 100644 --- a/bridgev2/portalbackfill.go +++ b/bridgev2/portalbackfill.go @@ -323,7 +323,10 @@ func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin if len(msg.Parts) == 0 { return } - intent := portal.GetIntentFor(ctx, msg.Sender, source, RemoteEventMessage) + intent, ok := portal.GetIntentFor(ctx, msg.Sender, source, RemoteEventMessage) + if !ok { + return + } replyTo, threadRoot, prevThreadEvent := portal.getRelationMeta(ctx, msg.ID, msg.ReplyTo, msg.ThreadRoot, true) if threadRoot != nil && out.PrevThreadEvents[*msg.ThreadRoot] != "" { prevThreadEvent.MXID = out.PrevThreadEvents[*msg.ThreadRoot] @@ -387,7 +390,10 @@ func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin } slices.Sort(partIDs) for _, reaction := range msg.Reactions { - reactionIntent := portal.GetIntentFor(ctx, reaction.Sender, source, RemoteEventReactionRemove) + reactionIntent, ok := portal.GetIntentFor(ctx, reaction.Sender, source, RemoteEventReactionRemove) + if !ok { + continue + } if reaction.TargetPart == nil { reaction.TargetPart = &partIDs[0] } @@ -513,8 +519,11 @@ func (portal *Portal) sendBatch(ctx context.Context, source *UserLogin, messages func (portal *Portal) sendLegacyBackfill(ctx context.Context, source *UserLogin, messages []*BackfillMessage, markRead bool) { var lastPart id.EventID for _, msg := range messages { - intent := portal.GetIntentFor(ctx, msg.Sender, source, RemoteEventMessage) - dbMessages := portal.sendConvertedMessage(ctx, msg.ID, intent, msg.Sender.Sender, msg.ConvertedMessage, msg.Timestamp, msg.StreamOrder, func(z *zerolog.Event) *zerolog.Event { + intent, ok := portal.GetIntentFor(ctx, msg.Sender, source, RemoteEventMessage) + if !ok { + continue + } + dbMessages, _ := portal.sendConvertedMessage(ctx, msg.ID, intent, msg.Sender.Sender, msg.ConvertedMessage, msg.Timestamp, msg.StreamOrder, func(z *zerolog.Event) *zerolog.Event { return z. Str("message_id", string(msg.ID)). Any("sender_id", msg.Sender). @@ -523,7 +532,10 @@ func (portal *Portal) sendLegacyBackfill(ctx context.Context, source *UserLogin, if len(dbMessages) > 0 { lastPart = dbMessages[len(dbMessages)-1].MXID for _, reaction := range msg.Reactions { - reactionIntent := portal.GetIntentFor(ctx, reaction.Sender, source, RemoteEventReaction) + reactionIntent, ok := portal.GetIntentFor(ctx, reaction.Sender, source, RemoteEventReaction) + if !ok { + continue + } targetPart := dbMessages[0] if reaction.TargetPart != nil { targetPartIdx := slices.IndexFunc(dbMessages, func(dbMsg *database.Message) bool { diff --git a/bridgev2/portalinternal.go b/bridgev2/portalinternal.go index bde0b170..2b25f0cf 100644 --- a/bridgev2/portalinternal.go +++ b/bridgev2/portalinternal.go @@ -29,23 +29,23 @@ func (portal *PortalInternals) UpdateLogger() { (*Portal)(portal).updateLogger() } -func (portal *PortalInternals) QueueEvent(ctx context.Context, evt portalEvent) { - (*Portal)(portal).queueEvent(ctx, evt) +func (portal *PortalInternals) QueueEvent(ctx context.Context, evt portalEvent) EventHandlingResult { + return (*Portal)(portal).queueEvent(ctx, evt) } func (portal *PortalInternals) EventLoop() { (*Portal)(portal).eventLoop() } -func (portal *PortalInternals) HandleSingleEventAsync(idx int, rawEvt any) { - (*Portal)(portal).handleSingleEventAsync(idx, rawEvt) +func (portal *PortalInternals) HandleSingleEventAsync(idx int, rawEvt any) (outerRes EventHandlingResult) { + return (*Portal)(portal).handleSingleEventAsync(idx, rawEvt) } func (portal *PortalInternals) GetEventCtxWithLog(rawEvt any, idx int) context.Context { return (*Portal)(portal).getEventCtxWithLog(rawEvt, idx) } -func (portal *PortalInternals) HandleSingleEvent(ctx context.Context, rawEvt any, doneCallback func()) { +func (portal *PortalInternals) HandleSingleEvent(ctx context.Context, rawEvt any, doneCallback func(EventHandlingResult)) { (*Portal)(portal).handleSingleEvent(ctx, rawEvt, doneCallback) } @@ -129,11 +129,11 @@ func (portal *PortalInternals) HandleMatrixRedaction(ctx context.Context, sender (*Portal)(portal).handleMatrixRedaction(ctx, sender, origSender, evt) } -func (portal *PortalInternals) HandleRemoteEvent(ctx context.Context, source *UserLogin, evtType RemoteEventType, evt RemoteEvent) { - (*Portal)(portal).handleRemoteEvent(ctx, source, evtType, evt) +func (portal *PortalInternals) HandleRemoteEvent(ctx context.Context, source *UserLogin, evtType RemoteEventType, evt RemoteEvent) (res EventHandlingResult) { + return (*Portal)(portal).handleRemoteEvent(ctx, source, evtType, evt) } -func (portal *PortalInternals) GetIntentAndUserMXIDFor(ctx context.Context, sender EventSender, source *UserLogin, otherLogins []*UserLogin, evtType RemoteEventType) (intent MatrixAPI, extraUserID id.UserID) { +func (portal *PortalInternals) GetIntentAndUserMXIDFor(ctx context.Context, sender EventSender, source *UserLogin, otherLogins []*UserLogin, evtType RemoteEventType) (intent MatrixAPI, extraUserID id.UserID, err error) { return (*Portal)(portal).getIntentAndUserMXIDFor(ctx, sender, source, otherLogins, evtType) } @@ -145,7 +145,7 @@ func (portal *PortalInternals) ApplyRelationMeta(ctx context.Context, content *e (*Portal)(portal).applyRelationMeta(ctx, content, replyTo, threadRoot, prevThreadEvent) } -func (portal *PortalInternals) SendConvertedMessage(ctx context.Context, id networkid.MessageID, intent MatrixAPI, senderID networkid.UserID, converted *ConvertedMessage, ts time.Time, streamOrder int64, logContext func(*zerolog.Event) *zerolog.Event) []*database.Message { +func (portal *PortalInternals) SendConvertedMessage(ctx context.Context, id networkid.MessageID, intent MatrixAPI, senderID networkid.UserID, converted *ConvertedMessage, ts time.Time, streamOrder int64, logContext func(*zerolog.Event) *zerolog.Event) ([]*database.Message, EventHandlingResult) { return (*Portal)(portal).sendConvertedMessage(ctx, id, intent, senderID, converted, ts, streamOrder, logContext) } @@ -153,24 +153,24 @@ func (portal *PortalInternals) CheckPendingMessage(ctx context.Context, evt Remo return (*Portal)(portal).checkPendingMessage(ctx, evt) } -func (portal *PortalInternals) HandleRemoteUpsert(ctx context.Context, source *UserLogin, evt RemoteMessageUpsert, existing []*database.Message) bool { +func (portal *PortalInternals) HandleRemoteUpsert(ctx context.Context, source *UserLogin, evt RemoteMessageUpsert, existing []*database.Message) (handleRes EventHandlingResult, continueHandling bool) { return (*Portal)(portal).handleRemoteUpsert(ctx, source, evt, existing) } -func (portal *PortalInternals) HandleRemoteMessage(ctx context.Context, source *UserLogin, evt RemoteMessage) { - (*Portal)(portal).handleRemoteMessage(ctx, source, evt) +func (portal *PortalInternals) HandleRemoteMessage(ctx context.Context, source *UserLogin, evt RemoteMessage) (res EventHandlingResult) { + return (*Portal)(portal).handleRemoteMessage(ctx, source, evt) } func (portal *PortalInternals) SendRemoteErrorNotice(ctx context.Context, intent MatrixAPI, err error, ts time.Time, evtTypeName string) { (*Portal)(portal).sendRemoteErrorNotice(ctx, intent, err, ts, evtTypeName) } -func (portal *PortalInternals) HandleRemoteEdit(ctx context.Context, source *UserLogin, evt RemoteEdit) { - (*Portal)(portal).handleRemoteEdit(ctx, source, evt) +func (portal *PortalInternals) HandleRemoteEdit(ctx context.Context, source *UserLogin, evt RemoteEdit) EventHandlingResult { + return (*Portal)(portal).handleRemoteEdit(ctx, source, evt) } -func (portal *PortalInternals) SendConvertedEdit(ctx context.Context, targetID networkid.MessageID, senderID networkid.UserID, converted *ConvertedEdit, intent MatrixAPI, ts time.Time, streamOrder int64) { - (*Portal)(portal).sendConvertedEdit(ctx, targetID, senderID, converted, intent, ts, streamOrder) +func (portal *PortalInternals) SendConvertedEdit(ctx context.Context, targetID networkid.MessageID, senderID networkid.UserID, converted *ConvertedEdit, intent MatrixAPI, ts time.Time, streamOrder int64) EventHandlingResult { + return (*Portal)(portal).sendConvertedEdit(ctx, targetID, senderID, converted, intent, ts, streamOrder) } func (portal *PortalInternals) GetTargetMessagePart(ctx context.Context, evt RemoteEventWithTargetMessage) (*database.Message, error) { @@ -181,64 +181,64 @@ func (portal *PortalInternals) GetTargetReaction(ctx context.Context, evt Remote return (*Portal)(portal).getTargetReaction(ctx, evt) } -func (portal *PortalInternals) HandleRemoteReactionSync(ctx context.Context, source *UserLogin, evt RemoteReactionSync) { - (*Portal)(portal).handleRemoteReactionSync(ctx, source, evt) +func (portal *PortalInternals) HandleRemoteReactionSync(ctx context.Context, source *UserLogin, evt RemoteReactionSync) EventHandlingResult { + return (*Portal)(portal).handleRemoteReactionSync(ctx, source, evt) } -func (portal *PortalInternals) HandleRemoteReaction(ctx context.Context, source *UserLogin, evt RemoteReaction) { - (*Portal)(portal).handleRemoteReaction(ctx, source, evt) +func (portal *PortalInternals) HandleRemoteReaction(ctx context.Context, source *UserLogin, evt RemoteReaction) EventHandlingResult { + return (*Portal)(portal).handleRemoteReaction(ctx, source, evt) } -func (portal *PortalInternals) SendConvertedReaction(ctx context.Context, senderID networkid.UserID, intent MatrixAPI, targetMessage *database.Message, emojiID networkid.EmojiID, emoji string, ts time.Time, dbMetadata any, extraContent map[string]any, logContext func(*zerolog.Event) *zerolog.Event) { - (*Portal)(portal).sendConvertedReaction(ctx, senderID, intent, targetMessage, emojiID, emoji, ts, dbMetadata, extraContent, logContext) +func (portal *PortalInternals) SendConvertedReaction(ctx context.Context, senderID networkid.UserID, intent MatrixAPI, targetMessage *database.Message, emojiID networkid.EmojiID, emoji string, ts time.Time, dbMetadata any, extraContent map[string]any, logContext func(*zerolog.Event) *zerolog.Event) EventHandlingResult { + return (*Portal)(portal).sendConvertedReaction(ctx, senderID, intent, targetMessage, emojiID, emoji, ts, dbMetadata, extraContent, logContext) } func (portal *PortalInternals) GetIntentForMXID(ctx context.Context, userID id.UserID) (MatrixAPI, error) { return (*Portal)(portal).getIntentForMXID(ctx, userID) } -func (portal *PortalInternals) HandleRemoteReactionRemove(ctx context.Context, source *UserLogin, evt RemoteReactionRemove) { - (*Portal)(portal).handleRemoteReactionRemove(ctx, source, evt) +func (portal *PortalInternals) HandleRemoteReactionRemove(ctx context.Context, source *UserLogin, evt RemoteReactionRemove) EventHandlingResult { + return (*Portal)(portal).handleRemoteReactionRemove(ctx, source, evt) } -func (portal *PortalInternals) HandleRemoteMessageRemove(ctx context.Context, source *UserLogin, evt RemoteMessageRemove) { - (*Portal)(portal).handleRemoteMessageRemove(ctx, source, evt) +func (portal *PortalInternals) HandleRemoteMessageRemove(ctx context.Context, source *UserLogin, evt RemoteMessageRemove) EventHandlingResult { + return (*Portal)(portal).handleRemoteMessageRemove(ctx, source, evt) } -func (portal *PortalInternals) RedactMessageParts(ctx context.Context, parts []*database.Message, intent MatrixAPI, ts time.Time) { - (*Portal)(portal).redactMessageParts(ctx, parts, intent, ts) +func (portal *PortalInternals) RedactMessageParts(ctx context.Context, parts []*database.Message, intent MatrixAPI, ts time.Time) EventHandlingResult { + return (*Portal)(portal).redactMessageParts(ctx, parts, intent, ts) } -func (portal *PortalInternals) HandleRemoteReadReceipt(ctx context.Context, source *UserLogin, evt RemoteReadReceipt) { - (*Portal)(portal).handleRemoteReadReceipt(ctx, source, evt) +func (portal *PortalInternals) HandleRemoteReadReceipt(ctx context.Context, source *UserLogin, evt RemoteReadReceipt) EventHandlingResult { + return (*Portal)(portal).handleRemoteReadReceipt(ctx, source, evt) } -func (portal *PortalInternals) HandleRemoteMarkUnread(ctx context.Context, source *UserLogin, evt RemoteMarkUnread) { - (*Portal)(portal).handleRemoteMarkUnread(ctx, source, evt) +func (portal *PortalInternals) HandleRemoteMarkUnread(ctx context.Context, source *UserLogin, evt RemoteMarkUnread) EventHandlingResult { + return (*Portal)(portal).handleRemoteMarkUnread(ctx, source, evt) } -func (portal *PortalInternals) HandleRemoteDeliveryReceipt(ctx context.Context, source *UserLogin, evt RemoteDeliveryReceipt) { - (*Portal)(portal).handleRemoteDeliveryReceipt(ctx, source, evt) +func (portal *PortalInternals) HandleRemoteDeliveryReceipt(ctx context.Context, source *UserLogin, evt RemoteDeliveryReceipt) EventHandlingResult { + return (*Portal)(portal).handleRemoteDeliveryReceipt(ctx, source, evt) } -func (portal *PortalInternals) HandleRemoteTyping(ctx context.Context, source *UserLogin, evt RemoteTyping) { - (*Portal)(portal).handleRemoteTyping(ctx, source, evt) +func (portal *PortalInternals) HandleRemoteTyping(ctx context.Context, source *UserLogin, evt RemoteTyping) EventHandlingResult { + return (*Portal)(portal).handleRemoteTyping(ctx, source, evt) } -func (portal *PortalInternals) HandleRemoteChatInfoChange(ctx context.Context, source *UserLogin, evt RemoteChatInfoChange) { - (*Portal)(portal).handleRemoteChatInfoChange(ctx, source, evt) +func (portal *PortalInternals) HandleRemoteChatInfoChange(ctx context.Context, source *UserLogin, evt RemoteChatInfoChange) EventHandlingResult { + return (*Portal)(portal).handleRemoteChatInfoChange(ctx, source, evt) } -func (portal *PortalInternals) HandleRemoteChatResync(ctx context.Context, source *UserLogin, evt RemoteChatResync) { - (*Portal)(portal).handleRemoteChatResync(ctx, source, evt) +func (portal *PortalInternals) HandleRemoteChatResync(ctx context.Context, source *UserLogin, evt RemoteChatResync) EventHandlingResult { + return (*Portal)(portal).handleRemoteChatResync(ctx, source, evt) } -func (portal *PortalInternals) HandleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) { - (*Portal)(portal).handleRemoteChatDelete(ctx, source, evt) +func (portal *PortalInternals) HandleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) EventHandlingResult { + return (*Portal)(portal).handleRemoteChatDelete(ctx, source, evt) } -func (portal *PortalInternals) HandleRemoteBackfill(ctx context.Context, source *UserLogin, backfill RemoteBackfill) { - (*Portal)(portal).handleRemoteBackfill(ctx, source, backfill) +func (portal *PortalInternals) HandleRemoteBackfill(ctx context.Context, source *UserLogin, backfill RemoteBackfill) (res EventHandlingResult) { + return (*Portal)(portal).handleRemoteBackfill(ctx, source, backfill) } func (portal *PortalInternals) UpdateName(ctx context.Context, name string, sender MatrixAPI, ts time.Time) bool { diff --git a/bridgev2/queue.go b/bridgev2/queue.go index 74424290..48ee78f1 100644 --- a/bridgev2/queue.go +++ b/bridgev2/queue.go @@ -151,11 +151,24 @@ func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) { } } -func (ul *UserLogin) QueueRemoteEvent(evt RemoteEvent) { - ul.Bridge.QueueRemoteEvent(ul, evt) +type EventHandlingResult struct { + Success bool + Ignored bool + Queued bool } -func (br *Bridge) QueueRemoteEvent(login *UserLogin, evt RemoteEvent) { +var ( + EventHandlingResultFailed = EventHandlingResult{} + EventHandlingResultQueued = EventHandlingResult{Success: true, Queued: true} + EventHandlingResultSuccess = EventHandlingResult{Success: true} + EventHandlingResultIgnored = EventHandlingResult{Success: true, Ignored: true} +) + +func (ul *UserLogin) QueueRemoteEvent(evt RemoteEvent) EventHandlingResult { + return ul.Bridge.QueueRemoteEvent(ul, evt) +} + +func (br *Bridge) QueueRemoteEvent(login *UserLogin, evt RemoteEvent) (res EventHandlingResult) { log := login.Log ctx := log.WithContext(br.BackgroundCtx) maybeUncertain, ok := evt.(RemoteEventWithUncertainPortalReceiver) @@ -182,7 +195,7 @@ func (br *Bridge) QueueRemoteEvent(login *UserLogin, evt RemoteEvent) { } // TODO put this in a better place, and maybe cache to avoid constant db queries login.MarkInPortal(ctx, portal) - portal.queueEvent(ctx, &portalRemoteEvent{ + return portal.queueEvent(ctx, &portalRemoteEvent{ evt: evt, source: login, }) From f3722ca31f3d78f77648610ccb5638bc351ce150 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 19 Jun 2025 17:17:27 +0200 Subject: [PATCH 145/581] mediaproxy: validate media IDs --- mediaproxy/mediaproxy.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mediaproxy/mediaproxy.go b/mediaproxy/mediaproxy.go index 1300a305..c906fc8e 100644 --- a/mediaproxy/mediaproxy.go +++ b/mediaproxy/mediaproxy.go @@ -26,6 +26,7 @@ import ( "maunium.net/go/mautrix" "maunium.net/go/mautrix/federation" + "maunium.net/go/mautrix/id" ) type GetMediaResponse interface { @@ -234,6 +235,10 @@ func queryToMap(vals url.Values) map[string]string { func (mp *MediaProxy) getMedia(w http.ResponseWriter, r *http.Request) GetMediaResponse { mediaID := mux.Vars(r)["mediaID"] + if !id.IsValidMediaID(mediaID) { + mautrix.MNotFound.WithMessage("Media ID %q is not valid", mediaID).Write(w) + return nil + } resp, err := mp.GetMedia(r.Context(), mediaID, queryToMap(r.URL.Query())) if err != nil { var mautrixRespError mautrix.RespError From 324be4ecb99766aaa7f6a2ac0d31e80a3e8adc97 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 19 Jun 2025 17:55:09 +0200 Subject: [PATCH 146/581] mediaproxy: fix closing data response readers --- mediaproxy/mediaproxy.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/mediaproxy/mediaproxy.go b/mediaproxy/mediaproxy.go index c906fc8e..4be799d3 100644 --- a/mediaproxy/mediaproxy.go +++ b/mediaproxy/mediaproxy.go @@ -421,13 +421,16 @@ func (mp *MediaProxy) DownloadMedia(w http.ResponseWriter, r *http.Request) { } } } - } else if dataResp, ok := resp.(GetMediaResponseWriter); ok { - mp.addHeaders(w, dataResp.GetContentType(), vars["fileName"]) - if dataResp.GetContentLength() != 0 { - w.Header().Set("Content-Length", strconv.FormatInt(dataResp.GetContentLength(), 10)) + } else if writerResp, ok := resp.(GetMediaResponseWriter); ok { + if dataResp, ok := writerResp.(*GetMediaResponseData); ok { + defer dataResp.Reader.Close() + } + mp.addHeaders(w, writerResp.GetContentType(), vars["fileName"]) + if writerResp.GetContentLength() != 0 { + w.Header().Set("Content-Length", strconv.FormatInt(writerResp.GetContentLength(), 10)) } w.WriteHeader(http.StatusOK) - _, err := dataResp.WriteTo(w) + _, err := writerResp.WriteTo(w) if err != nil { log.Err(err).Msg("Failed to write media data") } From 3a135b6b1586ea449e79781f7afa865654312786 Mon Sep 17 00:00:00 2001 From: Matthias Kesler Date: Wed, 25 Jun 2025 12:35:18 +0200 Subject: [PATCH 147/581] id: fix ServerNameRegex not matching port correctly (#392) fixes #391 --- id/servername.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/id/servername.go b/id/servername.go index 591f394a..923705b6 100644 --- a/id/servername.go +++ b/id/servername.go @@ -25,7 +25,7 @@ type ParsedServerName struct { Port int } -var ServerNameRegex = regexp.MustCompile(`^(?:\[([0-9A-Fa-f:.]{2,45})]|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|([0-9A-Za-z.-]{1,255}))(\d{1,5})?$`) +var ServerNameRegex = regexp.MustCompile(`^(?:\[([0-9A-Fa-f:.]{2,45})]|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|([0-9A-Za-z.-]{1,255}))(?::(\d{1,5}))?$`) func ValidateServerName(serverName string) bool { return len(serverName) <= 255 && len(serverName) > 0 && ServerNameRegex.MatchString(serverName) From 7a7d7f70ef92b45b74d6009a0b7af95f65e1612d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 29 Jun 2025 19:10:35 +0300 Subject: [PATCH 148/581] federation: fix base64 in generated signatures --- federation/signingkey.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/federation/signingkey.go b/federation/signingkey.go index 5b111947..0ae6a571 100644 --- a/federation/signingkey.go +++ b/federation/signingkey.go @@ -179,7 +179,7 @@ func (sk *SigningKey) SignJSON(data any) (string, error) { if err != nil { return "", err } - return base64.RawURLEncoding.EncodeToString(sk.SignRawJSON(marshaled)), nil + return base64.RawStdEncoding.EncodeToString(sk.SignRawJSON(marshaled)), nil } func (sk *SigningKey) SignRawJSON(data json.RawMessage) []byte { From 94950585c94dbead4ca7da5e50f16c604ff5622a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 1 Jul 2025 01:15:24 +0300 Subject: [PATCH 149/581] event: fix removing per-message profile fallback in edits --- event/beeper.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/event/beeper.go b/event/beeper.go index a85e82fc..921e3466 100644 --- a/event/beeper.go +++ b/event/beeper.go @@ -164,7 +164,10 @@ func (content *MessageEventContent) AddPerMessageProfileFallback() { var HTMLProfileFallbackRegex = regexp.MustCompile(`([^<]+): `) func (content *MessageEventContent) RemovePerMessageProfileFallback() { - if content.BeeperPerMessageProfile == nil || !content.BeeperPerMessageProfile.HasFallback || content.BeeperPerMessageProfile.Displayname == "" { + if content.NewContent != nil && content.NewContent != content { + content.NewContent.RemovePerMessageProfileFallback() + } + if content == nil || content.BeeperPerMessageProfile == nil || !content.BeeperPerMessageProfile.HasFallback || content.BeeperPerMessageProfile.Displayname == "" { return } content.BeeperPerMessageProfile.HasFallback = false From 4f6d4d7c63f31b2103509f0752131e6e652568d6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 1 Jul 2025 01:34:42 +0300 Subject: [PATCH 150/581] bridgev2/portal: add support for per-message profiles in relay mode --- bridgev2/networkinterface.go | 1 + bridgev2/portal.go | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 457a7bd4..a107fae7 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -1180,6 +1180,7 @@ type OrigSender struct { RequiresDisambiguation bool DisambiguatedName string FormattedName string + PerMessageProfile event.BeeperPerMessageProfile event.MemberEventContent } diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 21d8550f..ad3f0e0d 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -544,6 +544,8 @@ func (portal *Portal) checkConfusableName(ctx context.Context, userID id.UserID, return false } +var fakePerMessageProfileEventType = event.Type{Class: event.StateEventType, Type: "m.per_message_profile"} + func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt *event.Event) { log := zerolog.Ctx(ctx) if evt.Mautrix.EventSource&event.SourceEphemeral != 0 { @@ -589,6 +591,24 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * } else { origSender.DisambiguatedName = sender.MXID.String() } + msg := evt.Content.AsMessage() + if msg != nil && msg.BeeperPerMessageProfile != nil && msg.BeeperPerMessageProfile.Displayname != "" { + pmp := msg.BeeperPerMessageProfile + origSender.PerMessageProfile = *pmp + roomPLs, err := portal.Bridge.Matrix.GetPowerLevels(ctx, portal.MXID) + if err != nil { + log.Warn().Err(err).Msg("Failed to get power levels to check relay profile") + } + if roomPLs != nil && + roomPLs.GetUserLevel(sender.MXID) >= roomPLs.GetEventLevel(fakePerMessageProfileEventType) && + !portal.checkConfusableName(ctx, sender.MXID, pmp.Displayname) { + origSender.DisambiguatedName = pmp.Displayname + origSender.RequiresDisambiguation = false + } else { + origSender.DisambiguatedName = fmt.Sprintf("%s via %s", pmp.Displayname, origSender.DisambiguatedName) + } + } + origSender.FormattedName = portal.Bridge.Config.Relay.FormatName(origSender) } // Copy logger because many of the handlers will use UpdateContext From 6f370cc3bb3953b2a22056ec583239eda4e3f4a6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 1 Jul 2025 23:27:45 +0300 Subject: [PATCH 151/581] bridgev2,appservice: move appservice ping loop to appservice package --- appservice/ping.go | 68 ++++++++++++++++++++++++++++++++++++ bridgev2/matrix/connector.go | 47 ++----------------------- 2 files changed, 70 insertions(+), 45 deletions(-) create mode 100644 appservice/ping.go diff --git a/appservice/ping.go b/appservice/ping.go new file mode 100644 index 00000000..bd6bcbd1 --- /dev/null +++ b/appservice/ping.go @@ -0,0 +1,68 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package appservice + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" +) + +func (intent *IntentAPI) EnsureAppserviceConnection(ctx context.Context, appserviceID string) { + var pingResp *mautrix.RespAppservicePing + var txnID string + var retryCount int + var err error + const maxRetries = 6 + for { + txnID = intent.TxnID() + pingResp, err = intent.AppservicePing(ctx, appserviceID, txnID) + if err == nil { + break + } + var httpErr mautrix.HTTPError + var pingErrBody string + if errors.As(err, &httpErr) && httpErr.RespError != nil { + if val, ok := httpErr.RespError.ExtraData["body"].(string); ok { + pingErrBody = strings.TrimSpace(val) + } + } + outOfRetries := retryCount >= maxRetries + level := zerolog.ErrorLevel + if outOfRetries { + level = zerolog.FatalLevel + } + evt := zerolog.Ctx(ctx).WithLevel(level).Err(err).Str("txn_id", txnID) + if pingErrBody != "" { + bodyBytes := []byte(pingErrBody) + if json.Valid(bodyBytes) { + evt.RawJSON("body", bodyBytes) + } else { + evt.Str("body", pingErrBody) + } + } + if outOfRetries { + evt.Msg("Homeserver -> appservice connection is not working") + zerolog.Ctx(ctx).Info().Msg("See https://docs.mau.fi/faq/as-ping for more info") + os.Exit(13) + } + evt.Msg("Homeserver -> appservice connection is not working, retrying in 5 seconds...") + time.Sleep(5 * time.Second) + retryCount++ + } + zerolog.Ctx(ctx).Debug(). + Str("txn_id", txnID). + Int64("duration_ms", pingResp.DurationMS). + Msg("Homeserver -> appservice connection works") +} diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index f56eece3..9fdb6804 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -10,7 +10,6 @@ import ( "context" "crypto/sha256" "encoding/base64" - "encoding/json" "errors" "fmt" "net/url" @@ -343,50 +342,8 @@ func (br *Connector) ensureConnection(ctx context.Context) { br.Log.Debug().Msg("Homeserver does not support checking status of homeserver -> bridge connection") return } - var pingResp *mautrix.RespAppservicePing - var txnID string - var retryCount int - const maxRetries = 6 - for { - txnID = br.Bot.TxnID() - pingResp, err = br.Bot.AppservicePing(ctx, br.Config.AppService.ID, txnID) - if err == nil { - break - } - var httpErr mautrix.HTTPError - var pingErrBody string - if errors.As(err, &httpErr) && httpErr.RespError != nil { - if val, ok := httpErr.RespError.ExtraData["body"].(string); ok { - pingErrBody = strings.TrimSpace(val) - } - } - outOfRetries := retryCount >= maxRetries - level := zerolog.ErrorLevel - if outOfRetries { - level = zerolog.FatalLevel - } - evt := br.Log.WithLevel(level).Err(err).Str("txn_id", txnID) - if pingErrBody != "" { - bodyBytes := []byte(pingErrBody) - if json.Valid(bodyBytes) { - evt.RawJSON("body", bodyBytes) - } else { - evt.Str("body", pingErrBody) - } - } - if outOfRetries { - evt.Msg("Homeserver -> bridge connection is not working") - br.Log.Info().Msg("See https://docs.mau.fi/faq/as-ping for more info") - os.Exit(13) - } - evt.Msg("Homeserver -> bridge connection is not working, retrying in 5 seconds...") - time.Sleep(5 * time.Second) - retryCount++ - } - br.Log.Debug(). - Str("txn_id", txnID). - Int64("duration_ms", pingResp.DurationMS). - Msg("Homeserver -> bridge connection works") + + br.Bot.EnsureAppserviceConnection(ctx, br.Config.AppService.ID) } func (br *Connector) fetchMediaConfig(ctx context.Context) { From 71b994b3fd47a23d854fcdbd87f7593bc5aaaf0b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 1 Jul 2025 23:29:43 +0300 Subject: [PATCH 152/581] appservice: remove unnecessary parameter in ping --- appservice/ping.go | 4 ++-- bridgev2/matrix/connector.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/appservice/ping.go b/appservice/ping.go index bd6bcbd1..774ec423 100644 --- a/appservice/ping.go +++ b/appservice/ping.go @@ -19,7 +19,7 @@ import ( "maunium.net/go/mautrix" ) -func (intent *IntentAPI) EnsureAppserviceConnection(ctx context.Context, appserviceID string) { +func (intent *IntentAPI) EnsureAppserviceConnection(ctx context.Context) { var pingResp *mautrix.RespAppservicePing var txnID string var retryCount int @@ -27,7 +27,7 @@ func (intent *IntentAPI) EnsureAppserviceConnection(ctx context.Context, appserv const maxRetries = 6 for { txnID = intent.TxnID() - pingResp, err = intent.AppservicePing(ctx, appserviceID, txnID) + pingResp, err = intent.AppservicePing(ctx, intent.as.Registration.ID, txnID) if err == nil { break } diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 9fdb6804..7af2d128 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -343,7 +343,7 @@ func (br *Connector) ensureConnection(ctx context.Context) { return } - br.Bot.EnsureAppserviceConnection(ctx, br.Config.AppService.ID) + br.Bot.EnsureAppserviceConnection(ctx) } func (br *Connector) fetchMediaConfig(ctx context.Context) { From b62535edaa57e2a5337ec305aa2ab9f64d62bf27 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 3 Jul 2025 21:22:19 +0300 Subject: [PATCH 153/581] bridgev2/portal: fix disappearing message notice for implicitly turning off timer --- bridgev2/portal.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index ad3f0e0d..88fc5fe9 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -3759,10 +3759,14 @@ func DisappearingMessageNotice(expiration time.Duration, implicit bool) *event.M Body: fmt.Sprintf("Set the disappearing message timer to %s", formattedDuration), Mentions: &event.Mentions{}, } - if implicit { + if expiration == 0 { + if implicit { + content.Body = "Automatically turned off disappearing messages because incoming message is not disappearing" + } else { + content.Body = "Turned off disappearing messages" + } + } else if implicit { content.Body = fmt.Sprintf("Automatically enabled disappearing message timer (%s) because incoming message is disappearing", formattedDuration) - } else if expiration == 0 { - content.Body = "Turned off disappearing messages" } return content } From 44515616d454b6229cf7749f4921355f7220b893 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 9 Jul 2025 16:28:02 +0300 Subject: [PATCH 154/581] bridgev2/portal: don't assume unknown reply events are cross-room --- bridgev2/portal.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 88fc5fe9..e0b0d4f6 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1940,7 +1940,13 @@ func (portal *Portal) GetIntentFor(ctx context.Context, sender EventSender, sour return intent, true } -func (portal *Portal) getRelationMeta(ctx context.Context, currentMsg networkid.MessageID, replyToPtr *networkid.MessageOptionalPartID, threadRootPtr *networkid.MessageID, isBatchSend bool) (replyTo, threadRoot, prevThreadEvent *database.Message) { +func (portal *Portal) getRelationMeta( + ctx context.Context, + currentMsg networkid.MessageID, + replyToPtr *networkid.MessageOptionalPartID, + threadRootPtr *networkid.MessageID, + isBatchSend bool, +) (replyTo, threadRoot, prevThreadEvent *database.Message) { log := zerolog.Ctx(ctx) var err error if replyToPtr != nil { @@ -1950,6 +1956,7 @@ func (portal *Portal) getRelationMeta(ctx context.Context, currentMsg networkid. } else if replyTo == nil { if isBatchSend || portal.Bridge.Config.OutgoingMessageReID { // This is somewhat evil + // TODO this does not work with cross-room replies replyTo = &database.Message{ MXID: portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, replyToPtr.MessageID, ptr.Val(replyToPtr.PartID)), } @@ -1988,7 +1995,7 @@ func (portal *Portal) applyRelationMeta(ctx context.Context, content *event.Mess content.GetRelatesTo().SetThread(threadRoot.MXID, prevThreadEvent.MXID) } if replyTo != nil { - crossRoom := replyTo.Room != portal.PortalKey + crossRoom := !replyTo.Room.IsEmpty() && replyTo.Room != portal.PortalKey if !crossRoom || portal.Bridge.Config.CrossRoomReplies { content.GetRelatesTo().SetReplyTo(replyTo.MXID) } From 0777c10028375b23af567110c543806b4dd0fcd0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 9 Jul 2025 16:35:14 +0300 Subject: [PATCH 155/581] bridgev2/networkinterface: add extra fields to reply metadata to allow unknown cross-room replies --- bridgev2/networkinterface.go | 9 ++++++- bridgev2/portal.go | 49 +++++++++++++++++++++++++----------- bridgev2/portalbackfill.go | 4 ++- bridgev2/portalinternal.go | 4 +-- 4 files changed, 48 insertions(+), 18 deletions(-) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index a107fae7..eb38bd2d 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -91,7 +91,14 @@ func (es EventSender) MarshalZerologObject(evt *zerolog.Event) { } type ConvertedMessage struct { - ReplyTo *networkid.MessageOptionalPartID + ReplyTo *networkid.MessageOptionalPartID + // Optional additional info about the reply. This is only used when backfilling messages + // on Beeper, where replies may target messages that haven't been bridged yet. + // Standard Matrix servers can't backwards backfill, so these are never used. + ReplyToRoom networkid.PortalKey + ReplyToUser networkid.UserID + ReplyToLogin networkid.UserLoginID + ThreadRoot *networkid.MessageID Parts []*ConvertedMessagePart Disappear database.DisappearingSetting diff --git a/bridgev2/portal.go b/bridgev2/portal.go index e0b0d4f6..856b6331 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1942,42 +1942,61 @@ func (portal *Portal) GetIntentFor(ctx context.Context, sender EventSender, sour func (portal *Portal) getRelationMeta( ctx context.Context, - currentMsg networkid.MessageID, - replyToPtr *networkid.MessageOptionalPartID, - threadRootPtr *networkid.MessageID, + currentMsgID networkid.MessageID, + currentMsg *ConvertedMessage, isBatchSend bool, ) (replyTo, threadRoot, prevThreadEvent *database.Message) { log := zerolog.Ctx(ctx) var err error - if replyToPtr != nil { - replyTo, err = portal.Bridge.DB.Message.GetFirstOrSpecificPartByID(ctx, portal.Receiver, *replyToPtr) + if currentMsg.ReplyTo != nil { + replyTo, err = portal.Bridge.DB.Message.GetFirstOrSpecificPartByID(ctx, portal.Receiver, *currentMsg.ReplyTo) if err != nil { log.Err(err).Msg("Failed to get reply target message from database") } else if replyTo == nil { if isBatchSend || portal.Bridge.Config.OutgoingMessageReID { // This is somewhat evil - // TODO this does not work with cross-room replies replyTo = &database.Message{ - MXID: portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, replyToPtr.MessageID, ptr.Val(replyToPtr.PartID)), + MXID: portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, currentMsg.ReplyTo.MessageID, ptr.Val(currentMsg.ReplyTo.PartID)), + Room: currentMsg.ReplyToRoom, + SenderID: currentMsg.ReplyToUser, + } + if currentMsg.ReplyToLogin != "" && (portal.Receiver == "" || portal.Receiver == currentMsg.ReplyToLogin) { + userLogin, err := portal.Bridge.GetExistingUserLoginByID(ctx, currentMsg.ReplyToLogin) + if err != nil { + log.Err(err). + Str("reply_to_login", string(currentMsg.ReplyToLogin)). + Msg("Failed to get reply target user login") + } else if userLogin != nil { + replyTo.SenderMXID = userLogin.UserMXID + } + } else { + ghost, err := portal.Bridge.GetGhostByID(ctx, currentMsg.ReplyToUser) + if err != nil { + log.Err(err). + Str("reply_to_user_id", string(currentMsg.ReplyToUser)). + Msg("Failed to get reply target ghost") + } else { + replyTo.SenderMXID = ghost.Intent.GetMXID() + } } } else { - log.Warn().Any("reply_to", *replyToPtr).Msg("Reply target message not found in database") + log.Warn().Any("reply_to", *currentMsg.ReplyTo).Msg("Reply target message not found in database") } } } - if threadRootPtr != nil && *threadRootPtr != currentMsg { - threadRoot, err = portal.Bridge.DB.Message.GetFirstThreadMessage(ctx, portal.PortalKey, *threadRootPtr) + if currentMsg.ThreadRoot != nil && *currentMsg.ThreadRoot != currentMsgID { + threadRoot, err = portal.Bridge.DB.Message.GetFirstThreadMessage(ctx, portal.PortalKey, *currentMsg.ThreadRoot) if err != nil { log.Err(err).Msg("Failed to get thread root message from database") } else if threadRoot == nil { if isBatchSend || portal.Bridge.Config.OutgoingMessageReID { threadRoot = &database.Message{ - MXID: portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, *threadRootPtr, ""), + MXID: portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, *currentMsg.ThreadRoot, ""), } } else { - log.Warn().Str("thread_root", string(*threadRootPtr)).Msg("Thread root message not found in database") + log.Warn().Str("thread_root", string(*currentMsg.ThreadRoot)).Msg("Thread root message not found in database") } - } else if prevThreadEvent, err = portal.Bridge.DB.Message.GetLastThreadMessage(ctx, portal.PortalKey, *threadRootPtr); err != nil { + } else if prevThreadEvent, err = portal.Bridge.DB.Message.GetLastThreadMessage(ctx, portal.PortalKey, *currentMsg.ThreadRoot); err != nil { log.Err(err).Msg("Failed to get last thread message from database") } if prevThreadEvent == nil { @@ -2033,7 +2052,9 @@ func (portal *Portal) sendConvertedMessage( } } log := zerolog.Ctx(ctx) - replyTo, threadRoot, prevThreadEvent := portal.getRelationMeta(ctx, id, converted.ReplyTo, converted.ThreadRoot, false) + replyTo, threadRoot, prevThreadEvent := portal.getRelationMeta( + ctx, id, converted, false, + ) output := make([]*database.Message, 0, len(converted.Parts)) allSuccess := true for i, part := range converted.Parts { diff --git a/bridgev2/portalbackfill.go b/bridgev2/portalbackfill.go index 74b75df2..9883fb12 100644 --- a/bridgev2/portalbackfill.go +++ b/bridgev2/portalbackfill.go @@ -327,7 +327,9 @@ func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin if !ok { return } - replyTo, threadRoot, prevThreadEvent := portal.getRelationMeta(ctx, msg.ID, msg.ReplyTo, msg.ThreadRoot, true) + replyTo, threadRoot, prevThreadEvent := portal.getRelationMeta( + ctx, msg.ID, msg.ConvertedMessage, true, + ) if threadRoot != nil && out.PrevThreadEvents[*msg.ThreadRoot] != "" { prevThreadEvent.MXID = out.PrevThreadEvents[*msg.ThreadRoot] } diff --git a/bridgev2/portalinternal.go b/bridgev2/portalinternal.go index 2b25f0cf..6815f043 100644 --- a/bridgev2/portalinternal.go +++ b/bridgev2/portalinternal.go @@ -137,8 +137,8 @@ func (portal *PortalInternals) GetIntentAndUserMXIDFor(ctx context.Context, send return (*Portal)(portal).getIntentAndUserMXIDFor(ctx, sender, source, otherLogins, evtType) } -func (portal *PortalInternals) GetRelationMeta(ctx context.Context, currentMsg networkid.MessageID, replyToPtr *networkid.MessageOptionalPartID, threadRootPtr *networkid.MessageID, isBatchSend bool) (replyTo, threadRoot, prevThreadEvent *database.Message) { - return (*Portal)(portal).getRelationMeta(ctx, currentMsg, replyToPtr, threadRootPtr, isBatchSend) +func (portal *PortalInternals) GetRelationMeta(ctx context.Context, currentMsgID networkid.MessageID, currentMsg *ConvertedMessage, isBatchSend bool) (replyTo, threadRoot, prevThreadEvent *database.Message) { + return (*Portal)(portal).getRelationMeta(ctx, currentMsgID, currentMsg, isBatchSend) } func (portal *PortalInternals) ApplyRelationMeta(ctx context.Context, content *event.MessageEventContent, replyTo, threadRoot, prevThreadEvent *database.Message) { From c80808439d5bc31d442323929710f1ddfd51e4f6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 10 Jul 2025 13:45:11 +0300 Subject: [PATCH 156/581] bridgev2: add logger to background context --- bridgev2/bridge.go | 1 + 1 file changed, 1 insertion(+) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index 5e3b74b7..a4ce033e 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -172,6 +172,7 @@ func (br *Bridge) StartConnectors(ctx context.Context) error { br.Log.Info().Msg("Starting bridge") if br.BackgroundCtx == nil || br.BackgroundCtx.Err() != nil { br.BackgroundCtx, br.cancelBackgroundCtx = context.WithCancel(context.Background()) + br.BackgroundCtx = br.Log.WithContext(br.BackgroundCtx) } if !br.ExternallyManagedDB { From 22587e915906f6e6d90531430a8fcf26857aa092 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 10 Jul 2025 13:45:23 +0300 Subject: [PATCH 157/581] bridgev2/portal: track event handler panics --- bridgev2/portal.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 856b6331..c264caea 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -420,10 +420,13 @@ func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCal doneCallback(res) if err := recover(); err != nil { logEvt := log.Error() + var errorString string if realErr, ok := err.(error); ok { logEvt = logEvt.Err(realErr) + errorString = realErr.Error() } else { logEvt = logEvt.Any(zerolog.ErrorFieldName, err) + errorString = fmt.Sprintf("%v", err) } logEvt. Bytes("stack", debug.Stack()). @@ -436,6 +439,9 @@ func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCal case *portalCreateEvent: evt.cb(fmt.Errorf("portal creation panicked")) } + portal.Bridge.TrackAnalytics("", "Bridge Event Handler Panic", map[string]any{ + "error": errorString, + }) } }() switch evt := rawEvt.(type) { From 40bb9637cdc44979358a337a1870d021ea4a07ad Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 10 Jul 2025 14:48:54 +0300 Subject: [PATCH 158/581] bridgev2/queue: add event handling result for matrix events --- bridgev2/matrixinvite.go | 30 ++--- bridgev2/portal.go | 226 +++++++++++++++++++++---------------- bridgev2/portalinternal.go | 36 +++--- bridgev2/queue.go | 41 ++++--- 4 files changed, 191 insertions(+), 142 deletions(-) diff --git a/bridgev2/matrixinvite.go b/bridgev2/matrixinvite.go index 11826b40..bfbabd26 100644 --- a/bridgev2/matrixinvite.go +++ b/bridgev2/matrixinvite.go @@ -19,17 +19,17 @@ import ( "maunium.net/go/mautrix/id" ) -func (br *Bridge) handleBotInvite(ctx context.Context, evt *event.Event, sender *User) { +func (br *Bridge) handleBotInvite(ctx context.Context, evt *event.Event, sender *User) EventHandlingResult { log := zerolog.Ctx(ctx) // These invites should already be rejected in QueueMatrixEvent if !sender.Permissions.Commands { log.Warn().Msg("Received bot invite from user without permission to send commands") - return + return EventHandlingResultIgnored } err := br.Bot.EnsureJoined(ctx, evt.RoomID) if err != nil { log.Err(err).Msg("Failed to accept invite to room") - return + return EventHandlingResultFailed } log.Debug().Msg("Accepted invite to room as bot") members, err := br.Matrix.GetMembers(ctx, evt.RoomID) @@ -55,6 +55,7 @@ func (br *Bridge) handleBotInvite(ctx context.Context, evt *event.Event, sender log.Err(err).Msg("Failed to send welcome message to room") } } + return EventHandlingResultSuccess } func sendNotice(ctx context.Context, evt *event.Event, intent MatrixAPI, message string, args ...any) { @@ -87,12 +88,12 @@ func sendErrorAndLeave(ctx context.Context, evt *event.Event, intent MatrixAPI, rejectInvite(ctx, evt, intent, "") } -func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sender *User) { +func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sender *User) EventHandlingResult { ghostID, _ := br.Matrix.ParseGhostMXID(id.UserID(evt.GetStateKey())) validator, ok := br.Network.(IdentifierValidatingNetwork) if ghostID == "" || (ok && !validator.ValidateUserID(ghostID)) { rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "Malformed user ID") - return + return EventHandlingResultIgnored } log := zerolog.Ctx(ctx).With(). Str("invitee_network_id", string(ghostID)). @@ -102,22 +103,22 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen logins := sender.GetUserLogins() if len(logins) == 0 { rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "You're not logged in") - return + return EventHandlingResultIgnored } _, ok = logins[0].Client.(IdentifierResolvingNetworkAPI) if !ok { rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "This bridge does not support starting chats") - return + return EventHandlingResultIgnored } invitedGhost, err := br.GetGhostByID(ctx, ghostID) if err != nil { log.Err(err).Msg("Failed to get invited ghost") - return + return EventHandlingResultFailed } err = invitedGhost.Intent.EnsureJoined(ctx, evt.RoomID) if err != nil { log.Err(err).Msg("Failed to accept invite to room") - return + return EventHandlingResultFailed } var resp *CreateChatResponse var sourceLogin *UserLogin @@ -144,7 +145,7 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen } else if err != nil { log.Err(err).Msg("Failed to resolve identifier") sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to create chat") - return + return EventHandlingResultFailed } else { sourceLogin = login break @@ -153,7 +154,7 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen if resp == nil { log.Warn().Msg("No login could resolve the identifier") sendErrorAndLeave(ctx, evt, br.Matrix.GhostIntent(ghostID), "Failed to create chat via any login") - return + return EventHandlingResultFailed } portal := resp.Portal if portal == nil { @@ -161,7 +162,7 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen if err != nil { log.Err(err).Msg("Failed to get portal by key") sendErrorAndLeave(ctx, evt, br.Matrix.GhostIntent(ghostID), "Failed to create portal entry") - return + return EventHandlingResultFailed } } if portal.MXID != "" { @@ -196,13 +197,13 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen if err != nil { log.Err(err).Msg("Failed to ensure bot is invited to room") sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to invite bridge bot") - return + return EventHandlingResultFailed } err = br.Bot.EnsureJoined(ctx, evt.RoomID) if err != nil { log.Err(err).Msg("Failed to ensure bot is joined to room") sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to join with bridge bot") - return + return EventHandlingResultFailed } didSetPortal := portal.setMXIDToExistingRoom(ctx, evt.RoomID) @@ -271,6 +272,7 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "You already have a direct chat with me at [%s](%s)", portal.MXID, portal.MXID.URI(br.Matrix.ServerName()).MatrixToURL()) rejectInvite(ctx, evt, br.Bot, "") } + return EventHandlingResultSuccess } func (br *Bridge) givePowerToBot(ctx context.Context, roomID id.RoomID, userWithPower MatrixAPI) error { diff --git a/bridgev2/portal.go b/bridgev2/portal.go index c264caea..900d057d 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -446,7 +446,7 @@ func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCal }() switch evt := rawEvt.(type) { case *portalMatrixEvent: - portal.handleMatrixEvent(ctx, evt.sender, evt.evt) + res = portal.handleMatrixEvent(ctx, evt.sender, evt.evt) case *portalRemoteEvent: res = portal.handleRemoteEvent(ctx, evt.source, evt.evtType, evt.evt) case *portalCreateEvent: @@ -552,16 +552,17 @@ func (portal *Portal) checkConfusableName(ctx context.Context, userID id.UserID, var fakePerMessageProfileEventType = event.Type{Class: event.StateEventType, Type: "m.per_message_profile"} -func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt *event.Event) { +func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt *event.Event) EventHandlingResult { log := zerolog.Ctx(ctx) if evt.Mautrix.EventSource&event.SourceEphemeral != 0 { switch evt.Type { case event.EphemeralEventReceipt: - portal.handleMatrixReceipts(ctx, evt) + return portal.handleMatrixReceipts(ctx, evt) case event.EphemeralEventTyping: - portal.handleMatrixTyping(ctx, evt) + return portal.handleMatrixTyping(ctx, evt) + default: + return EventHandlingResultIgnored } - return } login, _, err := portal.FindPreferredLogin(ctx, sender, true) if err != nil { @@ -572,7 +573,7 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * } else { portal.sendErrorStatus(ctx, evt, WrapErrorInStatus(err).WithMessage("Failed to get login to handle event").WithIsCertain(true).WithSendNotice(true)) } - return + return EventHandlingResultFailed } var origSender *OrigSender if login == nil { @@ -621,41 +622,44 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * ctx = log.With().Str("login_id", string(login.ID)).Logger().WithContext(ctx) switch evt.Type { case event.EventMessage, event.EventSticker, event.EventUnstablePollStart, event.EventUnstablePollResponse: - portal.handleMatrixMessage(ctx, login, origSender, evt) + return portal.handleMatrixMessage(ctx, login, origSender, evt) case event.EventReaction: if origSender != nil { log.Debug().Msg("Ignoring reaction event from relayed user") portal.sendErrorStatus(ctx, evt, ErrIgnoringReactionFromRelayedUser) - return + return EventHandlingResultIgnored } - portal.handleMatrixReaction(ctx, login, evt) + return portal.handleMatrixReaction(ctx, login, evt) case event.EventRedaction: - portal.handleMatrixRedaction(ctx, login, origSender, evt) + return portal.handleMatrixRedaction(ctx, login, origSender, evt) case event.StateRoomName: - handleMatrixRoomMeta(portal, ctx, login, origSender, evt, RoomNameHandlingNetworkAPI.HandleMatrixRoomName) + return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, RoomNameHandlingNetworkAPI.HandleMatrixRoomName) case event.StateTopic: - handleMatrixRoomMeta(portal, ctx, login, origSender, evt, RoomTopicHandlingNetworkAPI.HandleMatrixRoomTopic) + return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, RoomTopicHandlingNetworkAPI.HandleMatrixRoomTopic) case event.StateRoomAvatar: - handleMatrixRoomMeta(portal, ctx, login, origSender, evt, RoomAvatarHandlingNetworkAPI.HandleMatrixRoomAvatar) + return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, RoomAvatarHandlingNetworkAPI.HandleMatrixRoomAvatar) case event.StateEncryption: // TODO? + return EventHandlingResultIgnored case event.AccountDataMarkedUnread: - handleMatrixAccountData(portal, ctx, login, evt, MarkedUnreadHandlingNetworkAPI.HandleMarkedUnread) + return handleMatrixAccountData(portal, ctx, login, evt, MarkedUnreadHandlingNetworkAPI.HandleMarkedUnread) case event.AccountDataRoomTags: - handleMatrixAccountData(portal, ctx, login, evt, TagHandlingNetworkAPI.HandleRoomTag) + return handleMatrixAccountData(portal, ctx, login, evt, TagHandlingNetworkAPI.HandleRoomTag) case event.AccountDataBeeperMute: - handleMatrixAccountData(portal, ctx, login, evt, MuteHandlingNetworkAPI.HandleMute) + return handleMatrixAccountData(portal, ctx, login, evt, MuteHandlingNetworkAPI.HandleMute) case event.StateMember: - portal.handleMatrixMembership(ctx, login, origSender, evt) + return portal.handleMatrixMembership(ctx, login, origSender, evt) case event.StatePowerLevels: - portal.handleMatrixPowerLevels(ctx, login, origSender, evt) + return portal.handleMatrixPowerLevels(ctx, login, origSender, evt) + default: + return EventHandlingResultIgnored } } -func (portal *Portal) handleMatrixReceipts(ctx context.Context, evt *event.Event) { +func (portal *Portal) handleMatrixReceipts(ctx context.Context, evt *event.Event) EventHandlingResult { content, ok := evt.Content.Parsed.(*event.ReceiptEventContent) if !ok { - return + return EventHandlingResultFailed } for evtID, receipts := range *content { readReceipts, ok := receipts[event.ReceiptTypeRead] @@ -666,11 +670,13 @@ func (portal *Portal) handleMatrixReceipts(ctx context.Context, evt *event.Event sender, err := portal.Bridge.GetUserByMXID(ctx, userID) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to get user to handle read receipt") - return + return EventHandlingResultFailed } portal.handleMatrixReadReceipt(ctx, sender, evtID, receipt) } } + // TODO actual status + return EventHandlingResultSuccess } func (portal *Portal) handleMatrixReadReceipt(ctx context.Context, user *User, eventID id.EventID, receipt event.ReadReceipt) { @@ -736,10 +742,10 @@ func (portal *Portal) handleMatrixReadReceipt(ctx context.Context, user *User, e portal.Bridge.DisappearLoop.StartAll(ctx, portal.MXID) } -func (portal *Portal) handleMatrixTyping(ctx context.Context, evt *event.Event) { +func (portal *Portal) handleMatrixTyping(ctx context.Context, evt *event.Event) EventHandlingResult { content, ok := evt.Content.Parsed.(*event.TypingEventContent) if !ok { - return + return EventHandlingResultFailed } portal.currentlyTypingLock.Lock() defer portal.currentlyTypingLock.Unlock() @@ -750,6 +756,8 @@ func (portal *Portal) handleMatrixTyping(ctx context.Context, evt *event.Event) portal.sendTypings(ctx, stoppedTyping, false) portal.sendTypings(ctx, startedTyping, true) portal.currentlyTyping = content.UserIDs + // TODO actual status + return EventHandlingResultSuccess } func (portal *Portal) sendTypings(ctx context.Context, userIDs []id.UserID, typing bool) { @@ -882,7 +890,7 @@ func (portal *Portal) parseInputTransactionID(origSender *OrigSender, evt *event return networkid.RawTransactionID(strings.TrimPrefix(evt.ID.String(), database.NetworkTxnMXIDPrefix)) } -func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) { +func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { log := zerolog.Ctx(ctx) var relatesTo *event.RelatesTo var msgContent *event.MessageEventContent @@ -903,13 +911,14 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin } if msgContent.MsgType == event.MsgNotice && !portal.Bridge.Config.BridgeNotices { portal.sendErrorStatus(ctx, evt, ErrIgnoringMNotice) - return + return EventHandlingResultIgnored } } if !ok { log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - return + typeErr := fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) + portal.sendErrorStatus(ctx, evt, typeErr) + return EventHandlingResultFailed.WithError(typeErr) } caps := sender.Client.GetCapabilities(ctx, portal) @@ -917,34 +926,33 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin if msgContent == nil { log.Warn().Msg("Ignoring edit of poll") portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w of polls", ErrEditsNotSupported)) - return + return EventHandlingResultFailed.WithError(fmt.Errorf("%w of polls", ErrEditsNotSupported)) } - portal.handleMatrixEdit(ctx, sender, origSender, evt, msgContent, caps) - return + return portal.handleMatrixEdit(ctx, sender, origSender, evt, msgContent, caps) } var err error if origSender != nil { if msgContent == nil { log.Debug().Msg("Ignoring poll event from relayed user") portal.sendErrorStatus(ctx, evt, ErrIgnoringPollFromRelayedUser) - return + return EventHandlingResultIgnored } msgContent, err = portal.Bridge.Config.Relay.FormatMessage(msgContent, origSender) if err != nil { log.Err(err).Msg("Failed to format message for relaying") portal.sendErrorStatus(ctx, evt, err) - return + return EventHandlingResultFailed.WithError(err) } } if msgContent != nil { if !portal.checkMessageContentCaps(ctx, caps, msgContent, evt) { - return + return EventHandlingResultFailed } } else if pollResponseContent != nil || pollContent != nil { if _, ok = sender.Client.(PollHandlingNetworkAPI); !ok { log.Debug().Msg("Ignoring poll event as network connector doesn't implement PollHandlingNetworkAPI") portal.sendErrorStatus(ctx, evt, ErrPollsNotSupported) - return + return EventHandlingResultIgnored } } @@ -954,11 +962,11 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin if err != nil { log.Err(err).Msg("Failed to get poll target message from database") // TODO send status - return + return EventHandlingResultFailed } else if voteTo == nil { log.Warn().Stringer("vote_to_id", relatesTo.GetReferenceID()).Msg("Poll target message not found") // TODO send status - return + return EventHandlingResultFailed } } var replyToID id.EventID @@ -1023,7 +1031,7 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin Stringer("message_mxid", part.MXID). Stringer("input_event_id", evt.ID). Msg("Message already sent, ignoring") - return + return EventHandlingResultIgnored } } @@ -1044,12 +1052,12 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin } else { log.Error().Msg("Failed to handle Matrix message: all contents are nil?") portal.sendErrorStatus(ctx, evt, fmt.Errorf("all contents are nil")) - return + return EventHandlingResultFailed } if err != nil { log.Err(err).Msg("Failed to handle Matrix message") portal.sendErrorStatus(ctx, evt, err) - return + return EventHandlingResultFailed.WithError(err) } message := wrappedMsgEvt.fillDBMessage(resp.DB) if resp.Pending { @@ -1091,6 +1099,7 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin }, }) } + return EventHandlingResultSuccess } // AddPendingToIgnore adds a transaction ID that should be ignored if encountered as a new message. @@ -1202,7 +1211,14 @@ func (portal *Portal) checkPendingMessages(ctx context.Context, cfg *OutgoingTim } } -func (portal *Portal) handleMatrixEdit(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, content *event.MessageEventContent, caps *event.RoomFeatures) { +func (portal *Portal) handleMatrixEdit( + ctx context.Context, + sender *UserLogin, + origSender *OrigSender, + evt *event.Event, + content *event.MessageEventContent, + caps *event.RoomFeatures, +) EventHandlingResult { log := zerolog.Ctx(ctx) editTargetID := content.RelatesTo.GetReplaceID() log.UpdateContext(func(c zerolog.Context) zerolog.Context { @@ -1220,7 +1236,7 @@ func (portal *Portal) handleMatrixEdit(ctx context.Context, sender *UserLogin, o if err != nil { log.Err(err).Msg("Failed to format message for relaying") portal.sendErrorStatus(ctx, evt, err) - return + return EventHandlingResultFailed.WithError(err) } } @@ -1228,29 +1244,30 @@ func (portal *Portal) handleMatrixEdit(ctx context.Context, sender *UserLogin, o if !ok { log.Debug().Msg("Ignoring edit as network connector doesn't implement EditHandlingNetworkAPI") portal.sendErrorStatus(ctx, evt, ErrEditsNotSupported) - return + return EventHandlingResultIgnored } else if !caps.Edit.Partial() { log.Debug().Msg("Ignoring edit as room doesn't support edits") portal.sendErrorStatus(ctx, evt, ErrEditsNotSupportedInPortal) - return + return EventHandlingResultIgnored } else if !portal.checkMessageContentCaps(ctx, caps, content, evt) { - return + return EventHandlingResultFailed } editTarget, err := portal.Bridge.DB.Message.GetPartByMXID(ctx, editTargetID) if err != nil { log.Err(err).Msg("Failed to get edit target message from database") portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: failed to get edit target: %w", ErrDatabaseError, err)) - return + return EventHandlingResultFailed } else if editTarget == nil { log.Warn().Msg("Edit target message not found in database") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("edit %w", ErrTargetMessageNotFound)) - return + notFoundErr := fmt.Errorf("edit %w", ErrTargetMessageNotFound) + portal.sendErrorStatus(ctx, evt, notFoundErr) + return EventHandlingResultFailed.WithError(notFoundErr) } else if caps.EditMaxAge != nil && caps.EditMaxAge.Duration > 0 && time.Since(editTarget.Timestamp) > caps.EditMaxAge.Duration { portal.sendErrorStatus(ctx, evt, ErrEditTargetTooOld) - return + return EventHandlingResultFailed.WithError(ErrEditTargetTooOld) } else if caps.EditMaxCount > 0 && editTarget.EditCount >= caps.EditMaxCount { portal.sendErrorStatus(ctx, evt, ErrEditTargetTooManyEdits) - return + return EventHandlingResultFailed.WithError(ErrEditTargetTooManyEdits) } log.UpdateContext(func(c zerolog.Context) zerolog.Context { return c.Str("edit_target_remote_id", string(editTarget.ID)) @@ -1269,7 +1286,7 @@ func (portal *Portal) handleMatrixEdit(ctx context.Context, sender *UserLogin, o if err != nil { log.Err(err).Msg("Failed to handle Matrix edit") portal.sendErrorStatus(ctx, evt, err) - return + return EventHandlingResultFailed.WithError(err) } err = portal.Bridge.DB.Message.Update(ctx, editTarget) if err != nil { @@ -1277,21 +1294,23 @@ func (portal *Portal) handleMatrixEdit(ctx context.Context, sender *UserLogin, o } // TODO allow returning stream order from HandleMatrixEdit portal.sendSuccessStatus(ctx, evt, 0, "") + return EventHandlingResultSuccess } -func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogin, evt *event.Event) { +func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogin, evt *event.Event) EventHandlingResult { log := zerolog.Ctx(ctx) reactingAPI, ok := sender.Client.(ReactionHandlingNetworkAPI) if !ok { log.Debug().Msg("Ignoring reaction as network connector doesn't implement ReactionHandlingNetworkAPI") portal.sendErrorStatus(ctx, evt, ErrReactionsNotSupported) - return + return EventHandlingResultIgnored } content, ok := evt.Content.Parsed.(*event.ReactionEventContent) if !ok { log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - return + typeErr := fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) + portal.sendErrorStatus(ctx, evt, typeErr) + return EventHandlingResultFailed.WithError(typeErr) } log.UpdateContext(func(c zerolog.Context) zerolog.Context { return c.Stringer("reaction_target_mxid", content.RelatesTo.EventID) @@ -1300,11 +1319,12 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi if err != nil { log.Err(err).Msg("Failed to get reaction target message from database") portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: failed to get reaction target: %w", ErrDatabaseError, err)) - return + return EventHandlingResultFailed } else if reactionTarget == nil { log.Warn().Msg("Reaction target message not found in database") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("reaction %w", ErrTargetMessageNotFound)) - return + notFoundErr := fmt.Errorf("reaction %w", ErrTargetMessageNotFound) + portal.sendErrorStatus(ctx, evt, notFoundErr) + return EventHandlingResultFailed.WithError(notFoundErr) } log.UpdateContext(func(c zerolog.Context) zerolog.Context { return c.Str("reaction_target_remote_id", string(reactionTarget.ID)) @@ -1323,7 +1343,7 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi if err != nil { log.Err(err).Msg("Failed to pre-handle Matrix reaction") portal.sendErrorStatus(ctx, evt, err) - return + return EventHandlingResultFailed.WithError(err) } var deterministicID id.EventID if portal.Bridge.Config.OutgoingMessageReID { @@ -1332,12 +1352,12 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi existing, err := portal.Bridge.DB.Reaction.GetByID(ctx, portal.Receiver, reactionTarget.ID, reactionTarget.PartID, preResp.SenderID, preResp.EmojiID) if err != nil { log.Err(err).Msg("Failed to check if reaction is a duplicate") - return + return EventHandlingResultFailed } else if existing != nil { if existing.EmojiID != "" || existing.Emoji == preResp.Emoji { log.Debug().Msg("Ignoring duplicate reaction") portal.sendSuccessStatus(ctx, evt, 0, deterministicID) - return + return EventHandlingResultIgnored } react.ReactionToOverride = existing _, err = portal.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ @@ -1355,7 +1375,7 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi if err != nil { log.Err(err).Msg("Failed to get all reactions to message by sender") portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: failed to get previous reactions: %w", ErrDatabaseError, err)) - return + return EventHandlingResultFailed } if len(allReactions) < preResp.MaxReactions { react.ExistingReactionsToKeep = allReactions @@ -1382,7 +1402,7 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi if err != nil { log.Err(err).Msg("Failed to handle Matrix reaction") portal.sendErrorStatus(ctx, evt, err) - return + return EventHandlingResultFailed.WithError(err) } if dbReaction == nil { dbReaction = &database.Reaction{} @@ -1421,6 +1441,7 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi log.Err(err).Msg("Failed to save reaction to database") } portal.sendSuccessStatus(ctx, evt, 0, deterministicID) + return EventHandlingResultSuccess } func handleMatrixRoomMeta[APIType any, ContentType any]( @@ -1430,34 +1451,35 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( origSender *OrigSender, evt *event.Event, fn func(APIType, context.Context, *MatrixRoomMeta[ContentType]) (bool, error), -) { +) EventHandlingResult { api, ok := sender.Client.(APIType) if !ok { portal.sendErrorStatus(ctx, evt, ErrRoomMetadataNotSupported) - return + return EventHandlingResultIgnored } log := zerolog.Ctx(ctx) content, ok := evt.Content.Parsed.(ContentType) if !ok { log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - return + typeErr := fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) + portal.sendErrorStatus(ctx, evt, typeErr) + return EventHandlingResultFailed.WithError(typeErr) } switch typedContent := evt.Content.Parsed.(type) { case *event.RoomNameEventContent: if typedContent.Name == portal.Name { portal.sendSuccessStatus(ctx, evt, 0, "") - return + return EventHandlingResultIgnored } case *event.TopicEventContent: if typedContent.Topic == portal.Topic { portal.sendSuccessStatus(ctx, evt, 0, "") - return + return EventHandlingResultIgnored } case *event.RoomAvatarEventContent: if typedContent.URL == portal.AvatarMXC { portal.sendSuccessStatus(ctx, evt, 0, "") - return + return EventHandlingResultIgnored } } var prevContent ContentType @@ -1480,7 +1502,7 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( if err != nil { log.Err(err).Msg("Failed to handle Matrix room metadata") portal.sendErrorStatus(ctx, evt, err) - return + return EventHandlingResultFailed.WithError(err) } if changed { portal.UpdateBridgeInfo(ctx) @@ -1490,21 +1512,22 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( } } portal.sendSuccessStatus(ctx, evt, 0, "") + return EventHandlingResultSuccess } func handleMatrixAccountData[APIType any, ContentType any]( portal *Portal, ctx context.Context, sender *UserLogin, evt *event.Event, fn func(APIType, context.Context, *MatrixRoomMeta[ContentType]) error, -) { +) EventHandlingResult { api, ok := sender.Client.(APIType) if !ok { - return + return EventHandlingResultIgnored } log := zerolog.Ctx(ctx) content, ok := evt.Content.Parsed.(ContentType) if !ok { log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - return + return EventHandlingResultFailed.WithError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) } var prevContent ContentType if evt.Unsigned.PrevContent != nil { @@ -1522,7 +1545,9 @@ func handleMatrixAccountData[APIType any, ContentType any]( }) if err != nil { log.Err(err).Msg("Failed to handle Matrix room account data") + return EventHandlingResultFailed.WithError(err) } + return EventHandlingResultSuccess } func (portal *Portal) getTargetUser(ctx context.Context, userID id.UserID) (GhostOrUserLogin, error) { @@ -1547,13 +1572,14 @@ func (portal *Portal) handleMatrixMembership( sender *UserLogin, origSender *OrigSender, evt *event.Event, -) { +) EventHandlingResult { log := zerolog.Ctx(ctx) content, ok := evt.Content.Parsed.(*event.MemberEventContent) if !ok { log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - return + typeErr := fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) + portal.sendErrorStatus(ctx, evt, typeErr) + return EventHandlingResultFailed.WithError(typeErr) } prevContent := &event.MemberEventContent{Membership: event.MembershipLeave} if evt.Unsigned.PrevContent != nil { @@ -1569,7 +1595,7 @@ func (portal *Portal) handleMatrixMembership( api, ok := sender.Client.(MembershipHandlingNetworkAPI) if !ok { portal.sendErrorStatus(ctx, evt, ErrMembershipNotSupported) - return + return EventHandlingResultIgnored } targetMXID := id.UserID(*evt.StateKey) isSelf := sender.User.MXID == targetMXID @@ -1577,14 +1603,14 @@ func (portal *Portal) handleMatrixMembership( if err != nil { log.Err(err).Msg("Failed to get member event target") portal.sendErrorStatus(ctx, evt, err) - return + return EventHandlingResultFailed } membershipChangeType := MembershipChangeType{From: prevContent.Membership, To: content.Membership, IsSelf: isSelf} if !portal.Bridge.Config.BridgeMatrixLeave && membershipChangeType == Leave { log.Debug().Msg("Dropping leave event") //portal.sendErrorStatus(ctx, evt, ErrIgnoringLeaveEvent) - return + return EventHandlingResultIgnored } targetGhost, _ := target.(*Ghost) targetUserLogin, _ := target.(*UserLogin) @@ -1609,8 +1635,9 @@ func (portal *Portal) handleMatrixMembership( if err != nil { log.Err(err).Msg("Failed to handle Matrix membership change") portal.sendErrorStatus(ctx, evt, err) - return + return EventHandlingResultFailed.WithError(err) } + return EventHandlingResultSuccess } func makePLChange(old, new int, newIsSet bool) *SinglePowerLevelChange { @@ -1635,18 +1662,19 @@ func (portal *Portal) handleMatrixPowerLevels( sender *UserLogin, origSender *OrigSender, evt *event.Event, -) { +) EventHandlingResult { log := zerolog.Ctx(ctx) content, ok := evt.Content.Parsed.(*event.PowerLevelsEventContent) if !ok { log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - return + typeErr := fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) + portal.sendErrorStatus(ctx, evt, typeErr) + return EventHandlingResultFailed.WithError(typeErr) } api, ok := sender.Client.(PowerLevelHandlingNetworkAPI) if !ok { portal.sendErrorStatus(ctx, evt, ErrPowerLevelsNotSupported) - return + return EventHandlingResultIgnored } prevContent := &event.PowerLevelsEventContent{} if evt.Unsigned.PrevContent != nil { @@ -1706,17 +1734,21 @@ func (portal *Portal) handleMatrixPowerLevels( if err != nil { log.Err(err).Msg("Failed to handle Matrix power level change") portal.sendErrorStatus(ctx, evt, err) - return + return EventHandlingResultFailed.WithError(err) } + return EventHandlingResultSuccess } -func (portal *Portal) handleMatrixRedaction(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) { +func (portal *Portal) handleMatrixRedaction( + ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, +) EventHandlingResult { log := zerolog.Ctx(ctx) content, ok := evt.Content.Parsed.(*event.RedactionEventContent) if !ok { log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - return + typeErr := fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) + portal.sendErrorStatus(ctx, evt, typeErr) + return EventHandlingResultFailed.WithError(typeErr) } if evt.Redacts != "" && content.Redacts != evt.Redacts { content.Redacts = evt.Redacts @@ -1729,19 +1761,19 @@ func (portal *Portal) handleMatrixRedaction(ctx context.Context, sender *UserLog if !deleteOK && !reactOK { log.Debug().Msg("Ignoring redaction without checking target as network connector doesn't implement RedactionHandlingNetworkAPI nor ReactionHandlingNetworkAPI") portal.sendErrorStatus(ctx, evt, ErrRedactionsNotSupported) - return + return EventHandlingResultIgnored } var redactionTargetReaction *database.Reaction redactionTargetMsg, err := portal.Bridge.DB.Message.GetPartByMXID(ctx, content.Redacts) if err != nil { log.Err(err).Msg("Failed to get redaction target message from database") portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: failed to get redaction target message: %w", ErrDatabaseError, err)) - return + return EventHandlingResultFailed } else if redactionTargetMsg != nil { if !deleteOK { log.Debug().Msg("Ignoring message redaction event as network connector doesn't implement RedactionHandlingNetworkAPI") portal.sendErrorStatus(ctx, evt, ErrRedactionsNotSupported) - return + return EventHandlingResultIgnored } err = deletingAPI.HandleMatrixMessageRemove(ctx, &MatrixMessageRemove{ MatrixEventBase: MatrixEventBase[*event.RedactionEventContent]{ @@ -1757,12 +1789,12 @@ func (portal *Portal) handleMatrixRedaction(ctx context.Context, sender *UserLog } else if redactionTargetReaction, err = portal.Bridge.DB.Reaction.GetByMXID(ctx, content.Redacts); err != nil { log.Err(err).Msg("Failed to get redaction target reaction from database") portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: failed to get redaction target message reaction: %w", ErrDatabaseError, err)) - return + return EventHandlingResultFailed } else if redactionTargetReaction != nil { if !reactOK { log.Debug().Msg("Ignoring reaction redaction event as network connector doesn't implement ReactionHandlingNetworkAPI") portal.sendErrorStatus(ctx, evt, ErrReactionsNotSupported) - return + return EventHandlingResultIgnored } // TODO ignore if sender doesn't match? err = reactingAPI.HandleMatrixReactionRemove(ctx, &MatrixReactionRemove{ @@ -1778,16 +1810,18 @@ func (portal *Portal) handleMatrixRedaction(ctx context.Context, sender *UserLog }) } else { log.Debug().Msg("Redaction target message not found in database") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("redaction %w", ErrTargetMessageNotFound)) - return + notFoundErr := fmt.Errorf("redaction %w", ErrTargetMessageNotFound) + portal.sendErrorStatus(ctx, evt, notFoundErr) + return EventHandlingResultIgnored } if err != nil { log.Err(err).Msg("Failed to handle Matrix redaction") portal.sendErrorStatus(ctx, evt, err) - return + return EventHandlingResultFailed.WithError(err) } // TODO delete msg/reaction db row portal.sendSuccessStatus(ctx, evt, 0, "") + return EventHandlingResultSuccess } func (portal *Portal) handleRemoteEvent(ctx context.Context, source *UserLogin, evtType RemoteEventType, evt RemoteEvent) (res EventHandlingResult) { diff --git a/bridgev2/portalinternal.go b/bridgev2/portalinternal.go index 6815f043..ae338383 100644 --- a/bridgev2/portalinternal.go +++ b/bridgev2/portalinternal.go @@ -61,20 +61,20 @@ func (portal *PortalInternals) CheckConfusableName(ctx context.Context, userID i return (*Portal)(portal).checkConfusableName(ctx, userID, name) } -func (portal *PortalInternals) HandleMatrixEvent(ctx context.Context, sender *User, evt *event.Event) { - (*Portal)(portal).handleMatrixEvent(ctx, sender, evt) +func (portal *PortalInternals) HandleMatrixEvent(ctx context.Context, sender *User, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixEvent(ctx, sender, evt) } -func (portal *PortalInternals) HandleMatrixReceipts(ctx context.Context, evt *event.Event) { - (*Portal)(portal).handleMatrixReceipts(ctx, evt) +func (portal *PortalInternals) HandleMatrixReceipts(ctx context.Context, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixReceipts(ctx, evt) } func (portal *PortalInternals) HandleMatrixReadReceipt(ctx context.Context, user *User, eventID id.EventID, receipt event.ReadReceipt) { (*Portal)(portal).handleMatrixReadReceipt(ctx, user, eventID, receipt) } -func (portal *PortalInternals) HandleMatrixTyping(ctx context.Context, evt *event.Event) { - (*Portal)(portal).handleMatrixTyping(ctx, evt) +func (portal *PortalInternals) HandleMatrixTyping(ctx context.Context, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixTyping(ctx, evt) } func (portal *PortalInternals) SendTypings(ctx context.Context, userIDs []id.UserID, typing bool) { @@ -93,8 +93,8 @@ func (portal *PortalInternals) ParseInputTransactionID(origSender *OrigSender, e return (*Portal)(portal).parseInputTransactionID(origSender, evt) } -func (portal *PortalInternals) HandleMatrixMessage(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) { - (*Portal)(portal).handleMatrixMessage(ctx, sender, origSender, evt) +func (portal *PortalInternals) HandleMatrixMessage(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixMessage(ctx, sender, origSender, evt) } func (portal *PortalInternals) PendingMessageTimeoutLoop(ctx context.Context, cfg *OutgoingTimeoutConfig) { @@ -105,28 +105,28 @@ func (portal *PortalInternals) CheckPendingMessages(ctx context.Context, cfg *Ou (*Portal)(portal).checkPendingMessages(ctx, cfg) } -func (portal *PortalInternals) HandleMatrixEdit(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, content *event.MessageEventContent, caps *event.RoomFeatures) { - (*Portal)(portal).handleMatrixEdit(ctx, sender, origSender, evt, content, caps) +func (portal *PortalInternals) HandleMatrixEdit(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, content *event.MessageEventContent, caps *event.RoomFeatures) EventHandlingResult { + return (*Portal)(portal).handleMatrixEdit(ctx, sender, origSender, evt, content, caps) } -func (portal *PortalInternals) HandleMatrixReaction(ctx context.Context, sender *UserLogin, evt *event.Event) { - (*Portal)(portal).handleMatrixReaction(ctx, sender, evt) +func (portal *PortalInternals) HandleMatrixReaction(ctx context.Context, sender *UserLogin, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixReaction(ctx, sender, evt) } func (portal *PortalInternals) GetTargetUser(ctx context.Context, userID id.UserID) (GhostOrUserLogin, error) { return (*Portal)(portal).getTargetUser(ctx, userID) } -func (portal *PortalInternals) HandleMatrixMembership(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) { - (*Portal)(portal).handleMatrixMembership(ctx, sender, origSender, evt) +func (portal *PortalInternals) HandleMatrixMembership(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixMembership(ctx, sender, origSender, evt) } -func (portal *PortalInternals) HandleMatrixPowerLevels(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) { - (*Portal)(portal).handleMatrixPowerLevels(ctx, sender, origSender, evt) +func (portal *PortalInternals) HandleMatrixPowerLevels(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixPowerLevels(ctx, sender, origSender, evt) } -func (portal *PortalInternals) HandleMatrixRedaction(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) { - (*Portal)(portal).handleMatrixRedaction(ctx, sender, origSender, evt) +func (portal *PortalInternals) HandleMatrixRedaction(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixRedaction(ctx, sender, origSender, evt) } func (portal *PortalInternals) HandleRemoteEvent(ctx context.Context, source *UserLogin, evtType RemoteEventType, evt RemoteEvent) (res EventHandlingResult) { diff --git a/bridgev2/queue.go b/bridgev2/queue.go index 48ee78f1..4a107d36 100644 --- a/bridgev2/queue.go +++ b/bridgev2/queue.go @@ -63,7 +63,7 @@ func (br *Bridge) rejectInviteOnNoPermission(ctx context.Context, evt *event.Eve return true } -func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) { +func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) EventHandlingResult { // TODO maybe HandleMatrixEvent would be more appropriate as this also handles bot invites and commands log := zerolog.Ctx(ctx) @@ -75,26 +75,26 @@ func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) { log.Err(err).Msg("Failed to get sender user for incoming Matrix event") status := WrapErrorInStatus(fmt.Errorf("%w: failed to get sender user: %w", ErrDatabaseError, err)) br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) - return + return EventHandlingResultFailed } else if sender == nil { log.Error().Msg("Couldn't get sender for incoming non-ephemeral Matrix event") status := WrapErrorInStatus(errors.New("sender not found for event")).WithIsCertain(true).WithErrorAsMessage() br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) - return + return EventHandlingResultFailed } else if !sender.Permissions.SendEvents { if !br.rejectInviteOnNoPermission(ctx, evt, "interact with") { status := WrapErrorInStatus(errors.New("you don't have permission to send messages")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage() br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) } - return + return EventHandlingResultIgnored } else if !sender.Permissions.Commands && br.rejectInviteOnNoPermission(ctx, evt, "send commands to") { - return + return EventHandlingResultIgnored } } else if evt.Type.Class != event.EphemeralEventType { log.Error().Msg("Missing sender for incoming non-ephemeral Matrix event") status := WrapErrorInStatus(errors.New("sender not found for event")).WithIsCertain(true).WithErrorAsMessage() br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) - return + return EventHandlingResultIgnored } if evt.Type == event.EventMessage && sender != nil { msg := evt.Content.AsMessage() @@ -104,7 +104,7 @@ func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) { if !sender.Permissions.Commands { status := WrapErrorInStatus(errors.New("you don't have permission to use commands")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage() br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) - return + return EventHandlingResultIgnored } br.Commands.Handle( ctx, @@ -114,40 +114,41 @@ func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) { strings.TrimPrefix(msg.Body, br.Config.CommandPrefix+" "), msg.RelatesTo.GetReplyTo(), ) - return + return EventHandlingResultSuccess } } if evt.Type == event.StateMember && evt.GetStateKey() == br.Bot.GetMXID().String() && evt.Content.AsMember().Membership == event.MembershipInvite && sender != nil { - br.handleBotInvite(ctx, evt, sender) - return + return br.handleBotInvite(ctx, evt, sender) } else if sender != nil && evt.RoomID == sender.ManagementRoom { if evt.Type == event.StateMember && evt.Content.AsMember().Membership == event.MembershipLeave && (evt.GetStateKey() == br.Bot.GetMXID().String() || evt.GetStateKey() == sender.MXID.String()) { sender.ManagementRoom = "" err := br.DB.User.Update(ctx, sender.User) if err != nil { log.Err(err).Msg("Failed to clear user's management room in database") + return EventHandlingResultFailed } else { log.Debug().Msg("Cleared user's management room due to leave event") } } - return + return EventHandlingResultSuccess } portal, err := br.GetPortalByMXID(ctx, evt.RoomID) if err != nil { log.Err(err).Msg("Failed to get portal for incoming Matrix event") status := WrapErrorInStatus(fmt.Errorf("%w: failed to get portal: %w", ErrDatabaseError, err)) br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) - return + return EventHandlingResultFailed } else if portal != nil { - portal.queueEvent(ctx, &portalMatrixEvent{ + return portal.queueEvent(ctx, &portalMatrixEvent{ evt: evt, sender: sender, }) } else if evt.Type == event.StateMember && br.IsGhostMXID(id.UserID(evt.GetStateKey())) && evt.Content.AsMember().Membership == event.MembershipInvite && evt.Content.AsMember().IsDirect { - br.handleGhostDMInvite(ctx, evt, sender) + return br.handleGhostDMInvite(ctx, evt, sender) } else { status := WrapErrorInStatus(ErrNoPortal) br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) + return EventHandlingResultIgnored } } @@ -155,6 +156,18 @@ type EventHandlingResult struct { Success bool Ignored bool Queued bool + + // Error is an optional reason for failure. It is not required, Success may be false even without a specific error. + Error error +} + +func (ehr EventHandlingResult) WithError(err error) EventHandlingResult { + if err == nil { + return ehr + } + ehr.Error = err + ehr.Success = false + return ehr } var ( From 4f8ff2a35079a0cea77e2855a636b38fffe3dfff Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 10 Jul 2025 15:04:57 +0300 Subject: [PATCH 159/581] bridgev2/portal: merge MSS errors with handling result --- bridgev2/portal.go | 191 ++++++++++++++----------------------- bridgev2/portalinternal.go | 4 +- bridgev2/queue.go | 14 +++ 3 files changed, 88 insertions(+), 121 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 900d057d..136ecd12 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -447,6 +447,13 @@ func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCal switch evt := rawEvt.(type) { case *portalMatrixEvent: res = portal.handleMatrixEvent(ctx, evt.sender, evt.evt) + if res.SendMSS { + if res.Error != nil { + portal.sendErrorStatus(ctx, evt.evt, res.Error) + } else { + portal.sendSuccessStatus(ctx, evt.evt, 0, "") + } + } case *portalRemoteEvent: res = portal.handleRemoteEvent(ctx, evt.source, evt.evtType, evt.evt) case *portalCreateEvent: @@ -569,11 +576,14 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * log.Err(err).Msg("Failed to get user login to handle Matrix event") if errors.Is(err, ErrNotLoggedIn) { shouldSendNotice := evt.Content.AsMessage().MsgType != event.MsgNotice - portal.sendErrorStatus(ctx, evt, WrapErrorInStatus(err).WithMessage("You're not logged in").WithIsCertain(true).WithSendNotice(shouldSendNotice)) + return EventHandlingResultFailed.WithMSSError( + WrapErrorInStatus(err).WithMessage("You're not logged in").WithIsCertain(true).WithSendNotice(shouldSendNotice), + ) } else { - portal.sendErrorStatus(ctx, evt, WrapErrorInStatus(err).WithMessage("Failed to get login to handle event").WithIsCertain(true).WithSendNotice(true)) + return EventHandlingResultFailed.WithMSSError( + WrapErrorInStatus(err).WithMessage("Failed to get login to handle event").WithIsCertain(true).WithSendNotice(true), + ) } - return EventHandlingResultFailed } var origSender *OrigSender if login == nil { @@ -626,8 +636,7 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * case event.EventReaction: if origSender != nil { log.Debug().Msg("Ignoring reaction event from relayed user") - portal.sendErrorStatus(ctx, evt, ErrIgnoringReactionFromRelayedUser) - return EventHandlingResultIgnored + return EventHandlingResultIgnored.WithMSSError(ErrIgnoringReactionFromRelayedUser) } return portal.handleMatrixReaction(ctx, login, evt) case event.EventRedaction: @@ -848,39 +857,35 @@ func (portal *Portal) periodicTypingUpdater() { } } -func (portal *Portal) checkMessageContentCaps(ctx context.Context, caps *event.RoomFeatures, content *event.MessageEventContent, evt *event.Event) bool { +func (portal *Portal) checkMessageContentCaps(caps *event.RoomFeatures, content *event.MessageEventContent) error { switch content.MsgType { case event.MsgText, event.MsgNotice, event.MsgEmote: // No checks for now, message length is safer to check after conversion inside connector case event.MsgLocation: if caps.LocationMessage.Reject() { - portal.sendErrorStatus(ctx, evt, ErrLocationMessagesNotAllowed) - return false + return ErrLocationMessagesNotAllowed } case event.MsgImage, event.MsgAudio, event.MsgVideo, event.MsgFile, event.CapMsgSticker: capMsgType := content.GetCapMsgType() feat, ok := caps.File[capMsgType] if !ok { - portal.sendErrorStatus(ctx, evt, ErrUnsupportedMessageType) - return false + return ErrUnsupportedMessageType } if content.MsgType != event.CapMsgSticker && content.FileName != "" && content.Body != content.FileName && feat.Caption.Reject() { - portal.sendErrorStatus(ctx, evt, ErrCaptionsNotAllowed) - return false + return ErrCaptionsNotAllowed } if content.Info != nil && content.Info.MimeType != "" { if feat.GetMimeSupport(content.Info.MimeType).Reject() { - portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w (%s in %s)", ErrUnsupportedMediaType, content.Info.MimeType, capMsgType)) - return false + return fmt.Errorf("%w (%s in %s)", ErrUnsupportedMediaType, content.Info.MimeType, capMsgType) } } fallthrough default: } - return true + return nil } func (portal *Portal) parseInputTransactionID(origSender *OrigSender, evt *event.Event) networkid.RawTransactionID { @@ -910,23 +915,20 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin msgContent.MsgType = event.CapMsgSticker } if msgContent.MsgType == event.MsgNotice && !portal.Bridge.Config.BridgeNotices { - portal.sendErrorStatus(ctx, evt, ErrIgnoringMNotice) - return EventHandlingResultIgnored + return EventHandlingResultIgnored.WithMSSError(ErrIgnoringMNotice) } } if !ok { log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - typeErr := fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) - portal.sendErrorStatus(ctx, evt, typeErr) - return EventHandlingResultFailed.WithError(typeErr) + return EventHandlingResultFailed. + WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) } caps := sender.Client.GetCapabilities(ctx, portal) if relatesTo.GetReplaceID() != "" { if msgContent == nil { log.Warn().Msg("Ignoring edit of poll") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w of polls", ErrEditsNotSupported)) - return EventHandlingResultFailed.WithError(fmt.Errorf("%w of polls", ErrEditsNotSupported)) + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w of polls", ErrEditsNotSupported)) } return portal.handleMatrixEdit(ctx, sender, origSender, evt, msgContent, caps) } @@ -934,25 +936,22 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin if origSender != nil { if msgContent == nil { log.Debug().Msg("Ignoring poll event from relayed user") - portal.sendErrorStatus(ctx, evt, ErrIgnoringPollFromRelayedUser) - return EventHandlingResultIgnored + return EventHandlingResultIgnored.WithMSSError(ErrIgnoringPollFromRelayedUser) } msgContent, err = portal.Bridge.Config.Relay.FormatMessage(msgContent, origSender) if err != nil { log.Err(err).Msg("Failed to format message for relaying") - portal.sendErrorStatus(ctx, evt, err) - return EventHandlingResultFailed.WithError(err) + return EventHandlingResultFailed.WithMSSError(err) } } if msgContent != nil { - if !portal.checkMessageContentCaps(ctx, caps, msgContent, evt) { - return EventHandlingResultFailed + if err = portal.checkMessageContentCaps(caps, msgContent); err != nil { + return EventHandlingResultFailed.WithMSSError(err) } } else if pollResponseContent != nil || pollContent != nil { if _, ok = sender.Client.(PollHandlingNetworkAPI); !ok { log.Debug().Msg("Ignoring poll event as network connector doesn't implement PollHandlingNetworkAPI") - portal.sendErrorStatus(ctx, evt, ErrPollsNotSupported) - return EventHandlingResultIgnored + return EventHandlingResultIgnored.WithMSSError(ErrPollsNotSupported) } } @@ -1051,13 +1050,11 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin }) } else { log.Error().Msg("Failed to handle Matrix message: all contents are nil?") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("all contents are nil")) - return EventHandlingResultFailed + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("all contents are nil")) } if err != nil { log.Err(err).Msg("Failed to handle Matrix message") - portal.sendErrorStatus(ctx, evt, err) - return EventHandlingResultFailed.WithError(err) + return EventHandlingResultFailed.WithMSSError(err) } message := wrappedMsgEvt.fillDBMessage(resp.DB) if resp.Pending { @@ -1235,39 +1232,31 @@ func (portal *Portal) handleMatrixEdit( content, err = portal.Bridge.Config.Relay.FormatMessage(content, origSender) if err != nil { log.Err(err).Msg("Failed to format message for relaying") - portal.sendErrorStatus(ctx, evt, err) - return EventHandlingResultFailed.WithError(err) + return EventHandlingResultFailed.WithMSSError(err) } } editingAPI, ok := sender.Client.(EditHandlingNetworkAPI) if !ok { log.Debug().Msg("Ignoring edit as network connector doesn't implement EditHandlingNetworkAPI") - portal.sendErrorStatus(ctx, evt, ErrEditsNotSupported) - return EventHandlingResultIgnored + return EventHandlingResultIgnored.WithMSSError(ErrEditsNotSupported) } else if !caps.Edit.Partial() { log.Debug().Msg("Ignoring edit as room doesn't support edits") - portal.sendErrorStatus(ctx, evt, ErrEditsNotSupportedInPortal) - return EventHandlingResultIgnored - } else if !portal.checkMessageContentCaps(ctx, caps, content, evt) { - return EventHandlingResultFailed + return EventHandlingResultIgnored.WithMSSError(ErrEditsNotSupportedInPortal) + } else if err := portal.checkMessageContentCaps(caps, content); err != nil { + return EventHandlingResultFailed.WithMSSError(err) } editTarget, err := portal.Bridge.DB.Message.GetPartByMXID(ctx, editTargetID) if err != nil { log.Err(err).Msg("Failed to get edit target message from database") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: failed to get edit target: %w", ErrDatabaseError, err)) - return EventHandlingResultFailed + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get edit target: %w", ErrDatabaseError, err)) } else if editTarget == nil { log.Warn().Msg("Edit target message not found in database") - notFoundErr := fmt.Errorf("edit %w", ErrTargetMessageNotFound) - portal.sendErrorStatus(ctx, evt, notFoundErr) - return EventHandlingResultFailed.WithError(notFoundErr) + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("edit %w", ErrTargetMessageNotFound)) } else if caps.EditMaxAge != nil && caps.EditMaxAge.Duration > 0 && time.Since(editTarget.Timestamp) > caps.EditMaxAge.Duration { - portal.sendErrorStatus(ctx, evt, ErrEditTargetTooOld) - return EventHandlingResultFailed.WithError(ErrEditTargetTooOld) + return EventHandlingResultFailed.WithMSSError(ErrEditTargetTooOld) } else if caps.EditMaxCount > 0 && editTarget.EditCount >= caps.EditMaxCount { - portal.sendErrorStatus(ctx, evt, ErrEditTargetTooManyEdits) - return EventHandlingResultFailed.WithError(ErrEditTargetTooManyEdits) + return EventHandlingResultFailed.WithMSSError(ErrEditTargetTooManyEdits) } log.UpdateContext(func(c zerolog.Context) zerolog.Context { return c.Str("edit_target_remote_id", string(editTarget.ID)) @@ -1285,8 +1274,7 @@ func (portal *Portal) handleMatrixEdit( }) if err != nil { log.Err(err).Msg("Failed to handle Matrix edit") - portal.sendErrorStatus(ctx, evt, err) - return EventHandlingResultFailed.WithError(err) + return EventHandlingResultFailed.WithMSSError(err) } err = portal.Bridge.DB.Message.Update(ctx, editTarget) if err != nil { @@ -1302,15 +1290,12 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi reactingAPI, ok := sender.Client.(ReactionHandlingNetworkAPI) if !ok { log.Debug().Msg("Ignoring reaction as network connector doesn't implement ReactionHandlingNetworkAPI") - portal.sendErrorStatus(ctx, evt, ErrReactionsNotSupported) - return EventHandlingResultIgnored + return EventHandlingResultIgnored.WithMSSError(ErrReactionsNotSupported) } content, ok := evt.Content.Parsed.(*event.ReactionEventContent) if !ok { log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - typeErr := fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) - portal.sendErrorStatus(ctx, evt, typeErr) - return EventHandlingResultFailed.WithError(typeErr) + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) } log.UpdateContext(func(c zerolog.Context) zerolog.Context { return c.Stringer("reaction_target_mxid", content.RelatesTo.EventID) @@ -1318,13 +1303,10 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi reactionTarget, err := portal.Bridge.DB.Message.GetPartByMXID(ctx, content.RelatesTo.EventID) if err != nil { log.Err(err).Msg("Failed to get reaction target message from database") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: failed to get reaction target: %w", ErrDatabaseError, err)) - return EventHandlingResultFailed + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get reaction target: %w", ErrDatabaseError, err)) } else if reactionTarget == nil { log.Warn().Msg("Reaction target message not found in database") - notFoundErr := fmt.Errorf("reaction %w", ErrTargetMessageNotFound) - portal.sendErrorStatus(ctx, evt, notFoundErr) - return EventHandlingResultFailed.WithError(notFoundErr) + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("reaction %w", ErrTargetMessageNotFound)) } log.UpdateContext(func(c zerolog.Context) zerolog.Context { return c.Str("reaction_target_remote_id", string(reactionTarget.ID)) @@ -1342,8 +1324,7 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi preResp, err := reactingAPI.PreHandleMatrixReaction(ctx, react) if err != nil { log.Err(err).Msg("Failed to pre-handle Matrix reaction") - portal.sendErrorStatus(ctx, evt, err) - return EventHandlingResultFailed.WithError(err) + return EventHandlingResultFailed.WithMSSError(err) } var deterministicID id.EventID if portal.Bridge.Config.OutgoingMessageReID { @@ -1352,7 +1333,7 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi existing, err := portal.Bridge.DB.Reaction.GetByID(ctx, portal.Receiver, reactionTarget.ID, reactionTarget.PartID, preResp.SenderID, preResp.EmojiID) if err != nil { log.Err(err).Msg("Failed to check if reaction is a duplicate") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to check for existing reaction: %w", ErrDatabaseError, err)) } else if existing != nil { if existing.EmojiID != "" || existing.Emoji == preResp.Emoji { log.Debug().Msg("Ignoring duplicate reaction") @@ -1374,8 +1355,7 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi allReactions, err := portal.Bridge.DB.Reaction.GetAllToMessageBySender(ctx, portal.Receiver, reactionTarget.ID, preResp.SenderID) if err != nil { log.Err(err).Msg("Failed to get all reactions to message by sender") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: failed to get previous reactions: %w", ErrDatabaseError, err)) - return EventHandlingResultFailed + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get previous reactions: %w", ErrDatabaseError, err)) } if len(allReactions) < preResp.MaxReactions { react.ExistingReactionsToKeep = allReactions @@ -1401,8 +1381,7 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi dbReaction, err := reactingAPI.HandleMatrixReaction(ctx, react) if err != nil { log.Err(err).Msg("Failed to handle Matrix reaction") - portal.sendErrorStatus(ctx, evt, err) - return EventHandlingResultFailed.WithError(err) + return EventHandlingResultFailed.WithMSSError(err) } if dbReaction == nil { dbReaction = &database.Reaction{} @@ -1454,16 +1433,13 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( ) EventHandlingResult { api, ok := sender.Client.(APIType) if !ok { - portal.sendErrorStatus(ctx, evt, ErrRoomMetadataNotSupported) - return EventHandlingResultIgnored + return EventHandlingResultIgnored.WithMSSError(ErrRoomMetadataNotSupported) } log := zerolog.Ctx(ctx) content, ok := evt.Content.Parsed.(ContentType) if !ok { log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - typeErr := fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) - portal.sendErrorStatus(ctx, evt, typeErr) - return EventHandlingResultFailed.WithError(typeErr) + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) } switch typedContent := evt.Content.Parsed.(type) { case *event.RoomNameEventContent: @@ -1501,8 +1477,7 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( }) if err != nil { log.Err(err).Msg("Failed to handle Matrix room metadata") - portal.sendErrorStatus(ctx, evt, err) - return EventHandlingResultFailed.WithError(err) + return EventHandlingResultFailed.WithMSSError(err) } if changed { portal.UpdateBridgeInfo(ctx) @@ -1511,8 +1486,7 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( log.Err(err).Msg("Failed to save portal after updating room metadata") } } - portal.sendSuccessStatus(ctx, evt, 0, "") - return EventHandlingResultSuccess + return EventHandlingResultSuccess.WithMSS() } func handleMatrixAccountData[APIType any, ContentType any]( @@ -1577,9 +1551,7 @@ func (portal *Portal) handleMatrixMembership( content, ok := evt.Content.Parsed.(*event.MemberEventContent) if !ok { log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - typeErr := fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) - portal.sendErrorStatus(ctx, evt, typeErr) - return EventHandlingResultFailed.WithError(typeErr) + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) } prevContent := &event.MemberEventContent{Membership: event.MembershipLeave} if evt.Unsigned.PrevContent != nil { @@ -1594,23 +1566,20 @@ func (portal *Portal) handleMatrixMembership( }) api, ok := sender.Client.(MembershipHandlingNetworkAPI) if !ok { - portal.sendErrorStatus(ctx, evt, ErrMembershipNotSupported) - return EventHandlingResultIgnored + return EventHandlingResultIgnored.WithMSSError(ErrMembershipNotSupported) } targetMXID := id.UserID(*evt.StateKey) isSelf := sender.User.MXID == targetMXID target, err := portal.getTargetUser(ctx, targetMXID) if err != nil { log.Err(err).Msg("Failed to get member event target") - portal.sendErrorStatus(ctx, evt, err) - return EventHandlingResultFailed + return EventHandlingResultFailed.WithMSSError(err) } membershipChangeType := MembershipChangeType{From: prevContent.Membership, To: content.Membership, IsSelf: isSelf} if !portal.Bridge.Config.BridgeMatrixLeave && membershipChangeType == Leave { log.Debug().Msg("Dropping leave event") - //portal.sendErrorStatus(ctx, evt, ErrIgnoringLeaveEvent) - return EventHandlingResultIgnored + return EventHandlingResultIgnored //.WithMSSError(ErrIgnoringLeaveEvent) } targetGhost, _ := target.(*Ghost) targetUserLogin, _ := target.(*UserLogin) @@ -1634,10 +1603,9 @@ func (portal *Portal) handleMatrixMembership( _, err = api.HandleMatrixMembership(ctx, membershipChange) if err != nil { log.Err(err).Msg("Failed to handle Matrix membership change") - portal.sendErrorStatus(ctx, evt, err) - return EventHandlingResultFailed.WithError(err) + return EventHandlingResultFailed.WithMSSError(err) } - return EventHandlingResultSuccess + return EventHandlingResultSuccess.WithMSS() } func makePLChange(old, new int, newIsSet bool) *SinglePowerLevelChange { @@ -1667,14 +1635,11 @@ func (portal *Portal) handleMatrixPowerLevels( content, ok := evt.Content.Parsed.(*event.PowerLevelsEventContent) if !ok { log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - typeErr := fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) - portal.sendErrorStatus(ctx, evt, typeErr) - return EventHandlingResultFailed.WithError(typeErr) + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) } api, ok := sender.Client.(PowerLevelHandlingNetworkAPI) if !ok { - portal.sendErrorStatus(ctx, evt, ErrPowerLevelsNotSupported) - return EventHandlingResultIgnored + return EventHandlingResultIgnored.WithMSSError(ErrPowerLevelsNotSupported) } prevContent := &event.PowerLevelsEventContent{} if evt.Unsigned.PrevContent != nil { @@ -1733,10 +1698,9 @@ func (portal *Portal) handleMatrixPowerLevels( _, err := api.HandleMatrixPowerLevels(ctx, plChange) if err != nil { log.Err(err).Msg("Failed to handle Matrix power level change") - portal.sendErrorStatus(ctx, evt, err) - return EventHandlingResultFailed.WithError(err) + return EventHandlingResultFailed.WithMSSError(err) } - return EventHandlingResultSuccess + return EventHandlingResultSuccess.WithMSS() } func (portal *Portal) handleMatrixRedaction( @@ -1746,9 +1710,7 @@ func (portal *Portal) handleMatrixRedaction( content, ok := evt.Content.Parsed.(*event.RedactionEventContent) if !ok { log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - typeErr := fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) - portal.sendErrorStatus(ctx, evt, typeErr) - return EventHandlingResultFailed.WithError(typeErr) + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) } if evt.Redacts != "" && content.Redacts != evt.Redacts { content.Redacts = evt.Redacts @@ -1760,20 +1722,17 @@ func (portal *Portal) handleMatrixRedaction( reactingAPI, reactOK := sender.Client.(ReactionHandlingNetworkAPI) if !deleteOK && !reactOK { log.Debug().Msg("Ignoring redaction without checking target as network connector doesn't implement RedactionHandlingNetworkAPI nor ReactionHandlingNetworkAPI") - portal.sendErrorStatus(ctx, evt, ErrRedactionsNotSupported) - return EventHandlingResultIgnored + return EventHandlingResultIgnored.WithMSSError(ErrRedactionsNotSupported) } var redactionTargetReaction *database.Reaction redactionTargetMsg, err := portal.Bridge.DB.Message.GetPartByMXID(ctx, content.Redacts) if err != nil { log.Err(err).Msg("Failed to get redaction target message from database") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: failed to get redaction target message: %w", ErrDatabaseError, err)) - return EventHandlingResultFailed + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get redaction target message: %w", ErrDatabaseError, err)) } else if redactionTargetMsg != nil { if !deleteOK { log.Debug().Msg("Ignoring message redaction event as network connector doesn't implement RedactionHandlingNetworkAPI") - portal.sendErrorStatus(ctx, evt, ErrRedactionsNotSupported) - return EventHandlingResultIgnored + return EventHandlingResultIgnored.WithMSSError(ErrRedactionsNotSupported) } err = deletingAPI.HandleMatrixMessageRemove(ctx, &MatrixMessageRemove{ MatrixEventBase: MatrixEventBase[*event.RedactionEventContent]{ @@ -1788,13 +1747,11 @@ func (portal *Portal) handleMatrixRedaction( }) } else if redactionTargetReaction, err = portal.Bridge.DB.Reaction.GetByMXID(ctx, content.Redacts); err != nil { log.Err(err).Msg("Failed to get redaction target reaction from database") - portal.sendErrorStatus(ctx, evt, fmt.Errorf("%w: failed to get redaction target message reaction: %w", ErrDatabaseError, err)) - return EventHandlingResultFailed + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get redaction target message reaction: %w", ErrDatabaseError, err)) } else if redactionTargetReaction != nil { if !reactOK { log.Debug().Msg("Ignoring reaction redaction event as network connector doesn't implement ReactionHandlingNetworkAPI") - portal.sendErrorStatus(ctx, evt, ErrReactionsNotSupported) - return EventHandlingResultIgnored + return EventHandlingResultIgnored.WithMSSError(ErrReactionsNotSupported) } // TODO ignore if sender doesn't match? err = reactingAPI.HandleMatrixReactionRemove(ctx, &MatrixReactionRemove{ @@ -1810,18 +1767,14 @@ func (portal *Portal) handleMatrixRedaction( }) } else { log.Debug().Msg("Redaction target message not found in database") - notFoundErr := fmt.Errorf("redaction %w", ErrTargetMessageNotFound) - portal.sendErrorStatus(ctx, evt, notFoundErr) - return EventHandlingResultIgnored + return EventHandlingResultIgnored.WithMSSError(fmt.Errorf("redaction %w", ErrTargetMessageNotFound)) } if err != nil { log.Err(err).Msg("Failed to handle Matrix redaction") - portal.sendErrorStatus(ctx, evt, err) - return EventHandlingResultFailed.WithError(err) + return EventHandlingResultFailed.WithMSSError(err) } // TODO delete msg/reaction db row - portal.sendSuccessStatus(ctx, evt, 0, "") - return EventHandlingResultSuccess + return EventHandlingResultSuccess.WithMSS() } func (portal *Portal) handleRemoteEvent(ctx context.Context, source *UserLogin, evtType RemoteEventType, evt RemoteEvent) (res EventHandlingResult) { diff --git a/bridgev2/portalinternal.go b/bridgev2/portalinternal.go index ae338383..e82c481a 100644 --- a/bridgev2/portalinternal.go +++ b/bridgev2/portalinternal.go @@ -85,8 +85,8 @@ func (portal *PortalInternals) PeriodicTypingUpdater() { (*Portal)(portal).periodicTypingUpdater() } -func (portal *PortalInternals) CheckMessageContentCaps(ctx context.Context, caps *event.RoomFeatures, content *event.MessageEventContent, evt *event.Event) bool { - return (*Portal)(portal).checkMessageContentCaps(ctx, caps, content, evt) +func (portal *PortalInternals) CheckMessageContentCaps(caps *event.RoomFeatures, content *event.MessageEventContent) error { + return (*Portal)(portal).checkMessageContentCaps(caps, content) } func (portal *PortalInternals) ParseInputTransactionID(origSender *OrigSender, evt *event.Event) networkid.RawTransactionID { diff --git a/bridgev2/queue.go b/bridgev2/queue.go index 4a107d36..04d982b5 100644 --- a/bridgev2/queue.go +++ b/bridgev2/queue.go @@ -159,6 +159,8 @@ type EventHandlingResult struct { // Error is an optional reason for failure. It is not required, Success may be false even without a specific error. Error error + // Whether the Error should be sent as a MSS event. + SendMSS bool } func (ehr EventHandlingResult) WithError(err error) EventHandlingResult { @@ -170,6 +172,18 @@ func (ehr EventHandlingResult) WithError(err error) EventHandlingResult { return ehr } +func (ehr EventHandlingResult) WithMSS() EventHandlingResult { + ehr.SendMSS = true + return ehr +} + +func (ehr EventHandlingResult) WithMSSError(err error) EventHandlingResult { + if err == nil { + return ehr + } + return ehr.WithError(err).WithMSS() +} + var ( EventHandlingResultFailed = EventHandlingResult{} EventHandlingResultQueued = EventHandlingResult{Success: true, Queued: true} From 5e29bac3dd9ce315be28f20aed9b03e19333f49c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 10 Jul 2025 16:19:37 +0300 Subject: [PATCH 160/581] bridgev2/portal: adjust handleMatrixMessage return value for pending messages --- bridgev2/portal.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 136ecd12..1fab94e6 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1096,6 +1096,10 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin }, }) } + if resp.Pending { + // Not exactly queued, but not finished either + return EventHandlingResultQueued + } return EventHandlingResultSuccess } From b74368ac2302a10d9805a548e68fdc306d0526d1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 15 Jul 2025 13:19:44 +0300 Subject: [PATCH 161/581] commands: add safety to type check --- commands/event.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commands/event.go b/commands/event.go index 65ddd3da..77a3c0d2 100644 --- a/commands/event.go +++ b/commands/event.go @@ -62,8 +62,8 @@ var IDHTMLParser = &format.HTMLParser{ // ParseEvent parses a message into a command event struct. func ParseEvent[MetaType any](ctx context.Context, evt *event.Event) *Event[MetaType] { - content := evt.Content.Parsed.(*event.MessageEventContent) - if content.MsgType == event.MsgNotice || content.RelatesTo.GetReplaceID() != "" { + content, ok := evt.Content.Parsed.(*event.MessageEventContent) + if !ok || content.MsgType == event.MsgNotice || content.RelatesTo.GetReplaceID() != "" { return nil } text := content.Body From 687717bd73cd6f163af3843ad0bf486826716810 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 15 Jul 2025 13:44:45 +0300 Subject: [PATCH 162/581] bridgev2: hardcode room v11 for new rooms Upcoming breaking changes in room v12 prevent safely using the default room version and security embargoes prevent fixing them ahead of time. --- bridgev2/portal.go | 1 + bridgev2/space.go | 3 ++- bridgev2/user.go | 5 +++-- requests.go | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 1fab94e6..1d8faa1a 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4064,6 +4064,7 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo IsDirect: portal.RoomType == database.RoomTypeDM, PowerLevelOverride: powerLevels, BeeperLocalRoomID: portal.Bridge.Matrix.GenerateDeterministicRoomID(portal.PortalKey), + RoomVersion: event.RoomV11, } autoJoinInvites := portal.Bridge.Matrix.GetCapabilities().AutoJoinInvites if autoJoinInvites { diff --git a/bridgev2/space.go b/bridgev2/space.go index 11de9cfa..ccb74b26 100644 --- a/bridgev2/space.go +++ b/bridgev2/space.go @@ -164,7 +164,8 @@ func (ul *UserLogin) GetSpaceRoom(ctx context.Context) (id.RoomID, error) { ul.UserMXID: 50, }, }, - Invite: []id.UserID{ul.UserMXID}, + RoomVersion: event.RoomV11, + Invite: []id.UserID{ul.UserMXID}, } if autoJoin { req.BeeperInitialMembers = []id.UserID{ul.UserMXID} diff --git a/bridgev2/user.go b/bridgev2/user.go index e6a5dd99..350cecd1 100644 --- a/bridgev2/user.go +++ b/bridgev2/user.go @@ -225,8 +225,9 @@ func (user *User) GetManagementRoom(ctx context.Context) (id.RoomID, error) { user.MXID: 50, }, }, - Invite: []id.UserID{user.MXID}, - IsDirect: true, + RoomVersion: event.RoomV11, + Invite: []id.UserID{user.MXID}, + IsDirect: true, } if autoJoin { req.BeeperInitialMembers = []id.UserID{user.MXID} diff --git a/requests.go b/requests.go index 8363aeda..09e4b3cd 100644 --- a/requests.go +++ b/requests.go @@ -120,7 +120,7 @@ type ReqCreateRoom struct { InitialState []*event.Event `json:"initial_state,omitempty"` Preset string `json:"preset,omitempty"` IsDirect bool `json:"is_direct,omitempty"` - RoomVersion string `json:"room_version,omitempty"` + RoomVersion event.RoomVersion `json:"room_version,omitempty"` PowerLevelOverride *event.PowerLevelsEventContent `json:"power_level_content_override,omitempty"` From 1d37430204bdceb16dbd029215524f93810daba0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 15 Jul 2025 14:31:44 +0300 Subject: [PATCH 163/581] bridgev2/portal: block in queueEvent if buffer is full --- bridgev2/portal.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 1d8faa1a..9fa90d89 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -296,8 +296,16 @@ func (portal *Portal) queueEvent(ctx context.Context, evt portalEvent) EventHand default: zerolog.Ctx(ctx).Error(). Str("portal_id", string(portal.ID)). - Msg("Portal event channel is full") - return EventHandlingResultFailed + Msg("Portal event channel is full, queue will block") + for { + select { + case portal.events <- evt: + case <-time.After(5 * time.Second): + zerolog.Ctx(ctx).Error(). + Str("portal_id", string(portal.ID)). + Msg("Portal event channel is still full") + } + } } } } From 1ee29a47b6c601e760132867cc3d80944528c530 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 15 Jul 2025 13:37:07 +0200 Subject: [PATCH 164/581] bridgev2: add option to auto-reconnect after unknown error (#394) --- bridgev2/bridgeconfig/config.go | 3 + bridgev2/bridgeconfig/upgrade.go | 1 + bridgev2/bridgestate.go | 78 +++++++++++++++++++++- bridgev2/matrix/mxmain/example-config.yaml | 3 + bridgev2/userlogin.go | 16 +++++ 5 files changed, 98 insertions(+), 3 deletions(-) diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index bd6f53c3..9bdee5fe 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -7,6 +7,8 @@ package bridgeconfig import ( + "time" + "go.mau.fi/util/dbutil" "go.mau.fi/zeroconfig" "gopkg.in/yaml.v3" @@ -66,6 +68,7 @@ type BridgeConfig struct { ResendBridgeInfo bool `yaml:"resend_bridge_info"` NoBridgeInfoStateKey bool `yaml:"no_bridge_info_state_key"` BridgeStatusNotices string `yaml:"bridge_status_notices"` + UnknownErrorAutoReconnect time.Duration `yaml:"unknown_error_auto_reconnect"` BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"` BridgeNotices bool `yaml:"bridge_notices"` TagOnlyOnCreate bool `yaml:"tag_only_on_create"` diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index fa4b4493..b69a1fdb 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -32,6 +32,7 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.Bool, "bridge", "resend_bridge_info") helper.Copy(up.Bool, "bridge", "no_bridge_info_state_key") helper.Copy(up.Str|up.Null, "bridge", "bridge_status_notices") + helper.Copy(up.Str|up.Int|up.Null, "bridge", "unknown_error_auto_reconnect") helper.Copy(up.Bool, "bridge", "bridge_matrix_leave") helper.Copy(up.Bool, "bridge", "bridge_notices") helper.Copy(up.Bool, "bridge", "tag_only_on_create") diff --git a/bridgev2/bridgestate.go b/bridgev2/bridgestate.go index 81ec8160..f31d4e92 100644 --- a/bridgev2/bridgestate.go +++ b/bridgev2/bridgestate.go @@ -9,7 +9,9 @@ package bridgev2 import ( "context" "fmt" + "math/rand/v2" "runtime/debug" + "sync/atomic" "time" "github.com/rs/zerolog" @@ -26,6 +28,9 @@ type BridgeStateQueue struct { ch chan status.BridgeState bridge *Bridge login *UserLogin + + stopChan chan struct{} + stopReconnect atomic.Pointer[context.CancelFunc] } func (br *Bridge) SendGlobalBridgeState(state status.BridgeState) { @@ -47,16 +52,28 @@ func (br *Bridge) SendGlobalBridgeState(state status.BridgeState) { func (br *Bridge) NewBridgeStateQueue(login *UserLogin) *BridgeStateQueue { bsq := &BridgeStateQueue{ - ch: make(chan status.BridgeState, 10), - bridge: br, - login: login, + ch: make(chan status.BridgeState, 10), + stopChan: make(chan struct{}), + bridge: br, + login: login, } go bsq.loop() return bsq } func (bsq *BridgeStateQueue) Destroy() { + close(bsq.stopChan) close(bsq.ch) + bsq.StopUnknownErrorReconnect() +} + +func (bsq *BridgeStateQueue) StopUnknownErrorReconnect() { + if bsq == nil { + return + } + if cancelFn := bsq.stopReconnect.Swap(nil); cancelFn != nil { + (*cancelFn)() + } } func (bsq *BridgeStateQueue) loop() { @@ -117,6 +134,58 @@ func (bsq *BridgeStateQueue) sendNotice(ctx context.Context, state status.Bridge } } +func (bsq *BridgeStateQueue) unknownErrorReconnect(triggeredBy status.BridgeState) { + log := bsq.login.Log.With().Str("action", "unknown error reconnect").Logger() + ctx := log.WithContext(bsq.bridge.BackgroundCtx) + if !bsq.waitForUnknownErrorReconnect(ctx) { + return + } + prevUnsent := bsq.GetPrevUnsent() + prev := bsq.GetPrev() + if triggeredBy.Timestamp != prev.Timestamp { + log.Debug().Msg("Not reconnecting as a new bridge state was sent after the unknown error") + return + } else if len(bsq.ch) > 0 { + log.Warn().Msg("Not reconnecting as there are unsent bridge states") + return + } else if prevUnsent.StateEvent != status.StateUnknownError || prev.StateEvent != status.StateUnknownError { + log.Debug().Msg("Not reconnecting as the previous state was not an unknown error") + return + } + log.Info().Msg("Disconnecting and reconnecting login due to unknown error") + bsq.login.Disconnect() + log.Debug().Msg("Disconnection finished, recreating client and reconnecting") + err := bsq.login.recreateClient(ctx) + if err != nil { + log.Err(err).Msg("Failed to recreate client after unknown error") + return + } + bsq.login.Client.Connect(ctx) + log.Debug().Msg("Reconnection finished") +} + +func (bsq *BridgeStateQueue) waitForUnknownErrorReconnect(ctx context.Context) bool { + reconnectIn := bsq.bridge.Config.UnknownErrorAutoReconnect + // Don't allow too low values + if reconnectIn < 1*time.Minute { + return false + } + reconnectIn += time.Duration(rand.Int64N(int64(float64(reconnectIn)*0.4)) - int64(float64(reconnectIn)*0.2)) + cancelCtx, cancel := context.WithCancel(ctx) + defer cancel() + if oldCancel := bsq.stopReconnect.Swap(&cancel); oldCancel != nil { + (*oldCancel)() + } + select { + case <-time.After(reconnectIn): + return bsq.stopReconnect.CompareAndSwap(&cancel, nil) + case <-cancelCtx.Done(): + return false + case <-bsq.stopChan: + return false + } +} + func (bsq *BridgeStateQueue) immediateSendBridgeState(state status.BridgeState) { if bsq.prevSent != nil && bsq.prevSent.ShouldDeduplicate(&state) { bsq.login.Log.Debug(). @@ -124,6 +193,9 @@ func (bsq *BridgeStateQueue) immediateSendBridgeState(state status.BridgeState) Msg("Not sending bridge state as it's a duplicate") return } + if state.StateEvent == status.StateUnknownError { + go bsq.unknownErrorReconnect(state) + } ctx := bsq.login.Log.WithContext(context.Background()) bsq.sendNotice(ctx, state) diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index dad3f8a8..48e0d528 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -25,6 +25,9 @@ bridge: # These contain the same data that can be posted to an external HTTP server using homeserver -> status_endpoint. # Allowed values: none, errors, all bridge_status_notices: errors + # How long after an unknown error should the bridge attempt a full reconnect? + # Must be at least 1 minute. The bridge will add an extra ±20% jitter to this value. + unknown_error_auto_reconnect: null # Should leaving Matrix rooms be bridged as leaving groups on the remote network? bridge_matrix_leave: false diff --git a/bridgev2/userlogin.go b/bridgev2/userlogin.go index 05574e71..203dc122 100644 --- a/bridgev2/userlogin.go +++ b/bridgev2/userlogin.go @@ -522,6 +522,7 @@ func (ul *UserLogin) DisconnectWithTimeout(timeout time.Duration) { } func (ul *UserLogin) disconnectInternal(timeout time.Duration) { + ul.BridgeState.StopUnknownErrorReconnect() disconnected := make(chan struct{}) go func() { ul.Client.Disconnect() @@ -544,3 +545,18 @@ func (ul *UserLogin) disconnectInternal(timeout time.Duration) { } } } + +func (ul *UserLogin) recreateClient(ctx context.Context) error { + oldClient := ul.Client + err := ul.Bridge.Network.LoadUserLogin(ctx, ul) + if err != nil { + return err + } + if ul.Client == oldClient { + zerolog.Ctx(ctx).Warn().Msg("LoadUserLogin didn't update client") + } else { + zerolog.Ctx(ctx).Debug().Msg("Recreated user login client") + } + ul.disconnectOnce = sync.Once{} + return nil +} From 095c63a97eb9a55b4fd3b271f134989037fff132 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 15 Jul 2025 14:57:52 +0300 Subject: [PATCH 165/581] bridgev2/portal: add missing return --- bridgev2/portal.go | 1 + 1 file changed, 1 insertion(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 9fa90d89..ab1f37f1 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -300,6 +300,7 @@ func (portal *Portal) queueEvent(ctx context.Context, evt portalEvent) EventHand for { select { case portal.events <- evt: + return EventHandlingResultQueued case <-time.After(5 * time.Second): zerolog.Ctx(ctx).Error(). Str("portal_id", string(portal.ID)). From fcc72dc54b5d50ac2c7e1cb03e0158a8d3fd8e03 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 16 Jul 2025 11:06:39 +0300 Subject: [PATCH 166/581] dependencies: update --- go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index dcc6616c..59f29c0c 100644 --- a/go.mod +++ b/go.mod @@ -20,10 +20,10 @@ require ( github.com/yuin/goldmark v1.7.12 go.mau.fi/util v0.8.8 go.mau.fi/zeroconfig v0.1.3 - golang.org/x/crypto v0.39.0 - golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 - golang.org/x/net v0.41.0 - golang.org/x/sync v0.15.0 + golang.org/x/crypto v0.40.0 + golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc + golang.org/x/net v0.42.0 + golang.org/x/sync v0.16.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 ) @@ -37,7 +37,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.27.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index 779e05db..9f48386e 100644 --- a/go.sum +++ b/go.sum @@ -57,22 +57,22 @@ go.mau.fi/util v0.8.8 h1:OnuEEc/sIJFhnq4kFggiImUpcmnmL/xpvQMRu5Fiy5c= go.mau.fi/util v0.8.8/go.mod h1:Y/kS3loxTEhy8Vill513EtPXr+CRDdae+Xj2BXXMy/c= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4= -golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc= +golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= From 81a807a6c9824922c6a3ffb9ad4ffa248171d899 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 16 Jul 2025 11:32:09 +0300 Subject: [PATCH 167/581] Bump version to v0.24.2 --- CHANGELOG.md | 23 +++++++++++++++++++++++ version.go | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2eefb3d..8e71381e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ +## v0.24.2 (2025-07-16) + +* *(bridgev2)* Added support for return values from portal event handlers. Note + that the return value will always be "queued" unless the event buffer is + disabled. +* *(bridgev2)* Added support for [MSC4144] per-message profile passthrough in + relay mode. +* *(bridgev2)* Added option to auto-reconnect logins after a certain period if + they hit an `UNKNOWN_ERROR` state. +* *(bridgev2)* Added analytics for event handler panics. +* *(bridgev2)* Changed new room creation to hardcode room v11 to avoid v12 rooms + being created before proper support for them can be added. +* *(bridgev2)* Changed queuing events to block instead of dropping events if the + buffer is full. +* *(bridgev2)* Fixed assumption that replies to unknown messages are cross-room. +* *(id)* Fixed server name validation not including ports correctly + (thanks to [@krombel] in [#392]). +* *(federation)* Fixed base64 algorithm in signature generation. +* *(event)* Fixed [MSC4144] fallbacks not being removed from edits. + +[@krombel]: https://github.com/krombel +[#392]: https://github.com/mautrix/go/pull/392 + ## v0.24.1 (2025-06-16) * *(commands)* Added framework for using reactions as buttons that execute diff --git a/version.go b/version.go index 193205ee..6b8af5ef 100644 --- a/version.go +++ b/version.go @@ -7,7 +7,7 @@ import ( "strings" ) -const Version = "v0.24.1" +const Version = "v0.24.2" var GoModVersion = "" var Commit = "" From 7ffdbe8bfc97be3acd19ea5e6f86b46a72d343be Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 17 Jul 2025 16:54:53 +0300 Subject: [PATCH 168/581] bridgev2/disappear: add limit to getting messages from the db --- bridgev2/database/disappear.go | 6 +++--- bridgev2/disappear.go | 28 +++++++++++++++++++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/bridgev2/database/disappear.go b/bridgev2/database/disappear.go index 23db1448..4e6f5e0a 100644 --- a/bridgev2/database/disappear.go +++ b/bridgev2/database/disappear.go @@ -61,7 +61,7 @@ const ( getUpcomingDisappearingMessagesQuery = ` SELECT bridge_id, mx_room, mxid, type, timer, disappear_at FROM disappearing_message WHERE bridge_id = $1 AND disappear_at IS NOT NULL AND disappear_at < $2 - ORDER BY disappear_at + ORDER BY disappear_at LIMIT $3 ` deleteDisappearingMessageQuery = ` DELETE FROM disappearing_message WHERE bridge_id=$1 AND mxid=$2 @@ -77,8 +77,8 @@ func (dmq *DisappearingMessageQuery) StartAll(ctx context.Context, roomID id.Roo return dmq.QueryMany(ctx, startDisappearingMessagesQuery, time.Now().UnixNano(), dmq.BridgeID, roomID) } -func (dmq *DisappearingMessageQuery) GetUpcoming(ctx context.Context, duration time.Duration) ([]*DisappearingMessage, error) { - return dmq.QueryMany(ctx, getUpcomingDisappearingMessagesQuery, dmq.BridgeID, time.Now().Add(duration).UnixNano()) +func (dmq *DisappearingMessageQuery) GetUpcoming(ctx context.Context, duration time.Duration, limit int) ([]*DisappearingMessage, error) { + return dmq.QueryMany(ctx, getUpcomingDisappearingMessagesQuery, dmq.BridgeID, time.Now().Add(duration).UnixNano(), limit) } func (dmq *DisappearingMessageQuery) Delete(ctx context.Context, eventID id.EventID) error { diff --git a/bridgev2/disappear.go b/bridgev2/disappear.go index 1d063088..8305f84b 100644 --- a/bridgev2/disappear.go +++ b/bridgev2/disappear.go @@ -36,10 +36,21 @@ func (dl *DisappearLoop) Start() { log.Debug().Msg("Disappearing message loop starting") for { dl.NextCheck = time.Now().Add(DisappearCheckInterval) - messages, err := dl.br.DB.DisappearingMessage.GetUpcoming(ctx, DisappearCheckInterval) + const MessageLimit = 200 + messages, err := dl.br.DB.DisappearingMessage.GetUpcoming(ctx, DisappearCheckInterval, MessageLimit) if err != nil { log.Err(err).Msg("Failed to get upcoming disappearing messages") } else if len(messages) > 0 { + if len(messages) > MessageLimit/2 && messages[len(messages)-1].DisappearAt.Before(time.Now()) { + // If there are many messages, and they're all due immediately, + // process them synchronously and then check again. + dl.sleepAndDisappear(ctx, messages...) + log.Debug(). + Int("message_count", len(messages)). + Time("last_due", messages[len(messages)-1].DisappearAt). + Msg("Checking for disappearing messages again immediately") + continue + } go dl.sleepAndDisappear(ctx, messages...) } select { @@ -91,10 +102,17 @@ func (dl *DisappearLoop) Add(ctx context.Context, dm *database.DisappearingMessa func (dl *DisappearLoop) sleepAndDisappear(ctx context.Context, dms ...*database.DisappearingMessage) { for _, msg := range dms { - select { - case <-time.After(time.Until(msg.DisappearAt)): - case <-ctx.Done(): - return + timeUntilDisappear := time.Until(msg.DisappearAt) + if timeUntilDisappear <= 0 { + if ctx.Err() != nil { + return + } + } else { + select { + case <-time.After(timeUntilDisappear): + case <-ctx.Done(): + return + } } resp, err := dl.br.Bot.SendMessage(ctx, msg.RoomID, event.EventRedaction, &event.Content{ Parsed: &event.RedactionEventContent{ From 8efdbc029bbde2f9192be0834eaec9fba99d4352 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 17 Jul 2025 17:20:28 +0300 Subject: [PATCH 169/581] bridgev2/disappear: reduce disappear loop interval when there are lots of messages --- bridgev2/disappear.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bridgev2/disappear.go b/bridgev2/disappear.go index 8305f84b..546118de 100644 --- a/bridgev2/disappear.go +++ b/bridgev2/disappear.go @@ -41,15 +41,22 @@ func (dl *DisappearLoop) Start() { if err != nil { log.Err(err).Msg("Failed to get upcoming disappearing messages") } else if len(messages) > 0 { - if len(messages) > MessageLimit/2 && messages[len(messages)-1].DisappearAt.Before(time.Now()) { + lastDisappearTime := messages[len(messages)-1].DisappearAt + if len(messages) > MessageLimit/2 && lastDisappearTime.Before(time.Now()) { // If there are many messages, and they're all due immediately, // process them synchronously and then check again. dl.sleepAndDisappear(ctx, messages...) log.Debug(). Int("message_count", len(messages)). - Time("last_due", messages[len(messages)-1].DisappearAt). + Time("last_due", lastDisappearTime). Msg("Checking for disappearing messages again immediately") continue + } else if len(messages) >= MessageLimit && lastDisappearTime.Add(5*time.Second).Before(dl.NextCheck) { + log.Debug(). + Int("message_count", len(messages)). + Time("last_due", lastDisappearTime). + Msg("Using lower disappearing message check interval as the limit was reached, but the last message isn't due yet") + dl.NextCheck = lastDisappearTime.Add(5 * time.Second) } go dl.sleepAndDisappear(ctx, messages...) } From 5a9e20e4511dbea327ae7846d27cadde1a976908 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 17 Jul 2025 17:27:08 +0300 Subject: [PATCH 170/581] bridgev2/disappear: always delete synchronously if limit is reached --- bridgev2/disappear.go | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/bridgev2/disappear.go b/bridgev2/disappear.go index 546118de..58ff9bf6 100644 --- a/bridgev2/disappear.go +++ b/bridgev2/disappear.go @@ -42,21 +42,16 @@ func (dl *DisappearLoop) Start() { log.Err(err).Msg("Failed to get upcoming disappearing messages") } else if len(messages) > 0 { lastDisappearTime := messages[len(messages)-1].DisappearAt - if len(messages) > MessageLimit/2 && lastDisappearTime.Before(time.Now()) { + if len(messages) >= MessageLimit { + log.Debug(). + Int("message_count", len(messages)). + Time("last_due", lastDisappearTime). + Msg("Deleting disappearing messages synchronously and checking again immediately") + dl.NextCheck = lastDisappearTime // If there are many messages, and they're all due immediately, // process them synchronously and then check again. dl.sleepAndDisappear(ctx, messages...) - log.Debug(). - Int("message_count", len(messages)). - Time("last_due", lastDisappearTime). - Msg("Checking for disappearing messages again immediately") continue - } else if len(messages) >= MessageLimit && lastDisappearTime.Add(5*time.Second).Before(dl.NextCheck) { - log.Debug(). - Int("message_count", len(messages)). - Time("last_due", lastDisappearTime). - Msg("Using lower disappearing message check interval as the limit was reached, but the last message isn't due yet") - dl.NextCheck = lastDisappearTime.Add(5 * time.Second) } go dl.sleepAndDisappear(ctx, messages...) } From 0508f02a9e1ce38e686e62d78602d417689d0b13 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 17 Jul 2025 17:35:03 +0300 Subject: [PATCH 171/581] bridgev2/disappear: make next check field atomic --- bridgev2/disappear.go | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/bridgev2/disappear.go b/bridgev2/disappear.go index 58ff9bf6..f072c01f 100644 --- a/bridgev2/disappear.go +++ b/bridgev2/disappear.go @@ -21,7 +21,7 @@ import ( type DisappearLoop struct { br *Bridge - NextCheck time.Time + nextCheck atomic.Pointer[time.Time] stop atomic.Pointer[context.CancelFunc] } @@ -35,28 +35,30 @@ func (dl *DisappearLoop) Start() { } log.Debug().Msg("Disappearing message loop starting") for { - dl.NextCheck = time.Now().Add(DisappearCheckInterval) + nextCheck := time.Now().Add(DisappearCheckInterval) + dl.nextCheck.Store(&nextCheck) const MessageLimit = 200 messages, err := dl.br.DB.DisappearingMessage.GetUpcoming(ctx, DisappearCheckInterval, MessageLimit) if err != nil { log.Err(err).Msg("Failed to get upcoming disappearing messages") } else if len(messages) > 0 { - lastDisappearTime := messages[len(messages)-1].DisappearAt if len(messages) >= MessageLimit { + lastDisappearTime := messages[len(messages)-1].DisappearAt log.Debug(). Int("message_count", len(messages)). Time("last_due", lastDisappearTime). Msg("Deleting disappearing messages synchronously and checking again immediately") - dl.NextCheck = lastDisappearTime - // If there are many messages, and they're all due immediately, - // process them synchronously and then check again. + // Store the expected next check time to avoid Add spawning unnecessary goroutines. + // This can be in the past, in which case Add will put everything in the db, which is also fine. + dl.nextCheck.Store(&lastDisappearTime) + // If there are many messages, process them synchronously and then check again. dl.sleepAndDisappear(ctx, messages...) continue } go dl.sleepAndDisappear(ctx, messages...) } select { - case <-time.After(time.Until(dl.NextCheck)): + case <-time.After(time.Until(dl.GetNextCheck())): case <-ctx.Done(): log.Debug().Msg("Disappearing message loop stopping") return @@ -64,6 +66,17 @@ func (dl *DisappearLoop) Start() { } } +func (dl *DisappearLoop) GetNextCheck() time.Time { + if dl == nil { + return time.Time{} + } + nextCheck := dl.nextCheck.Load() + if nextCheck == nil { + return time.Time{} + } + return *nextCheck +} + func (dl *DisappearLoop) Stop() { if dl == nil { return @@ -80,7 +93,7 @@ func (dl *DisappearLoop) StartAll(ctx context.Context, roomID id.RoomID) { return } startedMessages = slices.DeleteFunc(startedMessages, func(dm *database.DisappearingMessage) bool { - return dm.DisappearAt.After(dl.NextCheck) + return dm.DisappearAt.After(dl.GetNextCheck()) }) slices.SortFunc(startedMessages, func(a, b *database.DisappearingMessage) int { return a.DisappearAt.Compare(b.DisappearAt) @@ -97,7 +110,7 @@ func (dl *DisappearLoop) Add(ctx context.Context, dm *database.DisappearingMessa Stringer("event_id", dm.EventID). Msg("Failed to save disappearing message") } - if !dm.DisappearAt.IsZero() && dm.DisappearAt.Before(dl.NextCheck) { + if !dm.DisappearAt.IsZero() && dm.DisappearAt.Before(dl.GetNextCheck()) { go dl.sleepAndDisappear(zerolog.Ctx(ctx).WithContext(dl.br.BackgroundCtx), dm) } } From 90a7dc3c75196da529f281cde5ff43282c1dd43c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 18 Jul 2025 16:05:04 +0300 Subject: [PATCH 172/581] bridgev2/portal: ignore delete for me in multi-user portals --- bridgev2/portal.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index ab1f37f1..a36524d8 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -2784,7 +2784,14 @@ func (portal *Portal) handleRemoteMessageRemove(ctx context.Context, source *Use onlyForMeProvider, ok := evt.(RemoteDeleteOnlyForMe) onlyForMe := ok && onlyForMeProvider.DeleteOnlyForMe() if onlyForMe && portal.Receiver == "" { - // TODO check if there are other user logins before deleting + logins, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) + if err != nil { + log.Err(err).Msg("Failed to check if portal has other logins") + return EventHandlingResultFailed + } else if len(logins) > 1 { + log.Debug().Msg("Ignoring delete for me event in portal with multiple logins") + return EventHandlingResultIgnored + } } intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventMessageRemove) From c7263bab40c0a183197fe11c841e00761ec4ee6d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 18 Jul 2025 17:37:45 +0300 Subject: [PATCH 173/581] bridgev2/portal: add support for following tombstones --- bridgev2/database/userportal.go | 7 ++ bridgev2/matrix/connector.go | 1 + bridgev2/matrix/matrix.go | 2 +- bridgev2/portal.go | 217 ++++++++++++++++++++++++++++---- 4 files changed, 199 insertions(+), 28 deletions(-) diff --git a/bridgev2/database/userportal.go b/bridgev2/database/userportal.go index 278b236b..e928a4c7 100644 --- a/bridgev2/database/userportal.go +++ b/bridgev2/database/userportal.go @@ -67,6 +67,9 @@ const ( markLoginAsPreferredQuery = ` UPDATE user_portal SET preferred=(login_id=$3) WHERE bridge_id=$1 AND user_mxid=$2 AND portal_id=$4 AND portal_receiver=$5 ` + markAllNotInSpaceQuery = ` + UPDATE user_portal SET in_space=false WHERE bridge_id=$1 AND portal_id=$2 AND portal_receiver=$3 + ` deleteUserPortalQuery = ` DELETE FROM user_portal WHERE bridge_id=$1 AND user_mxid=$2 AND login_id=$3 AND portal_id=$4 AND portal_receiver=$5 ` @@ -110,6 +113,10 @@ func (upq *UserPortalQuery) MarkAsPreferred(ctx context.Context, login *UserLogi return upq.Exec(ctx, markLoginAsPreferredQuery, upq.BridgeID, login.UserMXID, login.ID, portal.ID, portal.Receiver) } +func (upq *UserPortalQuery) MarkAllNotInSpace(ctx context.Context, portal networkid.PortalKey) error { + return upq.Exec(ctx, markAllNotInSpaceQuery, upq.BridgeID, portal.ID, portal.Receiver) +} + func (upq *UserPortalQuery) Delete(ctx context.Context, up *UserPortal) error { return upq.Exec(ctx, deleteUserPortalQuery, up.BridgeID, up.UserMXID, up.LoginID, up.Portal.ID, up.Portal.Receiver) } diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 7af2d128..7075a1aa 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -145,6 +145,7 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) { br.EventProcessor.On(event.StateRoomName, br.handleRoomEvent) br.EventProcessor.On(event.StateRoomAvatar, br.handleRoomEvent) br.EventProcessor.On(event.StateTopic, br.handleRoomEvent) + br.EventProcessor.On(event.StateTombstone, br.handleRoomEvent) br.EventProcessor.On(event.EphemeralEventReceipt, br.handleEphemeralEvent) br.EventProcessor.On(event.EphemeralEventTyping, br.handleEphemeralEvent) br.Bot = br.AS.BotIntent() diff --git a/bridgev2/matrix/matrix.go b/bridgev2/matrix/matrix.go index 84e85d24..fed9d37a 100644 --- a/bridgev2/matrix/matrix.go +++ b/bridgev2/matrix/matrix.go @@ -169,7 +169,7 @@ func (br *Connector) shouldIgnoreEventFromUser(userID id.UserID) bool { } func (br *Connector) shouldIgnoreEvent(evt *event.Event) bool { - if br.shouldIgnoreEventFromUser(evt.Sender) { + if br.shouldIgnoreEventFromUser(evt.Sender) && evt.Type != event.StateTombstone { return true } dpVal, ok := evt.Content.Raw[appservice.DoublePuppetKey] diff --git a/bridgev2/portal.go b/bridgev2/portal.go index a36524d8..82e76318 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -580,6 +580,10 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * return EventHandlingResultIgnored } } + if evt.Type == event.StateTombstone { + // Tombstones aren't bridged so they don't need a login + return portal.handleMatrixTombstone(ctx, evt) + } login, _, err := portal.FindPreferredLogin(ctx, sender, true) if err != nil { log.Err(err).Msg("Failed to get user login to handle Matrix event") @@ -1716,6 +1720,158 @@ func (portal *Portal) handleMatrixPowerLevels( return EventHandlingResultSuccess.WithMSS() } +func (portal *Portal) handleMatrixTombstone(ctx context.Context, evt *event.Event) EventHandlingResult { + if evt.StateKey == nil || *evt.StateKey != "" || portal.MXID != evt.RoomID { + return EventHandlingResultIgnored + } + log := *zerolog.Ctx(ctx) + sentByBridge := evt.Sender == portal.Bridge.Bot.GetMXID() || portal.Bridge.IsGhostMXID(evt.Sender) + var senderUser *User + var err error + if !sentByBridge { + senderUser, err = portal.Bridge.GetUserByMXID(ctx, evt.Sender) + if err != nil { + log.Err(err).Msg("Failed to get tombstone sender user") + return EventHandlingResultFailed + } + } + content, ok := evt.Content.Parsed.(*event.TombstoneEventContent) + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + log = log.With(). + Stringer("replacement_room", content.ReplacementRoom). + Logger() + if content.ReplacementRoom == "" { + log.Info().Msg("Received tombstone with no replacement room, cleaning up portal") + err := portal.RemoveMXID(ctx) + if err != nil { + log.Err(err).Msg("Failed to remove portal MXID") + return EventHandlingResultFailed.WithMSSError(err) + } + err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, true) + if err != nil { + log.Err(err).Msg("Failed to clean up Matrix room") + return EventHandlingResultFailed + } + return EventHandlingResultSuccess + } + existingMemberEvt, err := portal.Bridge.Matrix.GetMemberInfo(ctx, content.ReplacementRoom, portal.Bridge.Bot.GetMXID()) + if err != nil { + log.Err(err).Msg("Failed to get member info of bot in replacement room") + return EventHandlingResultFailed + } + leaveOnError := func() { + if existingMemberEvt != nil && existingMemberEvt.Membership == event.MembershipJoin { + return + } + log.Debug().Msg("Leaving replacement room with bot after tombstone validation failed") + _, err = portal.Bridge.Bot.SendState( + ctx, + content.ReplacementRoom, + event.StateMember, + portal.Bridge.Bot.GetMXID().String(), + &event.Content{ + Parsed: &event.MemberEventContent{ + Membership: event.MembershipLeave, + Reason: fmt.Sprintf("Failed to validate tombstone sent by %s from %s", evt.Sender, evt.RoomID), + }, + }, + time.Time{}, + ) + if err != nil { + log.Err(err).Msg("Failed to leave replacement room after tombstone validation failed") + } + } + err = portal.Bridge.Bot.EnsureJoined(ctx, content.ReplacementRoom) + if err != nil { + log.Err(err).Msg("Failed to join replacement room from tombstone") + return EventHandlingResultFailed + } + if !sentByBridge && !senderUser.Permissions.Admin { + powers, err := portal.Bridge.Matrix.GetPowerLevels(ctx, content.ReplacementRoom) + if err != nil { + log.Err(err).Msg("Failed to get power levels in replacement room") + leaveOnError() + return EventHandlingResultFailed + } + if powers.GetUserLevel(evt.Sender) < powers.Invite() { + log.Warn().Msg("Tombstone sender doesn't have enough power to invite the bot to the replacement room") + leaveOnError() + return EventHandlingResultIgnored + } + } + + portal.Bridge.cacheLock.Lock() + if _, alreadyExists := portal.Bridge.portalsByMXID[content.ReplacementRoom]; alreadyExists { + log.Warn().Msg("Replacement room is already a portal, ignoring tombstone") + portal.Bridge.cacheLock.Unlock() + return EventHandlingResultIgnored + } + delete(portal.Bridge.portalsByMXID, portal.MXID) + portal.MXID = content.ReplacementRoom + portal.Bridge.portalsByMXID[portal.MXID] = portal + portal.NameSet = false + portal.AvatarSet = false + portal.TopicSet = false + portal.InSpace = false + portal.CapState = database.CapabilityState{} + portal.Bridge.cacheLock.Unlock() + + err = portal.Save(ctx) + if err != nil { + log.Err(err).Msg("Failed to save portal after tombstone") + return EventHandlingResultFailed + } + log.Info().Msg("Successfully followed tombstone and updated portal MXID") + err = portal.Bridge.DB.UserPortal.MarkAllNotInSpace(ctx, portal.PortalKey) + if err != nil { + log.Err(err).Msg("Failed to update in_space flag for user portals after tombstone") + } + go portal.addToUserSpaces(ctx) + go portal.updateInfoAfterTombstone(ctx, senderUser) + go func() { + err = portal.Bridge.Bot.DeleteRoom(ctx, evt.RoomID, true) + if err != nil { + log.Err(err).Msg("Failed to clean up Matrix room after following tombstone") + } + }() + return EventHandlingResultSuccess +} + +func (portal *Portal) updateInfoAfterTombstone(ctx context.Context, senderUser *User) { + log := zerolog.Ctx(ctx) + logins, err := portal.Bridge.GetUserLoginsInPortal(ctx, portal.PortalKey) + if err != nil { + log.Err(err).Msg("Failed to get user logins in portal to sync info") + return + } + var preferredLogin *UserLogin + for _, login := range logins { + if !login.Client.IsLoggedIn() { + continue + } else if preferredLogin == nil { + preferredLogin = login + } else if senderUser != nil && login.User == senderUser { + preferredLogin = login + } + } + if preferredLogin == nil { + log.Warn().Msg("No logins found to sync info") + return + } + info, err := preferredLogin.Client.GetChatInfo(ctx, portal) + if err != nil { + log.Err(err).Msg("Failed to get chat info") + return + } + log.Info(). + Str("info_source_login", string(preferredLogin.ID)). + Msg("Fetched info to update portal after tombstone") + portal.UpdateInfo(ctx, info, preferredLogin, nil, time.Time{}) +} + func (portal *Portal) handleMatrixRedaction( ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, ) EventHandlingResult { @@ -4203,39 +4359,46 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo } } } - if portal.Parent == nil { - if portal.Receiver != "" { - login := portal.Bridge.GetCachedUserLoginByID(portal.Receiver) - if login != nil { - up, err := portal.Bridge.DB.UserPortal.Get(ctx, login.UserLogin, portal.PortalKey) - if err != nil { - log.Err(err).Msg("Failed to get user portal to add portal to spaces") - } else { - login.inPortalCache.Remove(portal.PortalKey) - go login.tryAddPortalToSpace(withoutCancelCtx, portal, up.CopyWithoutValues()) - } - } - } else { - userPortals, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) - if err != nil { - log.Err(err).Msg("Failed to get user logins in portal to add portal to spaces") - } else { - for _, up := range userPortals { - login := portal.Bridge.GetCachedUserLoginByID(up.LoginID) - if login != nil { - login.inPortalCache.Remove(portal.PortalKey) - go login.tryAddPortalToSpace(withoutCancelCtx, portal, up.CopyWithoutValues()) - } - } - } - } - } + portal.addToUserSpaces(ctx) if portal.Bridge.Config.Backfill.Enabled && portal.RoomType != database.RoomTypeSpace && !portal.Bridge.Background { portal.doForwardBackfill(ctx, source, nil, backfillBundle) } return nil } +func (portal *Portal) addToUserSpaces(ctx context.Context) { + if portal.Parent == nil { + return + } + log := zerolog.Ctx(ctx) + withoutCancelCtx := log.WithContext(portal.Bridge.BackgroundCtx) + if portal.Receiver != "" { + login := portal.Bridge.GetCachedUserLoginByID(portal.Receiver) + if login != nil { + up, err := portal.Bridge.DB.UserPortal.Get(ctx, login.UserLogin, portal.PortalKey) + if err != nil { + log.Err(err).Msg("Failed to get user portal to add portal to spaces") + } else { + login.inPortalCache.Remove(portal.PortalKey) + go login.tryAddPortalToSpace(withoutCancelCtx, portal, up.CopyWithoutValues()) + } + } + } else { + userPortals, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) + if err != nil { + log.Err(err).Msg("Failed to get user logins in portal to add portal to spaces") + } else { + for _, up := range userPortals { + login := portal.Bridge.GetCachedUserLoginByID(up.LoginID) + if login != nil { + login.inPortalCache.Remove(portal.PortalKey) + go login.tryAddPortalToSpace(withoutCancelCtx, portal, up.CopyWithoutValues()) + } + } + } + } +} + func (portal *Portal) Delete(ctx context.Context) error { portal.removeInPortalCache(ctx) err := portal.Bridge.DB.Portal.Delete(ctx, portal.PortalKey) From 9a170d26695315d363dc74ec6db17fda20dbdaea Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 18 Jul 2025 17:55:27 +0300 Subject: [PATCH 174/581] bridgev2,appservice: add via to EnsureJoined and use it for tombstone handling --- appservice/intent.go | 11 +++++++++-- bridgev2/matrix/intent.go | 8 ++++++-- bridgev2/matrixinterface.go | 6 +++++- bridgev2/portal.go | 6 +++++- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/appservice/intent.go b/appservice/intent.go index d6cda137..194057f7 100644 --- a/appservice/intent.go +++ b/appservice/intent.go @@ -86,6 +86,7 @@ func (intent *IntentAPI) EnsureRegistered(ctx context.Context) error { type EnsureJoinedParams struct { IgnoreCache bool BotOverride *mautrix.Client + Via []string } func (intent *IntentAPI) EnsureJoined(ctx context.Context, roomID id.RoomID, extra ...EnsureJoinedParams) error { @@ -99,11 +100,17 @@ func (intent *IntentAPI) EnsureJoined(ctx context.Context, roomID id.RoomID, ext return nil } - if err := intent.EnsureRegistered(ctx); err != nil { + err := intent.EnsureRegistered(ctx) + if err != nil { return fmt.Errorf("failed to ensure joined: %w", err) } - resp, err := intent.JoinRoomByID(ctx, roomID) + var resp *mautrix.RespJoinRoom + if len(params.Via) > 0 { + resp, err = intent.JoinRoom(ctx, roomID.String(), &mautrix.ReqJoinRoom{Via: params.Via}) + } else { + resp, err = intent.JoinRoomByID(ctx, roomID) + } if err != nil { bot := intent.bot if params.BotOverride != nil { diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index 2088d5b1..4a337e53 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -490,8 +490,12 @@ func (as *ASIntent) IsDoublePuppet() bool { return as.Matrix.IsDoublePuppet() } -func (as *ASIntent) EnsureJoined(ctx context.Context, roomID id.RoomID) error { - err := as.Matrix.EnsureJoined(ctx, roomID) +func (as *ASIntent) EnsureJoined(ctx context.Context, roomID id.RoomID, extra ...bridgev2.EnsureJoinedParams) error { + var params bridgev2.EnsureJoinedParams + if len(extra) > 0 { + params = extra[0] + } + err := as.Matrix.EnsureJoined(ctx, roomID, appservice.EnsureJoinedParams{Via: params.Via}) if err != nil { return err } diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index ae1b99d7..c1bd69b8 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -144,6 +144,10 @@ func (ce CallbackError) Unwrap() error { return ce.Wrapped } +type EnsureJoinedParams struct { + Via []string +} + type MatrixAPI interface { GetMXID() id.UserID IsDoublePuppet() bool @@ -164,7 +168,7 @@ type MatrixAPI interface { CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom) (id.RoomID, error) DeleteRoom(ctx context.Context, roomID id.RoomID, puppetsOnly bool) error - EnsureJoined(ctx context.Context, roomID id.RoomID) error + EnsureJoined(ctx context.Context, roomID id.RoomID, params ...EnsureJoinedParams) error EnsureInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) error TagRoom(ctx context.Context, roomID id.RoomID, tag event.RoomTag, isTagged bool) error diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 82e76318..b9ea3385 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1784,7 +1784,11 @@ func (portal *Portal) handleMatrixTombstone(ctx context.Context, evt *event.Even log.Err(err).Msg("Failed to leave replacement room after tombstone validation failed") } } - err = portal.Bridge.Bot.EnsureJoined(ctx, content.ReplacementRoom) + var via []string + if senderHS := evt.Sender.Homeserver(); senderHS != "" { + via = []string{senderHS} + } + err = portal.Bridge.Bot.EnsureJoined(ctx, content.ReplacementRoom, EnsureJoinedParams{Via: via}) if err != nil { log.Err(err).Msg("Failed to join replacement room from tombstone") return EventHandlingResultFailed From 237ce1c64c7f079d38fe6a630770181ea8d4842f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 11 Jul 2025 12:55:34 +0300 Subject: [PATCH 175/581] client: remove redundant state store update in room create --- client.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/client.go b/client.go index 6f746015..7a83619f 100644 --- a/client.go +++ b/client.go @@ -1378,9 +1378,6 @@ func (cli *Client) CreateRoom(ctx context.Context, req *ReqCreateRoom) (resp *Re Msg("Failed to update membership in state store after creating room") } } - for _, evt := range req.InitialState { - cli.updateStoreWithOutgoingEvent(ctx, resp.RoomID, evt.Type, evt.GetStateKey(), &evt.Content) - } } return } From 0b62253d3b48ec0ea3540af0568d74fd889f9ecc Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 11 Jul 2025 12:55:44 +0300 Subject: [PATCH 176/581] all: add support for creator power --- appservice/intent.go | 27 +++++- bridgev2/matrix/connector.go | 8 ++ bridgev2/matrixinterface.go | 1 + bridgev2/portal.go | 8 ++ client.go | 18 +++- event/powerlevels.go | 30 ++++++- event/state.go | 14 ++++ sqlstatestore/statestore.go | 116 +++++++++++--------------- sqlstatestore/v00-latest-revision.sql | 3 +- sqlstatestore/v08-create-event.sql | 2 + statestore.go | 29 ++++++- 11 files changed, 178 insertions(+), 78 deletions(-) create mode 100644 sqlstatestore/v08-create-event.sql diff --git a/appservice/intent.go b/appservice/intent.go index 194057f7..a1245d74 100644 --- a/appservice/intent.go +++ b/appservice/intent.go @@ -375,6 +375,24 @@ func (intent *IntentAPI) Member(ctx context.Context, roomID id.RoomID, userID id return member } +func (intent *IntentAPI) FillPowerLevelCreateEvent(ctx context.Context, roomID id.RoomID, pl *event.PowerLevelsEventContent) error { + if pl.CreateEvent != nil { + return nil + } + var err error + pl.CreateEvent, err = intent.StateStore.GetCreate(ctx, roomID) + if err != nil { + return fmt.Errorf("failed to get create event from cache: %w", err) + } else if pl.CreateEvent != nil { + return nil + } + pl.CreateEvent, err = intent.FullStateEvent(ctx, roomID, event.StateCreate, "") + if err != nil { + return fmt.Errorf("failed to get create event from server: %w", err) + } + return nil +} + func (intent *IntentAPI) PowerLevels(ctx context.Context, roomID id.RoomID) (pl *event.PowerLevelsEventContent, err error) { pl, err = intent.as.StateStore.GetPowerLevels(ctx, roomID) if err != nil { @@ -384,6 +402,12 @@ func (intent *IntentAPI) PowerLevels(ctx context.Context, roomID id.RoomID) (pl if pl == nil { pl = &event.PowerLevelsEventContent{} err = intent.StateEvent(ctx, roomID, event.StatePowerLevels, "", pl) + if err != nil { + return + } + } + if pl.CreateEvent == nil { + pl.CreateEvent, err = intent.FullStateEvent(ctx, roomID, event.StateCreate, "") } return } @@ -398,8 +422,7 @@ func (intent *IntentAPI) SetPowerLevel(ctx context.Context, roomID id.RoomID, us return nil, err } - if pl.GetUserLevel(userID) != level { - pl.SetUserLevel(userID, level) + if pl.EnsureUserLevelAs(intent.UserID, userID, level) { return intent.SendStateEvent(ctx, roomID, event.StatePowerLevels, "", &pl) } return nil, nil diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 7075a1aa..978f666f 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -534,6 +534,14 @@ func (br *Connector) GetPowerLevels(ctx context.Context, roomID id.RoomID) (*eve return br.Bot.PowerLevels(ctx, roomID) } +func (br *Connector) GetCreateEvent(ctx context.Context, roomID id.RoomID) (*event.Event, error) { + createEvt, err := br.Bot.StateStore.GetCreate(ctx, roomID) + if err != nil || createEvt != nil { + return createEvt, err + } + return br.Bot.FullStateEvent(ctx, roomID, event.StateCreate, "") +} + func (br *Connector) GetMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { fetched, err := br.Bot.StateStore.HasFetchedMembers(ctx, roomID) if err != nil { diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index c1bd69b8..5d0cb014 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -47,6 +47,7 @@ type MatrixConnector interface { GenerateContentURI(ctx context.Context, mediaID networkid.MediaID) (id.ContentURIString, error) GetPowerLevels(ctx context.Context, roomID id.RoomID) (*event.PowerLevelsEventContent, error) + GetCreateEvent(ctx context.Context, roomID id.RoomID) (*event.Event, error) GetMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) GetMemberInfo(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index b9ea3385..c91523ef 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1654,6 +1654,13 @@ func (portal *Portal) handleMatrixPowerLevels( log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) } + if content.CreateEvent == nil { + var err error + content.CreateEvent, err = portal.Bridge.Matrix.GetCreateEvent(ctx, portal.MXID) + if err != nil { + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("failed to get create event for power levels: %w", err)) + } + } api, ok := sender.Client.(PowerLevelHandlingNetworkAPI) if !ok { return EventHandlingResultIgnored.WithMSSError(ErrPowerLevelsNotSupported) @@ -1662,6 +1669,7 @@ func (portal *Portal) handleMatrixPowerLevels( if evt.Unsigned.PrevContent != nil { _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) prevContent, _ = evt.Unsigned.PrevContent.Parsed.(*event.PowerLevelsEventContent) + prevContent.CreateEvent = content.CreateEvent } plChange := &MatrixPowerLevelChange{ diff --git a/client.go b/client.go index 7a83619f..886dbb63 100644 --- a/client.go +++ b/client.go @@ -1548,12 +1548,15 @@ func (cli *Client) FullStateEvent(ctx context.Context, roomID id.RoomID, eventTy "format": "event", }) _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &evt) - if err == nil && cli.StateStore != nil { - UpdateStateStore(ctx, cli.StateStore, evt) - } if evt != nil { evt.Type.Class = event.StateEventType _ = evt.Content.ParseRaw(evt.Type) + if evt.RoomID == "" { + evt.RoomID = roomID + } + } + if err == nil && cli.StateStore != nil { + UpdateStateStore(ctx, cli.StateStore, evt) } return } @@ -1606,12 +1609,21 @@ func (cli *Client) State(ctx context.Context, roomID id.RoomID) (stateMap RoomSt ResponseJSON: &stateMap, Handler: parseRoomStateArray, }) + if stateMap != nil { + pls, ok := stateMap[event.StatePowerLevels][""] + if ok { + pls.Content.AsPowerLevels().CreateEvent = stateMap[event.StateCreate][""] + } + } if err == nil && cli.StateStore != nil { for evtType, evts := range stateMap { if evtType == event.StateMember { continue } for _, evt := range evts { + if evt.RoomID == "" { + evt.RoomID = roomID + } UpdateStateStore(ctx, cli.StateStore, evt) } } diff --git a/event/powerlevels.go b/event/powerlevels.go index 2f4d4573..79dbd1f3 100644 --- a/event/powerlevels.go +++ b/event/powerlevels.go @@ -7,6 +7,8 @@ package event import ( + "math" + "slices" "sync" "go.mau.fi/util/ptr" @@ -34,6 +36,10 @@ type PowerLevelsEventContent struct { KickPtr *int `json:"kick,omitempty"` BanPtr *int `json:"ban,omitempty"` RedactPtr *int `json:"redact,omitempty"` + + // This is not a part of power levels, it's added by mautrix-go internally in certain places + // in order to detect creator power accurately. + CreateEvent *Event `json:"-,omitempty"` } func (pl *PowerLevelsEventContent) Clone() *PowerLevelsEventContent { @@ -53,6 +59,8 @@ func (pl *PowerLevelsEventContent) Clone() *PowerLevelsEventContent { KickPtr: ptr.Clone(pl.KickPtr), BanPtr: ptr.Clone(pl.BanPtr), RedactPtr: ptr.Clone(pl.RedactPtr), + + CreateEvent: pl.CreateEvent, } } @@ -112,6 +120,9 @@ func (pl *PowerLevelsEventContent) StateDefault() int { } func (pl *PowerLevelsEventContent) GetUserLevel(userID id.UserID) int { + if pl.isCreator(userID) { + return math.MaxInt + } pl.usersLock.RLock() defer pl.usersLock.RUnlock() level, ok := pl.Users[userID] @@ -138,9 +149,24 @@ func (pl *PowerLevelsEventContent) EnsureUserLevel(target id.UserID, level int) return pl.EnsureUserLevelAs("", target, level) } +func (pl *PowerLevelsEventContent) createContent() *CreateEventContent { + if pl.CreateEvent == nil { + return &CreateEventContent{} + } + return pl.CreateEvent.Content.AsCreate() +} + +func (pl *PowerLevelsEventContent) isCreator(userID id.UserID) bool { + cc := pl.createContent() + return cc.SupportsCreatorPower() && (userID == pl.CreateEvent.Sender || slices.Contains(cc.AdditionalCreators, userID)) +} + func (pl *PowerLevelsEventContent) EnsureUserLevelAs(actor, target id.UserID, level int) bool { + if pl.isCreator(target) { + return false + } existingLevel := pl.GetUserLevel(target) - if actor != "" { + if actor != "" && !pl.isCreator(actor) { actorLevel := pl.GetUserLevel(actor) if actorLevel <= existingLevel || actorLevel < level { return false @@ -185,7 +211,7 @@ func (pl *PowerLevelsEventContent) EnsureEventLevel(eventType Type, level int) b func (pl *PowerLevelsEventContent) EnsureEventLevelAs(actor id.UserID, eventType Type, level int) bool { existingLevel := pl.GetEventLevel(eventType) - if actor != "" { + if actor != "" && !pl.isCreator(actor) { actorLevel := pl.GetUserLevel(actor) if existingLevel > actorLevel || level > actorLevel { return false diff --git a/event/state.go b/event/state.go index 028691e1..ff6dabfa 100644 --- a/event/state.go +++ b/event/state.go @@ -88,6 +88,7 @@ const ( RoomV9 RoomVersion = "9" RoomV10 RoomVersion = "10" RoomV11 RoomVersion = "11" + RoomV12 RoomVersion = "12" ) // CreateEventContent represents the content of a m.room.create state event. @@ -98,10 +99,23 @@ type CreateEventContent struct { RoomVersion RoomVersion `json:"room_version,omitempty"` Predecessor *Predecessor `json:"predecessor,omitempty"` + // Room v12+ only + AdditionalCreators []id.UserID `json:"additional_creators,omitempty"` + // Deprecated: use the event sender instead Creator id.UserID `json:"creator,omitempty"` } +func (cec *CreateEventContent) SupportsCreatorPower() bool { + switch cec.RoomVersion { + case "", RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11: + return false + default: + // Assume anything except known old versions supports creator power. + return true + } +} + // JoinRule specifies how open a room is to new members. // https://spec.matrix.org/v1.2/client-server-api/#mroomjoin_rules type JoinRule string diff --git a/sqlstatestore/statestore.go b/sqlstatestore/statestore.go index 4a220a2b..f9a7e421 100644 --- a/sqlstatestore/statestore.go +++ b/sqlstatestore/statestore.go @@ -379,89 +379,67 @@ func (store *SQLStateStore) SetPowerLevels(ctx context.Context, roomID id.RoomID } func (store *SQLStateStore) GetPowerLevels(ctx context.Context, roomID id.RoomID) (levels *event.PowerLevelsEventContent, err error) { + levels = &event.PowerLevelsEventContent{} err = store. - QueryRow(ctx, "SELECT power_levels FROM mx_room_state WHERE room_id=$1", roomID). - Scan(&dbutil.JSON{Data: &levels}) + QueryRow(ctx, "SELECT power_levels, create_event FROM mx_room_state WHERE room_id=$1", roomID). + Scan(&dbutil.JSON{Data: &levels}, &dbutil.JSON{Data: &levels.CreateEvent}) if errors.Is(err, sql.ErrNoRows) { - err = nil + return nil, nil + } else if err != nil { + return nil, err + } + if levels.CreateEvent != nil { + err = levels.CreateEvent.Content.ParseRaw(event.StateCreate) } return } func (store *SQLStateStore) GetPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID) (int, error) { - if store.Dialect == dbutil.Postgres { - var powerLevel int - err := store. - QueryRow(ctx, ` - SELECT COALESCE((power_levels->'users'->$2)::int, (power_levels->'users_default')::int, 0) - FROM mx_room_state WHERE room_id=$1 - `, roomID, userID). - Scan(&powerLevel) - return powerLevel, err - } else { - levels, err := store.GetPowerLevels(ctx, roomID) - if err != nil { - return 0, err - } - return levels.GetUserLevel(userID), nil + levels, err := store.GetPowerLevels(ctx, roomID) + if err != nil { + return 0, err } + return levels.GetUserLevel(userID), nil } func (store *SQLStateStore) GetPowerLevelRequirement(ctx context.Context, roomID id.RoomID, eventType event.Type) (int, error) { - if store.Dialect == dbutil.Postgres { - defaultType := "events_default" - defaultValue := 0 - if eventType.IsState() { - defaultType = "state_default" - defaultValue = 50 - } - var powerLevel int - err := store. - QueryRow(ctx, ` - SELECT COALESCE((power_levels->'events'->$2)::int, (power_levels->'$3')::int, $4) - FROM mx_room_state WHERE room_id=$1 - `, roomID, eventType.Type, defaultType, defaultValue). - Scan(&powerLevel) - if errors.Is(err, sql.ErrNoRows) { - err = nil - powerLevel = defaultValue - } - return powerLevel, err - } else { - levels, err := store.GetPowerLevels(ctx, roomID) - if err != nil { - return 0, err - } - return levels.GetEventLevel(eventType), nil + levels, err := store.GetPowerLevels(ctx, roomID) + if err != nil { + return 0, err } + return levels.GetEventLevel(eventType), nil } func (store *SQLStateStore) HasPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID, eventType event.Type) (bool, error) { - if store.Dialect == dbutil.Postgres { - defaultType := "events_default" - defaultValue := 0 - if eventType.IsState() { - defaultType = "state_default" - defaultValue = 50 - } - var hasPower bool - err := store. - QueryRow(ctx, `SELECT - COALESCE((power_levels->'users'->$2)::int, (power_levels->'users_default')::int, 0) - >= - COALESCE((power_levels->'events'->$3)::int, (power_levels->'$4')::int, $5) - FROM mx_room_state WHERE room_id=$1`, roomID, userID, eventType.Type, defaultType, defaultValue). - Scan(&hasPower) - if errors.Is(err, sql.ErrNoRows) { - err = nil - hasPower = defaultValue == 0 - } - return hasPower, err - } else { - levels, err := store.GetPowerLevels(ctx, roomID) - if err != nil { - return false, err - } - return levels.GetUserLevel(userID) >= levels.GetEventLevel(eventType), nil + levels, err := store.GetPowerLevels(ctx, roomID) + if err != nil { + return false, err } + return levels.GetUserLevel(userID) >= levels.GetEventLevel(eventType), nil +} + +func (store *SQLStateStore) SetCreate(ctx context.Context, evt *event.Event) error { + if evt.Type != event.StateCreate { + return fmt.Errorf("invalid event type for create event: %s", evt.Type) + } + _, err := store.Exec(ctx, ` + INSERT INTO mx_room_state (room_id, create_event) VALUES ($1, $2) + ON CONFLICT (room_id) DO UPDATE SET create_event=excluded.create_event + `, evt.RoomID, dbutil.JSON{Data: evt}) + return err +} + +func (store *SQLStateStore) GetCreate(ctx context.Context, roomID id.RoomID) (evt *event.Event, err error) { + err = store. + QueryRow(ctx, "SELECT create_event FROM mx_room_state WHERE room_id=$1", roomID). + Scan(&dbutil.JSON{Data: &evt}) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + if evt != nil { + err = evt.Content.ParseRaw(event.StateCreate) + } + return } diff --git a/sqlstatestore/v00-latest-revision.sql b/sqlstatestore/v00-latest-revision.sql index a58cc56a..132ed1ab 100644 --- a/sqlstatestore/v00-latest-revision.sql +++ b/sqlstatestore/v00-latest-revision.sql @@ -1,4 +1,4 @@ --- v0 -> v7 (compatible with v3+): Latest revision +-- v0 -> v8 (compatible with v3+): Latest revision CREATE TABLE mx_registrations ( user_id TEXT PRIMARY KEY @@ -26,5 +26,6 @@ CREATE TABLE mx_room_state ( room_id TEXT PRIMARY KEY, power_levels jsonb, encryption jsonb, + create_event jsonb, members_fetched BOOLEAN NOT NULL DEFAULT false ); diff --git a/sqlstatestore/v08-create-event.sql b/sqlstatestore/v08-create-event.sql new file mode 100644 index 00000000..9f1b55c9 --- /dev/null +++ b/sqlstatestore/v08-create-event.sql @@ -0,0 +1,2 @@ +-- v8 (compatible with v3+): Add create event to room state table +ALTER TABLE mx_room_state ADD COLUMN create_event jsonb; diff --git a/statestore.go b/statestore.go index e728b885..1933ab95 100644 --- a/statestore.go +++ b/statestore.go @@ -34,6 +34,9 @@ type StateStore interface { SetPowerLevels(ctx context.Context, roomID id.RoomID, levels *event.PowerLevelsEventContent) error GetPowerLevels(ctx context.Context, roomID id.RoomID) (*event.PowerLevelsEventContent, error) + SetCreate(ctx context.Context, evt *event.Event) error + GetCreate(ctx context.Context, roomID id.RoomID) (*event.Event, error) + HasFetchedMembers(ctx context.Context, roomID id.RoomID) (bool, error) MarkMembersFetched(ctx context.Context, roomID id.RoomID) error GetAllMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) @@ -68,9 +71,11 @@ func UpdateStateStore(ctx context.Context, store StateStore, evt *event.Event) { err = store.SetPowerLevels(ctx, evt.RoomID, content) case *event.EncryptionEventContent: err = store.SetEncryptionEvent(ctx, evt.RoomID, content) + case *event.CreateEventContent: + err = store.SetCreate(ctx, evt) default: switch evt.Type { - case event.StateMember, event.StatePowerLevels, event.StateEncryption: + case event.StateMember, event.StatePowerLevels, event.StateEncryption, event.StateCreate: zerolog.Ctx(ctx).Warn(). Stringer("event_id", evt.ID). Str("event_type", evt.Type.Type). @@ -101,6 +106,7 @@ type MemoryStateStore struct { MembersFetched map[id.RoomID]bool `json:"members_fetched"` PowerLevels map[id.RoomID]*event.PowerLevelsEventContent `json:"power_levels"` Encryption map[id.RoomID]*event.EncryptionEventContent `json:"encryption"` + Create map[id.RoomID]*event.Event `json:"create"` registrationsLock sync.RWMutex membersLock sync.RWMutex @@ -115,6 +121,7 @@ func NewMemoryStateStore() StateStore { MembersFetched: make(map[id.RoomID]bool), PowerLevels: make(map[id.RoomID]*event.PowerLevelsEventContent), Encryption: make(map[id.RoomID]*event.EncryptionEventContent), + Create: make(map[id.RoomID]*event.Event), } } @@ -298,6 +305,9 @@ func (store *MemoryStateStore) SetPowerLevels(_ context.Context, roomID id.RoomI func (store *MemoryStateStore) GetPowerLevels(_ context.Context, roomID id.RoomID) (levels *event.PowerLevelsEventContent, err error) { store.powerLevelsLock.RLock() levels = store.PowerLevels[roomID] + if levels != nil && levels.CreateEvent == nil { + levels.CreateEvent = store.Create[roomID] + } store.powerLevelsLock.RUnlock() return } @@ -314,6 +324,23 @@ func (store *MemoryStateStore) HasPowerLevel(ctx context.Context, roomID id.Room return exerrors.Must(store.GetPowerLevel(ctx, roomID, userID)) >= exerrors.Must(store.GetPowerLevelRequirement(ctx, roomID, eventType)), nil } +func (store *MemoryStateStore) SetCreate(ctx context.Context, evt *event.Event) error { + store.powerLevelsLock.Lock() + store.Create[evt.RoomID] = evt + if pls, ok := store.PowerLevels[evt.RoomID]; ok && pls.CreateEvent == nil { + pls.CreateEvent = evt + } + store.powerLevelsLock.Unlock() + return nil +} + +func (store *MemoryStateStore) GetCreate(ctx context.Context, roomID id.RoomID) (*event.Event, error) { + store.powerLevelsLock.RLock() + evt := store.Create[roomID] + store.powerLevelsLock.RUnlock() + return evt, nil +} + func (store *MemoryStateStore) SetEncryptionEvent(_ context.Context, roomID id.RoomID, content *event.EncryptionEventContent) error { store.encryptionLock.Lock() store.Encryption[roomID] = content From 96b07ad724dd3d7785c5eb30ae02749b17500070 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 13 Jul 2025 12:44:15 +0300 Subject: [PATCH 177/581] event: use full event type for stripped state for MSC4311 --- event/events.go | 25 +++++++++---------------- responses.go | 7 +------ sync.go | 9 ++------- 3 files changed, 12 insertions(+), 29 deletions(-) diff --git a/event/events.go b/event/events.go index a763cc31..1428bf8a 100644 --- a/event/events.go +++ b/event/events.go @@ -130,23 +130,16 @@ func (evt *Event) GetStateKey() string { return "" } -type StrippedState struct { - Content Content `json:"content"` - Type Type `json:"type"` - StateKey string `json:"state_key"` - Sender id.UserID `json:"sender"` -} - type Unsigned struct { - PrevContent *Content `json:"prev_content,omitempty"` - PrevSender id.UserID `json:"prev_sender,omitempty"` - Membership Membership `json:"membership,omitempty"` - ReplacesState id.EventID `json:"replaces_state,omitempty"` - Age int64 `json:"age,omitempty"` - TransactionID string `json:"transaction_id,omitempty"` - Relations *Relations `json:"m.relations,omitempty"` - RedactedBecause *Event `json:"redacted_because,omitempty"` - InviteRoomState []StrippedState `json:"invite_room_state,omitempty"` + PrevContent *Content `json:"prev_content,omitempty"` + PrevSender id.UserID `json:"prev_sender,omitempty"` + Membership Membership `json:"membership,omitempty"` + ReplacesState id.EventID `json:"replaces_state,omitempty"` + Age int64 `json:"age,omitempty"` + TransactionID string `json:"transaction_id,omitempty"` + Relations *Relations `json:"m.relations,omitempty"` + RedactedBecause *Event `json:"redacted_because,omitempty"` + InviteRoomState []*Event `json:"invite_room_state,omitempty"` BeeperHSOrder int64 `json:"com.beeper.hs.order,omitempty"` BeeperHSSuborder int16 `json:"com.beeper.hs.suborder,omitempty"` diff --git a/responses.go b/responses.go index 20d02af5..2e8005d4 100644 --- a/responses.go +++ b/responses.go @@ -648,12 +648,7 @@ type RespHierarchy struct { type ChildRoomsChunk struct { PublicRoomInfo - ChildrenState []StrippedStateWithTime `json:"children_state"` -} - -type StrippedStateWithTime struct { - event.StrippedState - Timestamp jsontime.UnixMilli `json:"origin_server_ts"` + ChildrenState []*event.Event `json:"children_state"` } type RespAppservicePing struct { diff --git a/sync.go b/sync.go index 9a2b9edf..c52bd2f9 100644 --- a/sync.go +++ b/sync.go @@ -263,7 +263,7 @@ func dontProcessOldEvents(userID id.UserID, resp *RespSync, since string) bool { // cli.Syncer.(mautrix.ExtensibleSyncer).OnSync(cli.MoveInviteState) func (cli *Client) MoveInviteState(ctx context.Context, resp *RespSync, _ string) bool { for _, meta := range resp.Rooms.Invite { - var inviteState []event.StrippedState + var inviteState []*event.Event var inviteEvt *event.Event for _, evt := range meta.State.Events { if evt.Type == event.StateMember && evt.GetStateKey() == cli.UserID.String() { @@ -271,12 +271,7 @@ func (cli *Client) MoveInviteState(ctx context.Context, resp *RespSync, _ string } else { evt.Type.Class = event.StateEventType _ = evt.Content.ParseRaw(evt.Type) - inviteState = append(inviteState, event.StrippedState{ - Content: evt.Content, - Type: evt.Type, - StateKey: evt.GetStateKey(), - Sender: evt.Sender, - }) + inviteState = append(inviteState, evt) } } if inviteEvt != nil { From 4866da52005c376dd10cb53e08399a543894fb93 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 18 Jul 2025 23:29:51 +0300 Subject: [PATCH 178/581] client: add custom room create ts field --- requests.go | 1 + 1 file changed, 1 insertion(+) diff --git a/requests.go b/requests.go index 09e4b3cd..17eda7d2 100644 --- a/requests.go +++ b/requests.go @@ -125,6 +125,7 @@ type ReqCreateRoom struct { PowerLevelOverride *event.PowerLevelsEventContent `json:"power_level_content_override,omitempty"` MeowRoomID id.RoomID `json:"fi.mau.room_id,omitempty"` + MeowCreateTS int64 `json:"fi.mau.origin_server_ts,omitempty"` BeeperInitialMembers []id.UserID `json:"com.beeper.initial_members,omitempty"` BeeperAutoJoinInvites bool `json:"com.beeper.auto_join_invites,omitempty"` BeeperLocalRoomID id.RoomID `json:"com.beeper.local_room_id,omitempty"` From 65a64c8044dd02a930fb5e446edaa52ac97389fd Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 20 Jul 2025 14:23:20 +0300 Subject: [PATCH 179/581] client: allow using custom http client for .well-known resolution --- client.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client.go b/client.go index 886dbb63..1f907608 100644 --- a/client.go +++ b/client.go @@ -139,6 +139,10 @@ type IdentityServerInfo struct { // Use ParseUserID to extract the server name from a user ID. // https://spec.matrix.org/v1.2/client-server-api/#server-discovery func DiscoverClientAPI(ctx context.Context, serverName string) (*ClientWellKnown, error) { + return DiscoverClientAPIWithClient(ctx, &http.Client{Timeout: 30 * time.Second}, serverName) +} + +func DiscoverClientAPIWithClient(ctx context.Context, client *http.Client, serverName string) (*ClientWellKnown, error) { wellKnownURL := url.URL{ Scheme: "https", Host: serverName, @@ -153,7 +157,6 @@ func DiscoverClientAPI(ctx context.Context, serverName string) (*ClientWellKnown req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", DefaultUserAgent+" (.well-known fetcher)") - client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err From ea72271badd31f4f1ec27ae63be15ae25fcaa9df Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 21 Jul 2025 11:15:23 +0300 Subject: [PATCH 180/581] bridgev2/queue: run command handlers in background --- bridgev2/queue.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bridgev2/queue.go b/bridgev2/queue.go index 04d982b5..95011cda 100644 --- a/bridgev2/queue.go +++ b/bridgev2/queue.go @@ -106,7 +106,7 @@ func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) EventH br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) return EventHandlingResultIgnored } - br.Commands.Handle( + go br.Commands.Handle( ctx, evt.RoomID, evt.ID, @@ -114,7 +114,7 @@ func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) EventH strings.TrimPrefix(msg.Body, br.Config.CommandPrefix+" "), msg.RelatesTo.GetReplyTo(), ) - return EventHandlingResultSuccess + return EventHandlingResultQueued } } if evt.Type == event.StateMember && evt.GetStateKey() == br.Bot.GetMXID().String() && evt.Content.AsMember().Membership == event.MembershipInvite && sender != nil { From 3ecdb886bfd03c850bec6737ea6bf94db44a5ec9 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 22 Jul 2025 16:18:25 +0300 Subject: [PATCH 181/581] bridgev2/database: add method to mark backfill task as not done --- bridgev2/database/backfillqueue.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bridgev2/database/backfillqueue.go b/bridgev2/database/backfillqueue.go index 224ae626..1f920640 100644 --- a/bridgev2/database/backfillqueue.go +++ b/bridgev2/database/backfillqueue.go @@ -78,6 +78,11 @@ const ( dispatched_at=$9, completed_at=$10, next_dispatch_min_ts=$11 WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 ` + markBackfillTaskNotDoneQuery = ` + UPDATE backfill_task + SET is_done = false + WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 AND user_login_id = $4 + ` getNextBackfillQuery = ` SELECT bridge_id, portal_id, portal_receiver, user_login_id, batch_count, is_done, @@ -127,6 +132,10 @@ func (btq *BackfillTaskQuery) Update(ctx context.Context, bq *BackfillTask) erro return btq.Exec(ctx, updateBackfillQueueQuery, bq.sqlVariables()...) } +func (btq *BackfillTaskQuery) MarkNotDone(ctx context.Context, portalKey networkid.PortalKey, userLoginID networkid.UserLoginID) error { + return btq.Exec(ctx, markBackfillTaskNotDoneQuery, btq.BridgeID, portalKey.ID, portalKey.Receiver, userLoginID) +} + func (btq *BackfillTaskQuery) GetNext(ctx context.Context) (*BackfillTask, error) { return btq.QueryOne(ctx, getNextBackfillQuery, btq.BridgeID, time.Now().UnixNano()) } From 3fe5a7badc23f233ab951de46d5af16ad03e29da Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 22 Jul 2025 17:19:47 +0300 Subject: [PATCH 182/581] event: replace soft failed field in unsigned --- event/events.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/event/events.go b/event/events.go index 1428bf8a..1a57fb4b 100644 --- a/event/events.go +++ b/event/events.go @@ -146,13 +146,12 @@ type Unsigned struct { BeeperHSOrderString *BeeperEncodedOrder `json:"com.beeper.hs.order_string,omitempty"` BeeperFromBackup bool `json:"com.beeper.from_backup,omitempty"` - MauSoftFailed bool `json:"fi.mau.soft_failed,omitempty"` - MauRejectionReason string `json:"fi.mau.rejection_reason,omitempty"` + ElementSoftFailed bool `json:"io.element.synapse.soft_failed,omitempty"` } func (us *Unsigned) IsEmpty() bool { return us.PrevContent == nil && us.PrevSender == "" && us.ReplacesState == "" && us.Age == 0 && us.Membership == "" && us.TransactionID == "" && us.RedactedBecause == nil && us.InviteRoomState == nil && us.Relations == nil && us.BeeperHSOrder == 0 && us.BeeperHSSuborder == 0 && us.BeeperHSOrderString.IsZero() && - !us.MauSoftFailed && us.MauRejectionReason == "" + !us.ElementSoftFailed } From fcd7d9a525ad36f812d1d31fb676ac3b2796f120 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 22 Jul 2025 19:19:37 +0300 Subject: [PATCH 183/581] bridgev2/commands: allow canceling qr login --- bridgev2/commands/login.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bridgev2/commands/login.go b/bridgev2/commands/login.go index 3544998c..a18564c2 100644 --- a/bridgev2/commands/login.go +++ b/bridgev2/commands/login.go @@ -273,6 +273,13 @@ func doLoginDisplayAndWait(ce *Event, login bridgev2.LoginProcessDisplayAndWait, prevEvent = new(id.EventID) ce.Ctx = context.WithValue(ce.Ctx, contextKeyPrevEventID, prevEvent) } + cancelCtx, cancelFunc := context.WithCancel(ce.Ctx) + defer cancelFunc() + StoreCommandState(ce.User, &CommandState{ + Action: "Login", + Cancel: cancelFunc, + }) + defer StoreCommandState(ce.User, nil) switch step.DisplayAndWaitParams.Type { case bridgev2.LoginDisplayTypeQR: err := sendQR(ce, step.DisplayAndWaitParams.Data, prevEvent) @@ -292,7 +299,7 @@ func doLoginDisplayAndWait(ce *Event, login bridgev2.LoginProcessDisplayAndWait, login.Cancel() return } - nextStep, err := login.Wait(ce.Ctx) + nextStep, err := login.Wait(cancelCtx) // Redact the QR code, unless the next step is refreshing the code (in which case the event is just edited) if *prevEvent != "" && (nextStep == nil || nextStep.StepID != step.StepID) { _, _ = ce.Bot.SendMessage(ce.Ctx, ce.RoomID, event.EventRedaction, &event.Content{ From cb80e5c63f7f7a4e2ec61f19b6e84754d83c0df5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 22 Jul 2025 20:31:25 +0300 Subject: [PATCH 184/581] bridgev2/portal: fix adding rooms to personal space on create --- bridgev2/portal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index c91523ef..114609ce 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4379,7 +4379,7 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo } func (portal *Portal) addToUserSpaces(ctx context.Context) { - if portal.Parent == nil { + if portal.Parent != nil { return } log := zerolog.Ctx(ctx) From 69a3d27c1c9f360da7d96bdd6c12de0f810f77c6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 22 Jul 2025 22:50:26 +0300 Subject: [PATCH 185/581] bridgev2: add interface for getting arbitrary state event --- bridgev2/matrix/connector.go | 13 ++++++++----- bridgev2/matrixinterface.go | 5 ++++- bridgev2/portal.go | 11 +++++++---- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 978f666f..c168ae3d 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -101,6 +101,7 @@ type Connector struct { var ( _ bridgev2.MatrixConnector = (*Connector)(nil) _ bridgev2.MatrixConnectorWithServer = (*Connector)(nil) + _ bridgev2.MatrixConnectorWithArbitraryRoomState = (*Connector)(nil) _ bridgev2.MatrixConnectorWithPostRoomBridgeHandling = (*Connector)(nil) _ bridgev2.MatrixConnectorWithPublicMedia = (*Connector)(nil) _ bridgev2.MatrixConnectorWithNameDisambiguation = (*Connector)(nil) @@ -534,12 +535,14 @@ func (br *Connector) GetPowerLevels(ctx context.Context, roomID id.RoomID) (*eve return br.Bot.PowerLevels(ctx, roomID) } -func (br *Connector) GetCreateEvent(ctx context.Context, roomID id.RoomID) (*event.Event, error) { - createEvt, err := br.Bot.StateStore.GetCreate(ctx, roomID) - if err != nil || createEvt != nil { - return createEvt, err +func (br *Connector) GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error) { + if eventType == event.StateCreate && stateKey == "" { + createEvt, err := br.Bot.StateStore.GetCreate(ctx, roomID) + if err != nil || createEvt != nil { + return createEvt, err + } } - return br.Bot.FullStateEvent(ctx, roomID, event.StateCreate, "") + return br.Bot.FullStateEvent(ctx, roomID, eventType, "") } func (br *Connector) GetMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index 5d0cb014..b5a575ba 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -47,7 +47,6 @@ type MatrixConnector interface { GenerateContentURI(ctx context.Context, mediaID networkid.MediaID) (id.ContentURIString, error) GetPowerLevels(ctx context.Context, roomID id.RoomID) (*event.PowerLevelsEventContent, error) - GetCreateEvent(ctx context.Context, roomID id.RoomID) (*event.Event, error) GetMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) GetMemberInfo(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) @@ -59,6 +58,10 @@ type MatrixConnector interface { ServerName() string } +type MatrixConnectorWithArbitraryRoomState interface { + GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error) +} + type MatrixConnectorWithServer interface { GetPublicAddress() string GetRouter() *mux.Router diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 114609ce..55f1cd47 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1655,10 +1655,13 @@ func (portal *Portal) handleMatrixPowerLevels( return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) } if content.CreateEvent == nil { - var err error - content.CreateEvent, err = portal.Bridge.Matrix.GetCreateEvent(ctx, portal.MXID) - if err != nil { - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("failed to get create event for power levels: %w", err)) + ars, ok := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState) + if ok { + var err error + content.CreateEvent, err = ars.GetStateEvent(ctx, portal.MXID, event.StateCreate, "") + if err != nil { + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("failed to get create event for power levels: %w", err)) + } } } api, ok := sender.Client.(PowerLevelHandlingNetworkAPI) From 463d2ea6d01154a2b1970a6197aa4d35dbb921bf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 22 Jul 2025 23:31:16 +0300 Subject: [PATCH 186/581] bridgev2/portal: add bots to functional members in DMs --- bridgev2/portal.go | 43 +++++++++++++++++++++++++++++++++++++++++++ event/state.go | 9 +++++++++ 2 files changed, 52 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 55f1cd47..5fea134f 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -87,6 +87,9 @@ type Portal struct { roomCreateLock sync.Mutex + functionalMembersLock sync.Mutex + functionalMembersCache *event.ElementFunctionalMembersContent + events chan portalEvent eventsLock sync.Mutex @@ -2043,6 +2046,45 @@ func (portal *Portal) handleRemoteEvent(ctx context.Context, source *UserLogin, return } +func (portal *Portal) ensureFunctionalMember(ctx context.Context, ghost *Ghost) { + if !ghost.IsBot || portal.RoomType != database.RoomTypeDM || portal.OtherUserID == ghost.ID { + return + } + ars, ok := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState) + if !ok { + return + } + portal.functionalMembersLock.Lock() + defer portal.functionalMembersLock.Unlock() + var functionalMembers *event.ElementFunctionalMembersContent + if portal.functionalMembersCache != nil { + functionalMembers = portal.functionalMembersCache + } else { + evt, err := ars.GetStateEvent(ctx, portal.MXID, event.StateElementFunctionalMembers, "") + if err != nil && !errors.Is(err, mautrix.MNotFound) { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get functional members state event") + return + } + functionalMembers = &event.ElementFunctionalMembersContent{} + if evt != nil { + evtContent, ok := evt.Content.Parsed.(*event.ElementFunctionalMembersContent) + if ok && evtContent != nil { + functionalMembers = evtContent + } + } + } + functionalMembers.Add(portal.Bridge.Bot.GetMXID()) + if functionalMembers.Add(ghost.Intent.GetMXID()) { + _, err := portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.Content{ + Parsed: functionalMembers, + }, time.Time{}) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to update functional members state event") + return + } + } +} + func (portal *Portal) getIntentAndUserMXIDFor(ctx context.Context, sender EventSender, source *UserLogin, otherLogins []*UserLogin, evtType RemoteEventType) (intent MatrixAPI, extraUserID id.UserID, err error) { var ghost *Ghost if !sender.IsFromMe && sender.ForceDMUser && portal.OtherUserID != "" && sender.Sender != portal.OtherUserID { @@ -2066,6 +2108,7 @@ func (portal *Portal) getIntentAndUserMXIDFor(ctx context.Context, sender EventS return } ghost.UpdateInfoIfNecessary(ctx, source, evtType) + portal.ensureFunctionalMember(ctx, ghost) } if sender.IsFromMe { intent = source.User.DoublePuppet(ctx) diff --git a/event/state.go b/event/state.go index ff6dabfa..83390c90 100644 --- a/event/state.go +++ b/event/state.go @@ -8,6 +8,7 @@ package event import ( "encoding/base64" + "slices" "maunium.net/go/mautrix/id" ) @@ -267,3 +268,11 @@ type InsertionMarkerContent struct { type ElementFunctionalMembersContent struct { ServiceMembers []id.UserID `json:"service_members"` } + +func (efmc *ElementFunctionalMembersContent) Add(mxid id.UserID) bool { + if slices.Contains(efmc.ServiceMembers, mxid) { + return false + } + efmc.ServiceMembers = append(efmc.ServiceMembers, mxid) + return true +} From 5b55330b859c8eca30cdba627fbe8e12f62fa9b0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 23 Jul 2025 14:37:57 +0300 Subject: [PATCH 187/581] bridgev2: run PostStart in background --- bridgev2/bridge.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index a4ce033e..24619c79 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -120,7 +120,7 @@ func (br *Bridge) Start(ctx context.Context) error { if err != nil { return err } - br.PostStart(ctx) + go br.PostStart(ctx) return nil } From d5223cdc8fcebace5f1de8d9b02f4f4568fc663d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 23 Jul 2025 20:30:43 +0300 Subject: [PATCH 188/581] all: replace gorilla/mux with standard library --- appservice/appservice.go | 25 +++-- appservice/http.go | 10 +- bridgev2/matrix/connector.go | 4 +- bridgev2/matrix/provisioning.go | 96 ++++++++++++-------- bridgev2/matrix/publicmedia.go | 13 +-- bridgev2/matrixinterface.go | 7 +- crypto/verificationhelper/mockserver_test.go | 41 +++------ federation/keyserver.go | 34 +++---- go.mod | 9 +- go.sum | 14 ++- mediaproxy/mediaproxy.go | 95 ++++++++----------- 11 files changed, 161 insertions(+), 187 deletions(-) diff --git a/appservice/appservice.go b/appservice/appservice.go index 518e1073..5dd067c0 100644 --- a/appservice/appservice.go +++ b/appservice/appservice.go @@ -1,4 +1,4 @@ -// Copyright (c) 2023 Tulir Asokan +// Copyright (c) 2025 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -19,7 +19,6 @@ import ( "syscall" "time" - "github.com/gorilla/mux" "github.com/gorilla/websocket" "github.com/rs/zerolog" "golang.org/x/net/publicsuffix" @@ -43,7 +42,7 @@ func Create() *AppService { intents: make(map[id.UserID]*IntentAPI), HTTPClient: &http.Client{Timeout: 180 * time.Second, Jar: jar}, StateStore: mautrix.NewMemoryStateStore().(StateStore), - Router: mux.NewRouter(), + Router: http.NewServeMux(), UserAgent: mautrix.DefaultUserAgent, txnIDC: NewTransactionIDCache(128), Live: true, @@ -61,12 +60,12 @@ func Create() *AppService { DefaultHTTPRetries: 4, } - as.Router.HandleFunc("/_matrix/app/v1/transactions/{txnID}", as.PutTransaction).Methods(http.MethodPut) - as.Router.HandleFunc("/_matrix/app/v1/rooms/{roomAlias}", as.GetRoom).Methods(http.MethodGet) - as.Router.HandleFunc("/_matrix/app/v1/users/{userID}", as.GetUser).Methods(http.MethodGet) - as.Router.HandleFunc("/_matrix/app/v1/ping", as.PostPing).Methods(http.MethodPost) - as.Router.HandleFunc("/_matrix/mau/live", as.GetLive).Methods(http.MethodGet) - as.Router.HandleFunc("/_matrix/mau/ready", as.GetReady).Methods(http.MethodGet) + as.Router.HandleFunc("PUT /_matrix/app/v1/transactions/{txnID}", as.PutTransaction) + as.Router.HandleFunc("GET /_matrix/app/v1/rooms/{roomAlias}", as.GetRoom) + as.Router.HandleFunc("GET /_matrix/app/v1/users/{userID}", as.GetUser) + as.Router.HandleFunc("POST /_matrix/app/v1/ping", as.PostPing) + as.Router.HandleFunc("GET /_matrix/mau/live", as.GetLive) + as.Router.HandleFunc("GET /_matrix/mau/ready", as.GetReady) return as } @@ -114,13 +113,13 @@ var _ StateStore = (*mautrix.MemoryStateStore)(nil) // QueryHandler handles room alias and user ID queries from the homeserver. type QueryHandler interface { - QueryAlias(alias string) bool + QueryAlias(alias id.RoomAlias) bool QueryUser(userID id.UserID) bool } type QueryHandlerStub struct{} -func (qh *QueryHandlerStub) QueryAlias(alias string) bool { +func (qh *QueryHandlerStub) QueryAlias(alias id.RoomAlias) bool { return false } @@ -128,7 +127,7 @@ func (qh *QueryHandlerStub) QueryUser(userID id.UserID) bool { return false } -type WebsocketHandler func(WebsocketCommand) (ok bool, data interface{}) +type WebsocketHandler func(WebsocketCommand) (ok bool, data any) type StateStore interface { mautrix.StateStore @@ -160,7 +159,7 @@ type AppService struct { QueryHandler QueryHandler StateStore StateStore - Router *mux.Router + Router *http.ServeMux UserAgent string server *http.Server HTTPClient *http.Client diff --git a/appservice/http.go b/appservice/http.go index 1ebe6e56..862de7fd 100644 --- a/appservice/http.go +++ b/appservice/http.go @@ -17,7 +17,6 @@ import ( "syscall" "time" - "github.com/gorilla/mux" "github.com/rs/zerolog" "go.mau.fi/util/exhttp" "go.mau.fi/util/exstrings" @@ -95,8 +94,7 @@ func (as *AppService) PutTransaction(w http.ResponseWriter, r *http.Request) { return } - vars := mux.Vars(r) - txnID := vars["txnID"] + txnID := r.PathValue("txnID") if len(txnID) == 0 { mautrix.MInvalidParam.WithMessage("Missing transaction ID").Write(w) return @@ -240,8 +238,7 @@ func (as *AppService) GetRoom(w http.ResponseWriter, r *http.Request) { return } - vars := mux.Vars(r) - roomAlias := vars["roomAlias"] + roomAlias := id.RoomAlias(r.PathValue("roomAlias")) ok := as.QueryHandler.QueryAlias(roomAlias) if ok { exhttp.WriteEmptyJSONResponse(w, http.StatusOK) @@ -256,8 +253,7 @@ func (as *AppService) GetUser(w http.ResponseWriter, r *http.Request) { return } - vars := mux.Vars(r) - userID := id.UserID(vars["userID"]) + userID := id.UserID(r.PathValue("userID")) ok := as.QueryHandler.QueryUser(userID) if ok { exhttp.WriteEmptyJSONResponse(w, http.StatusOK) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index c168ae3d..af9931b0 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -12,6 +12,7 @@ import ( "encoding/base64" "errors" "fmt" + "net/http" "net/url" "os" "regexp" @@ -20,7 +21,6 @@ import ( "time" "unsafe" - "github.com/gorilla/mux" _ "github.com/lib/pq" "github.com/rs/zerolog" "go.mau.fi/util/dbutil" @@ -223,7 +223,7 @@ func (br *Connector) GetPublicAddress() string { return br.Config.AppService.PublicAddress } -func (br *Connector) GetRouter() *mux.Router { +func (br *Connector) GetRouter() *http.ServeMux { if br.GetPublicAddress() != "" { return br.AS.Router } diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index f865a19e..7f4b8a2e 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -1,4 +1,4 @@ -// Copyright (c) 2024 Tulir Asokan +// Copyright (c) 2025 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -17,7 +17,6 @@ import ( "sync" "time" - "github.com/gorilla/mux" "github.com/rs/xid" "github.com/rs/zerolog" "github.com/rs/zerolog/hlog" @@ -40,7 +39,7 @@ type matrixAuthCacheEntry struct { } type ProvisioningAPI struct { - Router *mux.Router + Router *http.ServeMux br *Connector log zerolog.Logger @@ -91,12 +90,12 @@ func (prov *ProvisioningAPI) GetUser(r *http.Request) *bridgev2.User { return r.Context().Value(provisioningUserKey).(*bridgev2.User) } -func (prov *ProvisioningAPI) GetRouter() *mux.Router { +func (prov *ProvisioningAPI) GetRouter() *http.ServeMux { return prov.Router } type IProvisioningAPI interface { - GetRouter() *mux.Router + GetRouter() *http.ServeMux GetUser(r *http.Request) *bridgev2.User } @@ -116,41 +115,48 @@ func (prov *ProvisioningAPI) Init() { tp.Dialer.Timeout = 10 * time.Second tp.Transport.ResponseHeaderTimeout = 10 * time.Second tp.Transport.TLSHandshakeTimeout = 10 * time.Second - prov.Router = prov.br.AS.Router.PathPrefix(prov.br.Config.Provisioning.Prefix).Subrouter() - prov.Router.Use(hlog.NewHandler(prov.log)) - prov.Router.Use(hlog.RequestIDHandler("request_id", "Request-Id")) - prov.Router.Use(exhttp.CORSMiddleware) - prov.Router.Use(requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true})) - prov.Router.Use(prov.AuthMiddleware) - prov.Router.Path("/v3/whoami").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(prov.GetWhoami) - prov.Router.Path("/v3/login/flows").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(prov.GetLoginFlows) - prov.Router.Path("/v3/login/start/{flowID}").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostLoginStart) - prov.Router.Path("/v3/login/step/{loginProcessID}/{stepID}/{stepType:user_input|cookies}").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostLoginSubmitInput) - prov.Router.Path("/v3/login/step/{loginProcessID}/{stepID}/{stepType:display_and_wait}").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostLoginWait) - prov.Router.Path("/v3/logout/{loginID}").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostLogout) - prov.Router.Path("/v3/logins").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(prov.GetLogins) - prov.Router.Path("/v3/contacts").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(prov.GetContactList) - prov.Router.Path("/v3/search_users").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostSearchUsers) - prov.Router.Path("/v3/resolve_identifier/{identifier}").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(prov.GetResolveIdentifier) - prov.Router.Path("/v3/create_dm/{identifier}").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostCreateDM) - prov.Router.Path("/v3/create_group").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostCreateGroup) + prov.Router = http.NewServeMux() + prov.Router.HandleFunc("GET /v3/whoami", prov.GetWhoami) + prov.Router.HandleFunc("GET /v3/login/flows", prov.GetLoginFlows) + prov.Router.HandleFunc("POST /v3/login/start/{flowID}", prov.PostLoginStart) + prov.Router.HandleFunc("POST /v3/login/step/{loginProcessID}/{stepID}/{stepType}", prov.PostLoginStep) + prov.Router.HandleFunc("POST /v3/logout/{loginID}", prov.PostLogout) + prov.Router.HandleFunc("GET /v3/logins", prov.GetLogins) + prov.Router.HandleFunc("GET /v3/contacts", prov.GetContactList) + prov.Router.HandleFunc("POST /v3/search_users", prov.PostSearchUsers) + prov.Router.HandleFunc("GET /v3/resolve_identifier/{identifier}", prov.GetResolveIdentifier) + prov.Router.HandleFunc("POST /v3/create_dm/{identifier}", prov.PostCreateDM) + prov.Router.HandleFunc("POST /v3/create_group", prov.PostCreateGroup) if prov.br.Config.Provisioning.EnableSessionTransfers { prov.log.Debug().Msg("Enabling session transfer API") - prov.Router.Path("/v3/session_transfer/init").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostInitSessionTransfer) - prov.Router.Path("/v3/session_transfer/finish").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostFinishSessionTransfer) + prov.Router.HandleFunc("POST /v3/session_transfer/init", prov.PostInitSessionTransfer) + prov.Router.HandleFunc("POST /v3/session_transfer/finish", prov.PostFinishSessionTransfer) } if prov.br.Config.Provisioning.DebugEndpoints { prov.log.Debug().Msg("Enabling debug API at /debug") - r := prov.br.AS.Router.PathPrefix("/debug").Subrouter() - r.Use(prov.DebugAuthMiddleware) - r.HandleFunc("/pprof/cmdline", pprof.Cmdline).Methods(http.MethodGet) - r.HandleFunc("/pprof/profile", pprof.Profile).Methods(http.MethodGet) - r.HandleFunc("/pprof/symbol", pprof.Symbol).Methods(http.MethodGet) - r.HandleFunc("/pprof/trace", pprof.Trace).Methods(http.MethodGet) - r.PathPrefix("/pprof/").HandlerFunc(pprof.Index) + debugRouter := http.NewServeMux() + debugRouter.HandleFunc("GET /pprof/cmdline", pprof.Cmdline) + debugRouter.HandleFunc("GET /pprof/profile", pprof.Profile) + debugRouter.HandleFunc("GET /pprof/symbol", pprof.Symbol) + debugRouter.HandleFunc("GET /pprof/trace", pprof.Trace) + debugRouter.HandleFunc("/pprof/", pprof.Index) + prov.br.AS.Router.Handle("/debug", exhttp.ApplyMiddleware( + debugRouter, + hlog.NewHandler(prov.br.Log.With().Str("component", "debug api").Logger()), + prov.DebugAuthMiddleware, + )) } + + prov.br.AS.Router.Handle("/_matrix/provision", exhttp.ApplyMiddleware( + prov.Router, + hlog.NewHandler(prov.log), + hlog.RequestIDHandler("request_id", "Request-Id"), + exhttp.CORSMiddleware, + requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), + prov.AuthMiddleware, + )) } func (prov *ProvisioningAPI) checkMatrixAuth(ctx context.Context, userID id.UserID, token string) error { @@ -250,7 +256,7 @@ func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { ctx := context.WithValue(r.Context(), ProvisioningKeyRequest, r) ctx = context.WithValue(ctx, provisioningUserKey, user) - if loginID, ok := mux.Vars(r)["loginProcessID"]; ok { + if loginID := r.PathValue("loginProcessID"); loginID != "" { prov.loginsLock.RLock() login, ok := prov.logins[loginID] prov.loginsLock.RUnlock() @@ -262,7 +268,7 @@ func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { login.Lock.Lock() // This will only unlock after the handler runs defer login.Lock.Unlock() - stepID := mux.Vars(r)["stepID"] + stepID := r.PathValue("stepID") if login.NextStep.StepID != stepID { zerolog.Ctx(r.Context()).Warn(). Str("request_step_id", stepID). @@ -271,7 +277,7 @@ func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { mautrix.MBadState.WithMessage("Step ID does not match").Write(w) return } - stepType := mux.Vars(r)["stepType"] + stepType := r.PathValue("stepType") if login.NextStep.Type != bridgev2.LoginStepType(stepType) { zerolog.Ctx(r.Context()).Warn(). Str("request_step_type", stepType). @@ -374,7 +380,7 @@ func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Reque login, err := prov.net.CreateLogin( r.Context(), prov.GetUser(r), - mux.Vars(r)["flowID"], + r.PathValue("flowID"), ) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to create login process") @@ -422,6 +428,20 @@ func (prov *ProvisioningAPI) handleCompleteStep(ctx context.Context, login *Prov }, bridgev2.DeleteOpts{LogoutRemote: true}) } +func (prov *ProvisioningAPI) PostLoginStep(w http.ResponseWriter, r *http.Request) { + switch bridgev2.LoginStepType(r.PathValue("stepType")) { + case bridgev2.LoginStepTypeUserInput, bridgev2.LoginStepTypeCookies: + prov.PostLoginSubmitInput(w, r) + case bridgev2.LoginStepTypeDisplayAndWait: + prov.PostLoginWait(w, r) + case bridgev2.LoginStepTypeComplete: + fallthrough + default: + // This is probably impossible because AuthMiddleware checks that the next step type matches the request. + mautrix.MUnrecognized.WithMessage("Invalid step type %q", r.PathValue("stepType")).Write(w) + } +} + func (prov *ProvisioningAPI) PostLoginSubmitInput(w http.ResponseWriter, r *http.Request) { var params map[string]string err := json.NewDecoder(r.Body).Decode(¶ms) @@ -475,7 +495,7 @@ func (prov *ProvisioningAPI) PostLoginWait(w http.ResponseWriter, r *http.Reques func (prov *ProvisioningAPI) PostLogout(w http.ResponseWriter, r *http.Request) { user := prov.GetUser(r) - userLoginID := networkid.UserLoginID(mux.Vars(r)["loginID"]) + userLoginID := networkid.UserLoginID(r.PathValue("loginID")) if userLoginID == "all" { for { login := user.GetDefaultLogin() @@ -571,7 +591,7 @@ func (prov *ProvisioningAPI) doResolveIdentifier(w http.ResponseWriter, r *http. mautrix.MUnrecognized.WithMessage("This bridge does not support resolving identifiers").Write(w) return } - resp, err := api.ResolveIdentifier(r.Context(), mux.Vars(r)["identifier"], createChat) + resp, err := api.ResolveIdentifier(r.Context(), r.PathValue("identifier"), createChat) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to resolve identifier") RespondWithError(w, err, "Internal error resolving identifier") diff --git a/bridgev2/matrix/publicmedia.go b/bridgev2/matrix/publicmedia.go index 9db5f442..95e37262 100644 --- a/bridgev2/matrix/publicmedia.go +++ b/bridgev2/matrix/publicmedia.go @@ -1,4 +1,4 @@ -// Copyright (c) 2024 Tulir Asokan +// Copyright (c) 2025 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -16,8 +16,6 @@ import ( "net/http" "time" - "github.com/gorilla/mux" - "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/id" ) @@ -35,7 +33,7 @@ func (br *Connector) initPublicMedia() error { return fmt.Errorf("public media hash length is negative") } br.pubMediaSigKey = []byte(br.Config.PublicMedia.SigningKey) - br.AS.Router.HandleFunc("/_mautrix/publicmedia/{server}/{mediaID}/{checksum}", br.servePublicMedia).Methods(http.MethodGet) + br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{server}/{mediaID}/{checksum}", br.servePublicMedia) return nil } @@ -76,16 +74,15 @@ var proxyHeadersToCopy = []string{ } func (br *Connector) servePublicMedia(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) contentURI := id.ContentURI{ - Homeserver: vars["server"], - FileID: vars["mediaID"], + Homeserver: r.PathValue("server"), + FileID: r.PathValue("mediaID"), } if !contentURI.IsValid() { http.Error(w, "invalid content URI", http.StatusBadRequest) return } - checksum, err := base64.RawURLEncoding.DecodeString(vars["checksum"]) + checksum, err := base64.RawURLEncoding.DecodeString(r.PathValue("checksum")) if err != nil || !hmac.Equal(checksum, br.makePublicMediaChecksum(contentURI)) { http.Error(w, "invalid base64 in checksum", http.StatusBadRequest) return diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index b5a575ba..b30e274a 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -1,4 +1,4 @@ -// Copyright (c) 2024 Tulir Asokan +// Copyright (c) 2025 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -10,11 +10,10 @@ import ( "context" "fmt" "io" + "net/http" "os" "time" - "github.com/gorilla/mux" - "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" @@ -64,7 +63,7 @@ type MatrixConnectorWithArbitraryRoomState interface { type MatrixConnectorWithServer interface { GetPublicAddress() string - GetRouter() *mux.Router + GetRouter() *http.ServeMux } type MatrixConnectorWithPublicMedia interface { diff --git a/crypto/verificationhelper/mockserver_test.go b/crypto/verificationhelper/mockserver_test.go index b6bf3d2c..45ca7781 100644 --- a/crypto/verificationhelper/mockserver_test.go +++ b/crypto/verificationhelper/mockserver_test.go @@ -12,11 +12,9 @@ import ( "io" "net/http" "net/http/httptest" - "net/url" "strings" "testing" - "github.com/gorilla/mux" "github.com/rs/zerolog/log" // zerolog-allow-global-log "github.com/stretchr/testify/require" "go.mau.fi/util/random" @@ -42,20 +40,6 @@ type mockServer struct { UserSigningKeys map[id.UserID]mautrix.CrossSigningKeys } -func DecodeVarsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - var err error - for k, v := range vars { - vars[k], err = url.PathUnescape(v) - if err != nil { - panic(err) - } - } - next.ServeHTTP(w, r) - }) -} - func createMockServer(t *testing.T) *mockServer { t.Helper() @@ -69,15 +53,14 @@ func createMockServer(t *testing.T) *mockServer { UserSigningKeys: map[id.UserID]mautrix.CrossSigningKeys{}, } - router := mux.NewRouter().SkipClean(true).StrictSlash(false).UseEncodedPath() - router.Use(DecodeVarsMiddleware) - router.HandleFunc("/_matrix/client/v3/login", server.postLogin).Methods(http.MethodPost) - router.HandleFunc("/_matrix/client/v3/keys/query", server.postKeysQuery).Methods(http.MethodPost) - router.HandleFunc("/_matrix/client/v3/sendToDevice/{type}/{txn}", server.putSendToDevice).Methods(http.MethodPut) - router.HandleFunc("/_matrix/client/v3/user/{userID}/account_data/{type}", server.putAccountData).Methods(http.MethodPut) - router.HandleFunc("/_matrix/client/v3/keys/device_signing/upload", server.postDeviceSigningUpload).Methods(http.MethodPost) - router.HandleFunc("/_matrix/client/v3/keys/signatures/upload", server.emptyResp).Methods(http.MethodPost) - router.HandleFunc("/_matrix/client/v3/keys/upload", server.postKeysUpload).Methods(http.MethodPost) + router := http.NewServeMux() + router.HandleFunc("POST /_matrix/client/v3/login", server.postLogin) + router.HandleFunc("POST /_matrix/client/v3/keys/query", server.postKeysQuery) + router.HandleFunc("PUT /_matrix/client/v3/sendToDevice/{type}/{txn}", server.putSendToDevice) + router.HandleFunc("PUT /_matrix/client/v3/user/{userID}/account_data/{type}", server.putAccountData) + router.HandleFunc("POST /_matrix/client/v3/keys/device_signing/upload", server.postDeviceSigningUpload) + router.HandleFunc("POST /_matrix/client/v3/keys/signatures/upload", server.emptyResp) + router.HandleFunc("POST /_matrix/client/v3/keys/upload", server.postKeysUpload) server.Server = httptest.NewServer(router) return &server @@ -118,10 +101,9 @@ func (s *mockServer) postLogin(w http.ResponseWriter, r *http.Request) { } func (s *mockServer) putSendToDevice(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) var req mautrix.ReqSendToDevice json.NewDecoder(r.Body).Decode(&req) - evtType := event.Type{Type: vars["type"], Class: event.ToDeviceEventType} + evtType := event.Type{Type: r.PathValue("type"), Class: event.ToDeviceEventType} for user, devices := range req.Messages { for device, content := range devices { @@ -140,9 +122,8 @@ func (s *mockServer) putSendToDevice(w http.ResponseWriter, r *http.Request) { } func (s *mockServer) putAccountData(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - userID := id.UserID(vars["userID"]) - eventType := event.Type{Type: vars["type"], Class: event.AccountDataEventType} + userID := id.UserID(r.PathValue("userID")) + eventType := event.Type{Type: r.PathValue("type"), Class: event.AccountDataEventType} jsonData, _ := io.ReadAll(r.Body) if _, ok := s.AccountData[userID]; !ok { diff --git a/federation/keyserver.go b/federation/keyserver.go index b0faf8fb..37998786 100644 --- a/federation/keyserver.go +++ b/federation/keyserver.go @@ -1,4 +1,4 @@ -// Copyright (c) 2024 Tulir Asokan +// Copyright (c) 2025 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -12,7 +12,7 @@ import ( "strconv" "time" - "github.com/gorilla/mux" + "go.mau.fi/util/exerrors" "go.mau.fi/util/exhttp" "go.mau.fi/util/jsontime" @@ -51,19 +51,21 @@ type KeyServer struct { } // Register registers the key server endpoints to the given router. -func (ks *KeyServer) Register(r *mux.Router) { - r.HandleFunc("/.well-known/matrix/server", ks.GetWellKnown).Methods(http.MethodGet) - r.HandleFunc("/_matrix/federation/v1/version", ks.GetServerVersion).Methods(http.MethodGet) - keyRouter := r.PathPrefix("/_matrix/key").Subrouter() - keyRouter.HandleFunc("/v2/server", ks.GetServerKey).Methods(http.MethodGet) - keyRouter.HandleFunc("/v2/query/{serverName}", ks.GetQueryKeys).Methods(http.MethodGet) - keyRouter.HandleFunc("/v2/query", ks.PostQueryKeys).Methods(http.MethodPost) - keyRouter.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mautrix.MUnrecognized.WithStatus(http.StatusNotFound).WithMessage("Unrecognized endpoint").Write(w) - }) - keyRouter.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mautrix.MUnrecognized.WithStatus(http.StatusMethodNotAllowed).WithMessage("Invalid method for endpoint").Write(w) - }) +func (ks *KeyServer) Register(r *http.ServeMux) { + r.HandleFunc("GET /.well-known/matrix/server", ks.GetWellKnown) + r.HandleFunc("GET /_matrix/federation/v1/version", ks.GetServerVersion) + keyRouter := http.NewServeMux() + keyRouter.HandleFunc("GET /v2/server", ks.GetServerKey) + keyRouter.HandleFunc("GET /v2/query/{serverName}", ks.GetQueryKeys) + keyRouter.HandleFunc("POST /v2/query", ks.PostQueryKeys) + errorBodies := exhttp.ErrorBodies{ + NotFound: exerrors.Must(json.Marshal(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint"))), + MethodNotAllowed: exerrors.Must(json.Marshal(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint"))), + } + r.Handle("/_matrix/key", exhttp.ApplyMiddleware( + keyRouter, + exhttp.HandleErrors(errorBodies), + )) } // RespWellKnown is the response body for the `GET /.well-known/matrix/server` endpoint. @@ -157,7 +159,7 @@ type GetQueryKeysResponse struct { // // https://spec.matrix.org/v1.9/server-server-api/#get_matrixkeyv2queryservername func (ks *KeyServer) GetQueryKeys(w http.ResponseWriter, r *http.Request) { - serverName := mux.Vars(r)["serverName"] + serverName := r.PathValue("serverName") minimumValidUntilTSString := r.URL.Query().Get("minimum_valid_until_ts") minimumValidUntilTS, err := strconv.ParseInt(minimumValidUntilTSString, 10, 64) if err != nil && minimumValidUntilTSString != "" { diff --git a/go.mod b/go.mod index 59f29c0c..d71e86ab 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,11 @@ module maunium.net/go/mautrix go 1.23.0 -toolchain go1.24.4 +toolchain go1.24.5 require ( filippo.io/edwards25519 v1.1.0 github.com/chzyer/readline v1.5.1 - github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.5.0 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.28 @@ -18,10 +17,10 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.12 - go.mau.fi/util v0.8.8 + go.mau.fi/util v0.8.9-0.20250723171559-474867266038 go.mau.fi/zeroconfig v0.1.3 golang.org/x/crypto v0.40.0 - golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc + golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 golang.org/x/net v0.42.0 golang.org/x/sync v0.16.0 gopkg.in/yaml.v3 v3.0.1 @@ -33,7 +32,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb // indirect + github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect diff --git a/go.sum b/go.sum index 9f48386e..eaa97cc8 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,6 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -28,8 +26,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb h1:3PrKuO92dUTMrQ9dx0YNejC6U/Si6jqKmyQ9vWjwqR4= -github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e h1:D0bJD+4O3G4izvrQUmzCL80zazlN7EwJ0PPDhpJWC/I= +github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -53,14 +51,14 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.8.8 h1:OnuEEc/sIJFhnq4kFggiImUpcmnmL/xpvQMRu5Fiy5c= -go.mau.fi/util v0.8.8/go.mod h1:Y/kS3loxTEhy8Vill513EtPXr+CRDdae+Xj2BXXMy/c= +go.mau.fi/util v0.8.9-0.20250723171559-474867266038 h1:RVL8TVaYc3LTBBopfjCNDtD+6eZks0O+qgXN/9hsz7k= +go.mau.fi/util v0.8.9-0.20250723171559-474867266038/go.mod h1:GZZp5f9r2MgEu4GDvtB0XxCF7i6Z7Z8fM0w9a5oZH3Y= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc= -golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= diff --git a/mediaproxy/mediaproxy.go b/mediaproxy/mediaproxy.go index 4be799d3..6fbcdbad 100644 --- a/mediaproxy/mediaproxy.go +++ b/mediaproxy/mediaproxy.go @@ -1,4 +1,4 @@ -// Copyright (c) 2024 Tulir Asokan +// Copyright (c) 2025 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,6 +8,7 @@ package mediaproxy import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -21,8 +22,9 @@ import ( "strings" "time" - "github.com/gorilla/mux" "github.com/rs/zerolog" + "go.mau.fi/util/exerrors" + "go.mau.fi/util/exhttp" "maunium.net/go/mautrix" "maunium.net/go/mautrix/federation" @@ -108,8 +110,8 @@ type MediaProxy struct { serverName string serverKey *federation.SigningKey - FederationRouter *mux.Router - ClientMediaRouter *mux.Router + FederationRouter *http.ServeMux + ClientMediaRouter *http.ServeMux } func New(serverName string, serverKey string, getMedia GetMediaFunc) (*MediaProxy, error) { @@ -117,7 +119,7 @@ func New(serverName string, serverKey string, getMedia GetMediaFunc) (*MediaProx if err != nil { return nil, err } - return &MediaProxy{ + mp := &MediaProxy{ serverName: serverName, serverKey: parsed, GetMedia: getMedia, @@ -132,7 +134,20 @@ func New(serverName string, serverKey string, getMedia GetMediaFunc) (*MediaProx Version: strings.TrimPrefix(mautrix.VersionWithCommit, "v"), }, }, - }, nil + } + mp.FederationRouter = http.NewServeMux() + mp.FederationRouter.HandleFunc("GET /v1/media/download/{mediaID}", mp.DownloadMediaFederation) + mp.FederationRouter.HandleFunc("GET /v1/version", mp.KeyServer.GetServerVersion) + mp.ClientMediaRouter = http.NewServeMux() + mp.ClientMediaRouter.HandleFunc("GET /download/{serverName}/{mediaID}", mp.DownloadMedia) + mp.ClientMediaRouter.HandleFunc("GET /download/{serverName}/{mediaID}/{fileName}", mp.DownloadMedia) + mp.ClientMediaRouter.HandleFunc("GET /thumbnail/{serverName}/{mediaID}", mp.DownloadMedia) + mp.ClientMediaRouter.HandleFunc("PUT /upload/{serverName}/{mediaID}", mp.UploadNotSupported) + mp.ClientMediaRouter.HandleFunc("POST /upload", mp.UploadNotSupported) + mp.ClientMediaRouter.HandleFunc("POST /create", mp.UploadNotSupported) + mp.ClientMediaRouter.HandleFunc("GET /config", mp.UploadNotSupported) + mp.ClientMediaRouter.HandleFunc("GET /preview_url", mp.PreviewURLNotSupported) + return mp, nil } type BasicConfig struct { @@ -162,7 +177,7 @@ type ServerConfig struct { } func (mp *MediaProxy) Listen(cfg ServerConfig) error { - router := mux.NewRouter() + router := http.NewServeMux() mp.RegisterRoutes(router) return http.ListenAndServe(fmt.Sprintf("%s:%d", cfg.Hostname, cfg.Port), router) } @@ -188,38 +203,20 @@ func (mp *MediaProxy) EnableServerAuth(client *federation.Client, keyCache feder }) } -func (mp *MediaProxy) RegisterRoutes(router *mux.Router) { - if mp.FederationRouter == nil { - mp.FederationRouter = router.PathPrefix("/_matrix/federation").Subrouter() +func (mp *MediaProxy) RegisterRoutes(router *http.ServeMux) { + errorBodies := exhttp.ErrorBodies{ + NotFound: exerrors.Must(json.Marshal(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint"))), + MethodNotAllowed: exerrors.Must(json.Marshal(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint"))), } - if mp.ClientMediaRouter == nil { - mp.ClientMediaRouter = router.PathPrefix("/_matrix/client/v1/media").Subrouter() - } - - mp.FederationRouter.HandleFunc("/v1/media/download/{mediaID}", mp.DownloadMediaFederation).Methods(http.MethodGet) - mp.FederationRouter.HandleFunc("/v1/version", mp.KeyServer.GetServerVersion).Methods(http.MethodGet) - mp.ClientMediaRouter.HandleFunc("/download/{serverName}/{mediaID}", mp.DownloadMedia).Methods(http.MethodGet) - mp.ClientMediaRouter.HandleFunc("/download/{serverName}/{mediaID}/{fileName}", mp.DownloadMedia).Methods(http.MethodGet) - mp.ClientMediaRouter.HandleFunc("/thumbnail/{serverName}/{mediaID}", mp.DownloadMedia).Methods(http.MethodGet) - mp.ClientMediaRouter.HandleFunc("/upload/{serverName}/{mediaID}", mp.UploadNotSupported).Methods(http.MethodPut) - mp.ClientMediaRouter.HandleFunc("/upload", mp.UploadNotSupported).Methods(http.MethodPost) - mp.ClientMediaRouter.HandleFunc("/create", mp.UploadNotSupported).Methods(http.MethodPost) - mp.ClientMediaRouter.HandleFunc("/config", mp.UploadNotSupported).Methods(http.MethodGet) - mp.ClientMediaRouter.HandleFunc("/preview_url", mp.PreviewURLNotSupported).Methods(http.MethodGet) - mp.FederationRouter.NotFoundHandler = http.HandlerFunc(mp.UnknownEndpoint) - mp.FederationRouter.MethodNotAllowedHandler = http.HandlerFunc(mp.UnsupportedMethod) - mp.ClientMediaRouter.NotFoundHandler = http.HandlerFunc(mp.UnknownEndpoint) - mp.ClientMediaRouter.MethodNotAllowedHandler = http.HandlerFunc(mp.UnsupportedMethod) - corsMiddleware := func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "X-Requested-With, Content-Type, Authorization") - w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; script-src 'none'; plugin-types application/pdf; style-src 'unsafe-inline'; object-src 'self';") - next.ServeHTTP(w, r) - }) - } - mp.ClientMediaRouter.Use(corsMiddleware) + router.Handle("/_matrix/federation", exhttp.ApplyMiddleware( + mp.FederationRouter, + exhttp.HandleErrors(errorBodies), + )) + router.Handle("/_matrix/client/v1/media", exhttp.ApplyMiddleware( + mp.ClientMediaRouter, + exhttp.CORSMiddleware, + exhttp.HandleErrors(errorBodies), + )) mp.KeyServer.Register(router) } @@ -234,7 +231,7 @@ func queryToMap(vals url.Values) map[string]string { } func (mp *MediaProxy) getMedia(w http.ResponseWriter, r *http.Request) GetMediaResponse { - mediaID := mux.Vars(r)["mediaID"] + mediaID := r.PathValue("mediaID") if !id.IsValidMediaID(mediaID) { mautrix.MNotFound.WithMessage("Media ID %q is not valid", mediaID).Write(w) return nil @@ -380,8 +377,7 @@ func (mp *MediaProxy) addHeaders(w http.ResponseWriter, mimeType, fileName strin func (mp *MediaProxy) DownloadMedia(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := zerolog.Ctx(ctx) - vars := mux.Vars(r) - if vars["serverName"] != mp.serverName { + if r.PathValue("serverName") != mp.serverName { mautrix.MNotFound.WithMessage("This is a media proxy at %q, other media downloads are not available here", mp.serverName).Write(w) return } @@ -404,7 +400,7 @@ func (mp *MediaProxy) DownloadMedia(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTemporaryRedirect) } else if fileResp, ok := resp.(*GetMediaResponseFile); ok { responseStarted, err := doTempFileDownload(fileResp, func(wt io.WriterTo, size int64, mimeType string) error { - mp.addHeaders(w, mimeType, vars["fileName"]) + mp.addHeaders(w, mimeType, r.PathValue("fileName")) w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) w.WriteHeader(http.StatusOK) _, err := wt.WriteTo(w) @@ -425,7 +421,7 @@ func (mp *MediaProxy) DownloadMedia(w http.ResponseWriter, r *http.Request) { if dataResp, ok := writerResp.(*GetMediaResponseData); ok { defer dataResp.Reader.Close() } - mp.addHeaders(w, writerResp.GetContentType(), vars["fileName"]) + mp.addHeaders(w, writerResp.GetContentType(), r.PathValue("fileName")) if writerResp.GetContentLength() != 0 { w.Header().Set("Content-Length", strconv.FormatInt(writerResp.GetContentLength(), 10)) } @@ -491,11 +487,6 @@ var ( ErrPreviewURLNotSupported = mautrix.MUnrecognized. WithMessage("This is a media proxy and does not support URL previews."). WithStatus(http.StatusNotImplemented) - ErrUnknownEndpoint = mautrix.MUnrecognized. - WithMessage("Unrecognized endpoint") - ErrUnsupportedMethod = mautrix.MUnrecognized. - WithMessage("Invalid method for endpoint"). - WithStatus(http.StatusMethodNotAllowed) ) func (mp *MediaProxy) UploadNotSupported(w http.ResponseWriter, r *http.Request) { @@ -505,11 +496,3 @@ func (mp *MediaProxy) UploadNotSupported(w http.ResponseWriter, r *http.Request) func (mp *MediaProxy) PreviewURLNotSupported(w http.ResponseWriter, r *http.Request) { ErrPreviewURLNotSupported.Write(w) } - -func (mp *MediaProxy) UnknownEndpoint(w http.ResponseWriter, r *http.Request) { - ErrUnknownEndpoint.Write(w) -} - -func (mp *MediaProxy) UnsupportedMethod(w http.ResponseWriter, r *http.Request) { - ErrUnsupportedMethod.Write(w) -} From 62c03d093a13c78e18b4e5886d0941247b982a3a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 23 Jul 2025 22:58:54 +0300 Subject: [PATCH 189/581] bridgev2/status: take context and http client in checkpoint SendHTTP --- bridgev2/matrix/connector.go | 6 +++--- bridgev2/matrix/matrix.go | 2 +- bridgev2/status/messagecheckpoint.go | 9 ++++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index af9931b0..0a859e42 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -435,7 +435,7 @@ func (br *Connector) internalSendMessageStatus(ctx context.Context, ms *bridgev2 log := zerolog.Ctx(ctx) if !evt.IsSourceEventDoublePuppeted { - err := br.SendMessageCheckpoints([]*status.MessageCheckpoint{ms.ToCheckpoint(evt)}) + err := br.SendMessageCheckpoints(ctx, []*status.MessageCheckpoint{ms.ToCheckpoint(evt)}) if err != nil { log.Err(err).Msg("Failed to send message checkpoint") } @@ -480,7 +480,7 @@ func (br *Connector) internalSendMessageStatus(ctx context.Context, ms *bridgev2 return "" } -func (br *Connector) SendMessageCheckpoints(checkpoints []*status.MessageCheckpoint) error { +func (br *Connector) SendMessageCheckpoints(ctx context.Context, checkpoints []*status.MessageCheckpoint) error { checkpointsJSON := status.CheckpointsJSON{Checkpoints: checkpoints} if br.Websocket { @@ -495,7 +495,7 @@ func (br *Connector) SendMessageCheckpoints(checkpoints []*status.MessageCheckpo return nil } - return checkpointsJSON.SendHTTP(endpoint, br.AS.Registration.AppToken) + return checkpointsJSON.SendHTTP(ctx, br.AS.HTTPClient, endpoint, br.AS.Registration.AppToken) } func (br *Connector) ParseGhostMXID(userID id.UserID) (networkid.UserID, bool) { diff --git a/bridgev2/matrix/matrix.go b/bridgev2/matrix/matrix.go index fed9d37a..49c377db 100644 --- a/bridgev2/matrix/matrix.go +++ b/bridgev2/matrix/matrix.go @@ -142,7 +142,7 @@ type CommandProcessor interface { } func (br *Connector) sendSuccessCheckpoint(ctx context.Context, evt *event.Event, step status.MessageCheckpointStep, retryNum int) { - err := br.SendMessageCheckpoints([]*status.MessageCheckpoint{{ + err := br.SendMessageCheckpoints(ctx, []*status.MessageCheckpoint{{ RoomID: evt.RoomID, EventID: evt.ID, EventType: evt.Type, diff --git a/bridgev2/status/messagecheckpoint.go b/bridgev2/status/messagecheckpoint.go index ea859b84..b3c05f4f 100644 --- a/bridgev2/status/messagecheckpoint.go +++ b/bridgev2/status/messagecheckpoint.go @@ -169,13 +169,13 @@ type CheckpointsJSON struct { Checkpoints []*MessageCheckpoint `json:"checkpoints"` } -func (cj *CheckpointsJSON) SendHTTP(endpoint string, token string) error { +func (cj *CheckpointsJSON) SendHTTP(ctx context.Context, cli *http.Client, endpoint string, token string) error { var body bytes.Buffer if err := json.NewEncoder(&body).Encode(cj); err != nil { return fmt.Errorf("failed to encode message checkpoint JSON: %w", err) } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &body) if err != nil { @@ -186,7 +186,10 @@ func (cj *CheckpointsJSON) SendHTTP(endpoint string, token string) error { req.Header.Set("User-Agent", mautrix.DefaultUserAgent+" (checkpoint sender)") req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + if cli == nil { + cli = http.DefaultClient + } + resp, err := cli.Do(req) if err != nil { return mautrix.HTTPError{ Request: req, From 83b4b71a167c1f871f8f8da36b5d2338e92db983 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 23 Jul 2025 22:59:10 +0300 Subject: [PATCH 190/581] appservice/websocket: switch from gorilla to coder --- appservice/appservice.go | 3 +- appservice/websocket.go | 97 +++++++++++++++++++----------------- bridgev2/matrix/connector.go | 4 +- bridgev2/matrix/websocket.go | 2 +- go.mod | 2 +- go.sum | 4 +- 6 files changed, 58 insertions(+), 54 deletions(-) diff --git a/appservice/appservice.go b/appservice/appservice.go index 5dd067c0..b0af02cd 100644 --- a/appservice/appservice.go +++ b/appservice/appservice.go @@ -19,7 +19,7 @@ import ( "syscall" "time" - "github.com/gorilla/websocket" + "github.com/coder/websocket" "github.com/rs/zerolog" "golang.org/x/net/publicsuffix" "gopkg.in/yaml.v3" @@ -178,7 +178,6 @@ type AppService struct { intentsLock sync.RWMutex ws *websocket.Conn - wsWriteLock sync.Mutex StopWebsocket func(error) websocketHandlers map[string]WebsocketHandler websocketHandlersLock sync.RWMutex diff --git a/appservice/websocket.go b/appservice/websocket.go index 3d5bd232..62f4370c 100644 --- a/appservice/websocket.go +++ b/appservice/websocket.go @@ -17,9 +17,8 @@ import ( "strings" "sync" "sync/atomic" - "time" - "github.com/gorilla/websocket" + "github.com/coder/websocket" "github.com/rs/zerolog" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -28,11 +27,9 @@ import ( ) type WebsocketRequest struct { - ReqID int `json:"id,omitempty"` - Command string `json:"command"` - Data interface{} `json:"data"` - - Deadline time.Duration `json:"-"` + ReqID int `json:"id,omitempty"` + Command string `json:"command"` + Data any `json:"data"` } type WebsocketCommand struct { @@ -43,7 +40,7 @@ type WebsocketCommand struct { Ctx context.Context `json:"-"` } -func (wsc *WebsocketCommand) MakeResponse(ok bool, data interface{}) *WebsocketRequest { +func (wsc *WebsocketCommand) MakeResponse(ok bool, data any) *WebsocketRequest { if wsc.ReqID == 0 || wsc.Command == "response" || wsc.Command == "error" { return nil } @@ -100,8 +97,8 @@ type WebsocketMessage struct { } const ( - WebsocketCloseConnReplaced = 4001 - WebsocketCloseTxnNotAcknowledged = 4002 + WebsocketCloseConnReplaced websocket.StatusCode = 4001 + WebsocketCloseTxnNotAcknowledged websocket.StatusCode = 4002 ) type MeowWebsocketCloseCode string @@ -135,7 +132,7 @@ func (mwcc MeowWebsocketCloseCode) String() string { } type CloseCommand struct { - Code int `json:"-"` + Code websocket.StatusCode `json:"-"` Command string `json:"command"` Status MeowWebsocketCloseCode `json:"status"` } @@ -145,15 +142,15 @@ func (cc CloseCommand) Error() string { } func parseCloseError(err error) error { - closeError := &websocket.CloseError{} + var closeError websocket.CloseError if !errors.As(err, &closeError) { return err } var closeCommand CloseCommand closeCommand.Code = closeError.Code closeCommand.Command = "disconnect" - if len(closeError.Text) > 0 { - jsonErr := json.Unmarshal([]byte(closeError.Text), &closeCommand) + if len(closeError.Reason) > 0 { + jsonErr := json.Unmarshal([]byte(closeError.Reason), &closeCommand) if jsonErr != nil { return err } @@ -161,7 +158,7 @@ func parseCloseError(err error) error { if len(closeCommand.Status) == 0 { if closeCommand.Code == WebsocketCloseConnReplaced { closeCommand.Status = MeowConnectionReplaced - } else if closeCommand.Code == websocket.CloseServiceRestart { + } else if closeCommand.Code == websocket.StatusServiceRestart { closeCommand.Status = MeowServerShuttingDown } } @@ -172,20 +169,22 @@ func (as *AppService) HasWebsocket() bool { return as.ws != nil } -func (as *AppService) SendWebsocket(cmd *WebsocketRequest) error { +func (as *AppService) SendWebsocket(ctx context.Context, cmd *WebsocketRequest) error { ws := as.ws if cmd == nil { return nil } else if ws == nil { return ErrWebsocketNotConnected } - as.wsWriteLock.Lock() - defer as.wsWriteLock.Unlock() - if cmd.Deadline == 0 { - cmd.Deadline = 3 * time.Minute + wr, err := ws.Writer(ctx, websocket.MessageText) + if err != nil { + return err } - _ = ws.SetWriteDeadline(time.Now().Add(cmd.Deadline)) - return ws.WriteJSON(cmd) + err = json.NewEncoder(wr).Encode(cmd) + if err != nil { + return err + } + return nil } func (as *AppService) clearWebsocketResponseWaiters() { @@ -222,12 +221,12 @@ func (er *ErrorResponse) Error() string { return fmt.Sprintf("%s: %s", er.Code, er.Message) } -func (as *AppService) RequestWebsocket(ctx context.Context, cmd *WebsocketRequest, response interface{}) error { +func (as *AppService) RequestWebsocket(ctx context.Context, cmd *WebsocketRequest, response any) error { cmd.ReqID = int(atomic.AddInt32(&as.websocketRequestID, 1)) respChan := make(chan *WebsocketCommand, 1) as.addWebsocketResponseWaiter(cmd.ReqID, respChan) defer as.removeWebsocketResponseWaiter(cmd.ReqID, respChan) - err := as.SendWebsocket(cmd) + err := as.SendWebsocket(ctx, cmd) if err != nil { return err } @@ -256,7 +255,7 @@ func (as *AppService) RequestWebsocket(ctx context.Context, cmd *WebsocketReques } } -func (as *AppService) unknownCommandHandler(cmd WebsocketCommand) (bool, interface{}) { +func (as *AppService) unknownCommandHandler(cmd WebsocketCommand) (bool, any) { zerolog.Ctx(cmd.Ctx).Warn().Msg("No handler for websocket command") return false, fmt.Errorf("unknown request type") } @@ -280,14 +279,22 @@ func (as *AppService) defaultHandleWebsocketTransaction(ctx context.Context, msg return true, &WebsocketTransactionResponse{TxnID: msg.TxnID} } -func (as *AppService) consumeWebsocket(stopFunc func(error), ws *websocket.Conn) { +func (as *AppService) consumeWebsocket(ctx context.Context, stopFunc func(error), ws *websocket.Conn) { defer stopFunc(ErrWebsocketUnknownError) - ctx := context.Background() for { - var msg WebsocketMessage - err := ws.ReadJSON(&msg) + msgType, reader, err := ws.Reader(ctx) if err != nil { - as.Log.Debug().Err(err).Msg("Error reading from websocket") + as.Log.Debug().Err(err).Msg("Error getting reader from websocket") + stopFunc(parseCloseError(err)) + return + } else if msgType != websocket.MessageText { + as.Log.Debug().Msg("Ignoring non-text message from websocket") + continue + } + var msg WebsocketMessage + err = json.NewDecoder(reader).Decode(&msg) + if err != nil { + as.Log.Debug().Err(err).Msg("Error reading JSON from websocket") stopFunc(parseCloseError(err)) return } @@ -298,11 +305,11 @@ func (as *AppService) consumeWebsocket(stopFunc func(error), ws *websocket.Conn) with = with.Str("transaction_id", msg.TxnID) } log := with.Logger() - ctx = log.WithContext(ctx) + ctx := log.WithContext(ctx) if msg.Command == "" || msg.Command == "transaction" { ok, resp := as.WebsocketTransactionHandler(ctx, msg) go func() { - err := as.SendWebsocket(msg.MakeResponse(ok, resp)) + err := as.SendWebsocket(ctx, msg.MakeResponse(ok, resp)) if err != nil { log.Warn().Err(err).Msg("Failed to send response to websocket transaction") } else { @@ -334,7 +341,7 @@ func (as *AppService) consumeWebsocket(stopFunc func(error), ws *websocket.Conn) } go func() { okResp, data := handler(msg.WebsocketCommand) - err := as.SendWebsocket(msg.MakeResponse(okResp, data)) + err := as.SendWebsocket(ctx, msg.MakeResponse(okResp, data)) if err != nil { log.Error().Err(err).Msg("Failed to send response to websocket command") } else if okResp { @@ -347,7 +354,7 @@ func (as *AppService) consumeWebsocket(stopFunc func(error), ws *websocket.Conn) } } -func (as *AppService) StartWebsocket(baseURL string, onConnect func()) error { +func (as *AppService) StartWebsocket(ctx context.Context, baseURL string, onConnect func()) error { var parsed *url.URL if baseURL != "" { var err error @@ -365,12 +372,15 @@ func (as *AppService) StartWebsocket(baseURL string, onConnect func()) error { } else if parsed.Scheme == "https" { parsed.Scheme = "wss" } - ws, resp, err := websocket.DefaultDialer.Dial(parsed.String(), http.Header{ - "Authorization": []string{fmt.Sprintf("Bearer %s", as.Registration.AppToken)}, - "User-Agent": []string{as.BotClient().UserAgent}, + ws, resp, err := websocket.Dial(ctx, parsed.String(), &websocket.DialOptions{ + HTTPClient: as.HTTPClient, + HTTPHeader: http.Header{ + "Authorization": []string{fmt.Sprintf("Bearer %s", as.Registration.AppToken)}, + "User-Agent": []string{as.BotClient().UserAgent}, - "X-Mautrix-Process-ID": []string{as.ProcessID}, - "X-Mautrix-Websocket-Version": []string{"3"}, + "X-Mautrix-Process-ID": []string{as.ProcessID}, + "X-Mautrix-Websocket-Version": []string{"3"}, + }, }) if resp != nil && resp.StatusCode >= 400 { var errResp mautrix.RespError @@ -406,7 +416,7 @@ func (as *AppService) StartWebsocket(baseURL string, onConnect func()) error { as.PrepareWebsocket() as.Log.Debug().Msg("Appservice transaction websocket opened") - go as.consumeWebsocket(stopFunc, ws) + go as.consumeWebsocket(ctx, stopFunc, ws) var onConnectDone atomic.Bool if onConnect != nil { @@ -428,12 +438,7 @@ func (as *AppService) StartWebsocket(baseURL string, onConnect func()) error { as.ws = nil } - _ = ws.SetWriteDeadline(time.Now().Add(3 * time.Second)) - err = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseGoingAway, "")) - if err != nil && !errors.Is(err, websocket.ErrCloseSent) { - as.Log.Warn().Err(err).Msg("Error writing close message to websocket") - } - err = ws.Close() + err = ws.Close(websocket.StatusGoingAway, "") if err != nil { as.Log.Warn().Err(err).Msg("Error closing websocket") } diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 0a859e42..158148f3 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -413,7 +413,7 @@ func (br *Connector) GhostIntent(userID networkid.UserID) bridgev2.MatrixAPI { func (br *Connector) SendBridgeStatus(ctx context.Context, state *status.BridgeState) error { if br.Websocket { br.hasSentAnyStates = true - return br.AS.SendWebsocket(&appservice.WebsocketRequest{ + return br.AS.SendWebsocket(ctx, &appservice.WebsocketRequest{ Command: "bridge_status", Data: state, }) @@ -484,7 +484,7 @@ func (br *Connector) SendMessageCheckpoints(ctx context.Context, checkpoints []* checkpointsJSON := status.CheckpointsJSON{Checkpoints: checkpoints} if br.Websocket { - return br.AS.SendWebsocket(&appservice.WebsocketRequest{ + return br.AS.SendWebsocket(ctx, &appservice.WebsocketRequest{ Command: "message_checkpoint", Data: checkpointsJSON, }) diff --git a/bridgev2/matrix/websocket.go b/bridgev2/matrix/websocket.go index c679f960..b498cacd 100644 --- a/bridgev2/matrix/websocket.go +++ b/bridgev2/matrix/websocket.go @@ -57,7 +57,7 @@ func (br *Connector) startWebsocket(wg *sync.WaitGroup) { addr = br.Config.Homeserver.Address } for { - err := br.AS.StartWebsocket(addr, onConnect) + err := br.AS.StartWebsocket(br.Bridge.BackgroundCtx, addr, onConnect) if errors.Is(err, appservice.ErrWebsocketManualStop) { return } else if closeCommand := (&appservice.CloseCommand{}); errors.As(err, &closeCommand) && closeCommand.Status == appservice.MeowConnectionReplaced { diff --git a/go.mod b/go.mod index d71e86ab..1133313f 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.24.5 require ( filippo.io/edwards25519 v1.1.0 github.com/chzyer/readline v1.5.1 - github.com/gorilla/websocket v1.5.0 + github.com/coder/websocket v1.8.13 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.28 github.com/rs/xid v1.6.0 diff --git a/go.sum b/go.sum index eaa97cc8..461ee542 100644 --- a/go.sum +++ b/go.sum @@ -8,13 +8,13 @@ github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= +github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= From 74ab3b118e10006ed3afcab3a34c08b6a3a71fa8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 28 Jul 2025 15:53:17 +0300 Subject: [PATCH 191/581] bridgev2/portal: add todo --- bridgev2/portal.go | 1 + 1 file changed, 1 insertion(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 5fea134f..7301c8ad 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -2073,6 +2073,7 @@ func (portal *Portal) ensureFunctionalMember(ctx context.Context, ghost *Ghost) } } } + // TODO what about non-double-puppeted user ghosts? functionalMembers.Add(portal.Bridge.Bot.GetMXID()) if functionalMembers.Add(ghost.Intent.GetMXID()) { _, err := portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.Content{ From ae2c07fb863a69c22e3ed66c89dbb35b39f20e2a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 28 Jul 2025 17:34:28 +0300 Subject: [PATCH 192/581] appservice/websocket: close writer after sending --- appservice/websocket.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/appservice/websocket.go b/appservice/websocket.go index 62f4370c..18768098 100644 --- a/appservice/websocket.go +++ b/appservice/websocket.go @@ -182,9 +182,10 @@ func (as *AppService) SendWebsocket(ctx context.Context, cmd *WebsocketRequest) } err = json.NewEncoder(wr).Encode(cmd) if err != nil { + _ = wr.Close() return err } - return nil + return wr.Close() } func (as *AppService) clearWebsocketResponseWaiters() { From 2e7ff3fedd4c3fb89dad8bddceb8e10846c2cef6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 28 Jul 2025 22:03:43 +0300 Subject: [PATCH 193/581] all: fix trailing slash in subrouters --- bridgev2/matrix/provisioning.go | 6 ++++-- federation/keyserver.go | 3 ++- mediaproxy/mediaproxy.go | 6 ++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 7f4b8a2e..6b594deb 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -142,15 +142,17 @@ func (prov *ProvisioningAPI) Init() { debugRouter.HandleFunc("GET /pprof/symbol", pprof.Symbol) debugRouter.HandleFunc("GET /pprof/trace", pprof.Trace) debugRouter.HandleFunc("/pprof/", pprof.Index) - prov.br.AS.Router.Handle("/debug", exhttp.ApplyMiddleware( + prov.br.AS.Router.Handle("/debug/", exhttp.ApplyMiddleware( debugRouter, + exhttp.StripPrefix("/debug"), hlog.NewHandler(prov.br.Log.With().Str("component", "debug api").Logger()), prov.DebugAuthMiddleware, )) } - prov.br.AS.Router.Handle("/_matrix/provision", exhttp.ApplyMiddleware( + prov.br.AS.Router.Handle("/_matrix/provision/", exhttp.ApplyMiddleware( prov.Router, + exhttp.StripPrefix("/_matrix/provision"), hlog.NewHandler(prov.log), hlog.RequestIDHandler("request_id", "Request-Id"), exhttp.CORSMiddleware, diff --git a/federation/keyserver.go b/federation/keyserver.go index 37998786..35ec59fd 100644 --- a/federation/keyserver.go +++ b/federation/keyserver.go @@ -62,8 +62,9 @@ func (ks *KeyServer) Register(r *http.ServeMux) { NotFound: exerrors.Must(json.Marshal(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint"))), MethodNotAllowed: exerrors.Must(json.Marshal(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint"))), } - r.Handle("/_matrix/key", exhttp.ApplyMiddleware( + r.Handle("/_matrix/key/", exhttp.ApplyMiddleware( keyRouter, + exhttp.StripPrefix("/_matrix/key"), exhttp.HandleErrors(errorBodies), )) } diff --git a/mediaproxy/mediaproxy.go b/mediaproxy/mediaproxy.go index 6fbcdbad..a5f07afa 100644 --- a/mediaproxy/mediaproxy.go +++ b/mediaproxy/mediaproxy.go @@ -208,12 +208,14 @@ func (mp *MediaProxy) RegisterRoutes(router *http.ServeMux) { NotFound: exerrors.Must(json.Marshal(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint"))), MethodNotAllowed: exerrors.Must(json.Marshal(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint"))), } - router.Handle("/_matrix/federation", exhttp.ApplyMiddleware( + router.Handle("/_matrix/federation/", exhttp.ApplyMiddleware( mp.FederationRouter, + exhttp.StripPrefix("/_matrix/federation"), exhttp.HandleErrors(errorBodies), )) - router.Handle("/_matrix/client/v1/media", exhttp.ApplyMiddleware( + router.Handle("/_matrix/client/v1/media/", exhttp.ApplyMiddleware( mp.ClientMediaRouter, + exhttp.StripPrefix("/_matrix/client/v1/media"), exhttp.CORSMiddleware, exhttp.HandleErrors(errorBodies), )) From f1da44490c55eea5856e4dc40e6dbb74ecfe4627 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 29 Jul 2025 16:15:16 +0300 Subject: [PATCH 194/581] bridgev2/provisioning: move login step checks into handler --- bridgev2/matrix/provisioning.go | 66 ++++++++++++++++----------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 6b594deb..e3ec21dd 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -258,38 +258,6 @@ func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { ctx := context.WithValue(r.Context(), ProvisioningKeyRequest, r) ctx = context.WithValue(ctx, provisioningUserKey, user) - if loginID := r.PathValue("loginProcessID"); loginID != "" { - prov.loginsLock.RLock() - login, ok := prov.logins[loginID] - prov.loginsLock.RUnlock() - if !ok { - zerolog.Ctx(r.Context()).Warn().Str("login_id", loginID).Msg("Login not found") - mautrix.MNotFound.WithMessage("Login not found").Write(w) - return - } - login.Lock.Lock() - // This will only unlock after the handler runs - defer login.Lock.Unlock() - stepID := r.PathValue("stepID") - if login.NextStep.StepID != stepID { - zerolog.Ctx(r.Context()).Warn(). - Str("request_step_id", stepID). - Str("expected_step_id", login.NextStep.StepID). - Msg("Step ID does not match") - mautrix.MBadState.WithMessage("Step ID does not match").Write(w) - return - } - stepType := r.PathValue("stepType") - if login.NextStep.Type != bridgev2.LoginStepType(stepType) { - zerolog.Ctx(r.Context()).Warn(). - Str("request_step_type", stepType). - Str("expected_step_type", string(login.NextStep.Type)). - Msg("Step type does not match") - mautrix.MBadState.WithMessage("Step type does not match").Write(w) - return - } - ctx = context.WithValue(ctx, provisioningLoginProcessKey, login) - } h.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -431,6 +399,38 @@ func (prov *ProvisioningAPI) handleCompleteStep(ctx context.Context, login *Prov } func (prov *ProvisioningAPI) PostLoginStep(w http.ResponseWriter, r *http.Request) { + loginID := r.PathValue("loginProcessID") + prov.loginsLock.RLock() + login, ok := prov.logins[loginID] + prov.loginsLock.RUnlock() + if !ok { + zerolog.Ctx(r.Context()).Warn().Str("login_id", loginID).Msg("Login not found") + mautrix.MNotFound.WithMessage("Login not found").Write(w) + return + } + login.Lock.Lock() + // This will only unlock after the handler runs + defer login.Lock.Unlock() + stepID := r.PathValue("stepID") + if login.NextStep.StepID != stepID { + zerolog.Ctx(r.Context()).Warn(). + Str("request_step_id", stepID). + Str("expected_step_id", login.NextStep.StepID). + Msg("Step ID does not match") + mautrix.MBadState.WithMessage("Step ID does not match").Write(w) + return + } + stepType := r.PathValue("stepType") + if login.NextStep.Type != bridgev2.LoginStepType(stepType) { + zerolog.Ctx(r.Context()).Warn(). + Str("request_step_type", stepType). + Str("expected_step_type", string(login.NextStep.Type)). + Msg("Step type does not match") + mautrix.MBadState.WithMessage("Step type does not match").Write(w) + return + } + ctx := context.WithValue(r.Context(), provisioningLoginProcessKey, login) + r = r.WithContext(ctx) switch bridgev2.LoginStepType(r.PathValue("stepType")) { case bridgev2.LoginStepTypeUserInput, bridgev2.LoginStepTypeCookies: prov.PostLoginSubmitInput(w, r) @@ -439,7 +439,7 @@ func (prov *ProvisioningAPI) PostLoginStep(w http.ResponseWriter, r *http.Reques case bridgev2.LoginStepTypeComplete: fallthrough default: - // This is probably impossible because AuthMiddleware checks that the next step type matches the request. + // This is probably impossible because of the above check that the next step type matches the request. mautrix.MUnrecognized.WithMessage("Invalid step type %q", r.PathValue("stepType")).Write(w) } } From 26e66f293e6a25d1167d8dc60bb7e9efcfe69d37 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 29 Jul 2025 16:15:36 +0300 Subject: [PATCH 195/581] bridgev2/portal: return event ignored result for type unknown --- bridgev2/portal.go | 1 + 1 file changed, 1 insertion(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 7301c8ad..2f973aae 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -2008,6 +2008,7 @@ func (portal *Portal) handleRemoteEvent(ctx context.Context, source *UserLogin, switch evtType { case RemoteEventUnknown: log.Debug().Msg("Ignoring remote event with type unknown") + res = EventHandlingResultIgnored case RemoteEventMessage, RemoteEventMessageUpsert: res = portal.handleRemoteMessage(ctx, source, evt.(RemoteMessage)) case RemoteEventEdit: From b4c7abd62b509ba84c72c02a63c926c578a42e81 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 29 Jul 2025 17:10:50 +0300 Subject: [PATCH 196/581] bridgev2,federation,mediaproxy: enable http access logging --- bridgev2/matrix/directmedia.go | 2 +- bridgev2/matrix/provisioning.go | 8 ++++++++ federation/keyserver.go | 13 ++++++++++--- mediaproxy/mediaproxy.go | 20 ++++++++++++++------ 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/bridgev2/matrix/directmedia.go b/bridgev2/matrix/directmedia.go index 71c01078..0667981a 100644 --- a/bridgev2/matrix/directmedia.go +++ b/bridgev2/matrix/directmedia.go @@ -39,7 +39,7 @@ func (br *Connector) initDirectMedia() error { if err != nil { return fmt.Errorf("failed to initialize media proxy: %w", err) } - br.MediaProxy.RegisterRoutes(br.AS.Router) + br.MediaProxy.RegisterRoutes(br.AS.Router, br.Log.With().Str("component", "media proxy").Logger()) br.dmaSigKey = sha256.Sum256(br.MediaProxy.GetServerKey().Priv.Seed()) dmn.SetUseDirectMedia() br.Log.Debug().Str("server_name", br.MediaProxy.GetServerName()).Msg("Enabled direct media access") diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index e3ec21dd..df3e1bdf 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -20,9 +20,11 @@ import ( "github.com/rs/xid" "github.com/rs/zerolog" "github.com/rs/zerolog/hlog" + "go.mau.fi/util/exerrors" "go.mau.fi/util/exhttp" "go.mau.fi/util/exstrings" "go.mau.fi/util/jsontime" + "go.mau.fi/util/ptr" "go.mau.fi/util/requestlog" "maunium.net/go/mautrix" @@ -146,10 +148,15 @@ func (prov *ProvisioningAPI) Init() { debugRouter, exhttp.StripPrefix("/debug"), hlog.NewHandler(prov.br.Log.With().Str("component", "debug api").Logger()), + requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), prov.DebugAuthMiddleware, )) } + errorBodies := exhttp.ErrorBodies{ + NotFound: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint")).MarshalJSON()), + MethodNotAllowed: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint")).MarshalJSON()), + } prov.br.AS.Router.Handle("/_matrix/provision/", exhttp.ApplyMiddleware( prov.Router, exhttp.StripPrefix("/_matrix/provision"), @@ -157,6 +164,7 @@ func (prov *ProvisioningAPI) Init() { hlog.RequestIDHandler("request_id", "Request-Id"), exhttp.CORSMiddleware, requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), + exhttp.HandleErrors(errorBodies), prov.AuthMiddleware, )) } diff --git a/federation/keyserver.go b/federation/keyserver.go index 35ec59fd..d32ba5cf 100644 --- a/federation/keyserver.go +++ b/federation/keyserver.go @@ -12,9 +12,13 @@ import ( "strconv" "time" + "github.com/rs/zerolog" + "github.com/rs/zerolog/hlog" "go.mau.fi/util/exerrors" "go.mau.fi/util/exhttp" "go.mau.fi/util/jsontime" + "go.mau.fi/util/ptr" + "go.mau.fi/util/requestlog" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" @@ -51,7 +55,7 @@ type KeyServer struct { } // Register registers the key server endpoints to the given router. -func (ks *KeyServer) Register(r *http.ServeMux) { +func (ks *KeyServer) Register(r *http.ServeMux, log zerolog.Logger) { r.HandleFunc("GET /.well-known/matrix/server", ks.GetWellKnown) r.HandleFunc("GET /_matrix/federation/v1/version", ks.GetServerVersion) keyRouter := http.NewServeMux() @@ -59,12 +63,15 @@ func (ks *KeyServer) Register(r *http.ServeMux) { keyRouter.HandleFunc("GET /v2/query/{serverName}", ks.GetQueryKeys) keyRouter.HandleFunc("POST /v2/query", ks.PostQueryKeys) errorBodies := exhttp.ErrorBodies{ - NotFound: exerrors.Must(json.Marshal(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint"))), - MethodNotAllowed: exerrors.Must(json.Marshal(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint"))), + NotFound: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint")).MarshalJSON()), + MethodNotAllowed: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint")).MarshalJSON()), } r.Handle("/_matrix/key/", exhttp.ApplyMiddleware( keyRouter, exhttp.StripPrefix("/_matrix/key"), + hlog.NewHandler(log), + hlog.RequestIDHandler("request_id", "Request-Id"), + requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), exhttp.HandleErrors(errorBodies), )) } diff --git a/mediaproxy/mediaproxy.go b/mediaproxy/mediaproxy.go index a5f07afa..07e30810 100644 --- a/mediaproxy/mediaproxy.go +++ b/mediaproxy/mediaproxy.go @@ -8,7 +8,6 @@ package mediaproxy import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -23,8 +22,11 @@ import ( "time" "github.com/rs/zerolog" + "github.com/rs/zerolog/hlog" "go.mau.fi/util/exerrors" "go.mau.fi/util/exhttp" + "go.mau.fi/util/ptr" + "go.mau.fi/util/requestlog" "maunium.net/go/mautrix" "maunium.net/go/mautrix/federation" @@ -178,7 +180,7 @@ type ServerConfig struct { func (mp *MediaProxy) Listen(cfg ServerConfig) error { router := http.NewServeMux() - mp.RegisterRoutes(router) + mp.RegisterRoutes(router, zerolog.Nop()) return http.ListenAndServe(fmt.Sprintf("%s:%d", cfg.Hostname, cfg.Port), router) } @@ -203,23 +205,29 @@ func (mp *MediaProxy) EnableServerAuth(client *federation.Client, keyCache feder }) } -func (mp *MediaProxy) RegisterRoutes(router *http.ServeMux) { +func (mp *MediaProxy) RegisterRoutes(router *http.ServeMux, log zerolog.Logger) { errorBodies := exhttp.ErrorBodies{ - NotFound: exerrors.Must(json.Marshal(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint"))), - MethodNotAllowed: exerrors.Must(json.Marshal(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint"))), + NotFound: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint")).MarshalJSON()), + MethodNotAllowed: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint")).MarshalJSON()), } router.Handle("/_matrix/federation/", exhttp.ApplyMiddleware( mp.FederationRouter, exhttp.StripPrefix("/_matrix/federation"), + hlog.NewHandler(log), + hlog.RequestIDHandler("request_id", "Request-Id"), + requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), exhttp.HandleErrors(errorBodies), )) router.Handle("/_matrix/client/v1/media/", exhttp.ApplyMiddleware( mp.ClientMediaRouter, exhttp.StripPrefix("/_matrix/client/v1/media"), + hlog.NewHandler(log), + hlog.RequestIDHandler("request_id", "Request-Id"), exhttp.CORSMiddleware, + requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), exhttp.HandleErrors(errorBodies), )) - mp.KeyServer.Register(router) + mp.KeyServer.Register(router, log) } var ErrInvalidMediaIDSyntax = errors.New("invalid media ID syntax") From 7bd136196d9dddd3432cc8b438a2efd3a63c723e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 29 Jul 2025 17:22:17 +0300 Subject: [PATCH 197/581] format/htmlparser: don't add link suffix if plaintext is only missing protocol Auto-linkification will add a protocol in the `href`, but usually won't touch the text part. We want to undo the linkification here since it doesn't carry any additional information. --- format/htmlparser.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/format/htmlparser.go b/format/htmlparser.go index f9d51e39..b4b1b9a4 100644 --- a/format/htmlparser.go +++ b/format/htmlparser.go @@ -286,7 +286,10 @@ func (parser *HTMLParser) linkToString(node *html.Node, ctx Context) string { } if parser.LinkConverter != nil { return parser.LinkConverter(str, href, ctx) - } else if str == href { + } else if str == href || + str == strings.TrimPrefix(href, "mailto:") || + str == strings.TrimPrefix(href, "http://") || + str == strings.TrimPrefix(href, "https://") { return str } return fmt.Sprintf("%s (%s)", str, href) From 3a2815178038567ba1b2ab937c626b9a619950a6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 29 Jul 2025 17:41:51 +0300 Subject: [PATCH 198/581] client: log method/url when retrying requests --- client.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client.go b/client.go index 1f907608..f0aa3467 100644 --- a/client.go +++ b/client.go @@ -556,6 +556,8 @@ func (cli *Client) doRetry(req *http.Request, cause error, retries int, backoff } } log.Warn().Err(cause). + Str("method", req.Method). + Str("url", req.URL.String()). Int("retry_in_seconds", int(backoff.Seconds())). Msg("Request failed, retrying") select { From bcf92ba0e80a9329ae0e2be071930633d46f2d53 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 29 Jul 2025 17:42:04 +0300 Subject: [PATCH 199/581] appservice/intent: don't download avatar before setting on hungry --- appservice/intent.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appservice/intent.go b/appservice/intent.go index a1245d74..fa9d9e7a 100644 --- a/appservice/intent.go +++ b/appservice/intent.go @@ -511,7 +511,7 @@ func (intent *IntentAPI) SetAvatarURL(ctx context.Context, avatarURL id.ContentU // No need to update return nil } - if !avatarURL.IsEmpty() { + if !avatarURL.IsEmpty() && !intent.SpecVersions.Supports(mautrix.BeeperFeatureHungry) { // Some homeservers require the avatar to be downloaded before setting it resp, _ := intent.Download(ctx, avatarURL) if resp != nil { From 91b2bcdb9fb75088e1ffff7bd10d412f9d7d2ea7 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 31 Jul 2025 13:01:08 +0300 Subject: [PATCH 200/581] bridgev2/matrix: don't send connecting bridge states to cloud --- bridgev2/matrix/connector.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 158148f3..50d493f3 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -418,6 +418,10 @@ func (br *Connector) SendBridgeStatus(ctx context.Context, state *status.BridgeS Data: state, }) } else if br.Config.Homeserver.StatusEndpoint != "" { + // Connecting states aren't really relevant unless the bridge runs somewhere with an unreliable network + if state.StateEvent == status.StateConnecting { + return nil + } return state.SendHTTP(ctx, br.Config.Homeserver.StatusEndpoint, br.Config.AppService.ASToken) } else { return nil From 66e0ed47c0715e6ed3210e15c02f1b9dd41044e8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 31 Jul 2025 13:40:18 +0300 Subject: [PATCH 201/581] bridgev2/portal: include error in event handling results --- bridgev2/portal.go | 66 +++++++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 2f973aae..5aae45e9 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -695,7 +695,7 @@ func (portal *Portal) handleMatrixReceipts(ctx context.Context, evt *event.Event sender, err := portal.Bridge.GetUserByMXID(ctx, userID) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to get user to handle read receipt") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } portal.handleMatrixReadReceipt(ctx, sender, evtID, receipt) } @@ -1746,7 +1746,7 @@ func (portal *Portal) handleMatrixTombstone(ctx context.Context, evt *event.Even senderUser, err = portal.Bridge.GetUserByMXID(ctx, evt.Sender) if err != nil { log.Err(err).Msg("Failed to get tombstone sender user") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } } content, ok := evt.Content.Parsed.(*event.TombstoneEventContent) @@ -1767,14 +1767,14 @@ func (portal *Portal) handleMatrixTombstone(ctx context.Context, evt *event.Even err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, true) if err != nil { log.Err(err).Msg("Failed to clean up Matrix room") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } return EventHandlingResultSuccess } existingMemberEvt, err := portal.Bridge.Matrix.GetMemberInfo(ctx, content.ReplacementRoom, portal.Bridge.Bot.GetMXID()) if err != nil { log.Err(err).Msg("Failed to get member info of bot in replacement room") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } leaveOnError := func() { if existingMemberEvt != nil && existingMemberEvt.Membership == event.MembershipJoin { @@ -1805,14 +1805,14 @@ func (portal *Portal) handleMatrixTombstone(ctx context.Context, evt *event.Even err = portal.Bridge.Bot.EnsureJoined(ctx, content.ReplacementRoom, EnsureJoinedParams{Via: via}) if err != nil { log.Err(err).Msg("Failed to join replacement room from tombstone") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } if !sentByBridge && !senderUser.Permissions.Admin { powers, err := portal.Bridge.Matrix.GetPowerLevels(ctx, content.ReplacementRoom) if err != nil { log.Err(err).Msg("Failed to get power levels in replacement room") leaveOnError() - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } if powers.GetUserLevel(evt.Sender) < powers.Invite() { log.Warn().Msg("Tombstone sender doesn't have enough power to invite the bot to the replacement room") @@ -1840,7 +1840,7 @@ func (portal *Portal) handleMatrixTombstone(ctx context.Context, evt *event.Even err = portal.Save(ctx) if err != nil { log.Err(err).Msg("Failed to save portal after tombstone") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } log.Info().Msg("Successfully followed tombstone and updated portal MXID") err = portal.Bridge.DB.UserPortal.MarkAllNotInSpace(ctx, portal.PortalKey) @@ -1989,7 +1989,7 @@ func (portal *Portal) handleRemoteEvent(ctx context.Context, source *UserLogin, err = portal.createMatrixRoomInLoop(ctx, source, info, bundle) if err != nil { log.Err(err).Msg("Failed to create portal to handle event") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } if evtType == RemoteEventChatResync { log.Debug().Msg("Not handling chat resync event further as portal was created by it") @@ -2420,7 +2420,7 @@ func (portal *Portal) handleRemoteUpsert(ctx context.Context, source *UserLogin, err = portal.Bridge.DB.Message.Update(ctx, part) if err != nil { log.Err(err).Str("part_id", string(part.PartID)).Msg("Failed to update message part in database") - handleRes = EventHandlingResultFailed + handleRes = EventHandlingResultFailed.WithError(err) } } } @@ -2484,7 +2484,7 @@ func (portal *Portal) handleRemoteMessage(ctx context.Context, source *UserLogin } else { log.Err(err).Msg("Failed to convert remote message") portal.sendRemoteErrorNotice(ctx, intent, err, ts, "message") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } } _, res = portal.sendConvertedMessage(ctx, evt.GetID(), intent, evt.GetSender().Sender, converted, ts, getStreamOrder(evt), nil) @@ -2529,7 +2529,7 @@ func (portal *Portal) handleRemoteEdit(ctx context.Context, source *UserLogin, e existing, err = portal.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, targetID) if err != nil { log.Err(err).Msg("Failed to get edit target message") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } } if existing == nil { @@ -2554,7 +2554,7 @@ func (portal *Portal) handleRemoteEdit(ctx context.Context, source *UserLogin, e } else if err != nil { log.Err(err).Msg("Failed to convert remote edit") portal.sendRemoteErrorNotice(ctx, intent, err, ts, "edit") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } res := portal.sendConvertedEdit(ctx, existing[0].ID, evt.GetSender().Sender, converted, intent, ts, getStreamOrder(evt)) if portal.currentlyTypingGhosts.Pop(intent.GetMXID()) { @@ -2703,7 +2703,7 @@ func (portal *Portal) handleRemoteReactionSync(ctx context.Context, source *User targetMessage, err := portal.getTargetMessagePart(ctx, evt) if err != nil { log.Err(err).Msg("Failed to get target message for reaction") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } else if targetMessage == nil { // TODO use deterministic event ID as target if applicable? log.Warn().Msg("Target message for reaction not found") @@ -2717,7 +2717,7 @@ func (portal *Portal) handleRemoteReactionSync(ctx context.Context, source *User } if err != nil { log.Err(err).Msg("Failed to get existing reactions for reaction sync") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } existing := make(map[networkid.UserID]map[networkid.EmojiID]*database.Reaction) for _, existingReaction := range existingReactions { @@ -2839,7 +2839,7 @@ func (portal *Portal) handleRemoteReaction(ctx context.Context, source *UserLogi targetMessage, err := portal.getTargetMessagePart(ctx, evt) if err != nil { log.Err(err).Msg("Failed to get target message for reaction") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } else if targetMessage == nil { // TODO use deterministic event ID as target if applicable? log.Warn().Msg("Target message for reaction not found") @@ -2849,7 +2849,7 @@ func (portal *Portal) handleRemoteReaction(ctx context.Context, source *UserLogi existingReaction, err := portal.Bridge.DB.Reaction.GetByID(ctx, portal.Receiver, targetMessage.ID, targetMessage.PartID, evt.GetSender().Sender, emojiID) if err != nil { log.Err(err).Msg("Failed to check if reaction is a duplicate") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } else if existingReaction != nil && (emojiID != "" || existingReaction.Emoji == emoji) { log.Debug().Msg("Ignoring duplicate reaction") return EventHandlingResultIgnored @@ -2919,7 +2919,7 @@ func (portal *Portal) sendConvertedReaction( }) if err != nil { logContext(log.Err(err)).Msg("Failed to send reaction to Matrix") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } logContext(log.Debug()). Stringer("event_id", resp.EventID). @@ -2928,7 +2928,7 @@ func (portal *Portal) sendConvertedReaction( err = portal.Bridge.DB.Reaction.Upsert(ctx, dbReaction) if err != nil { logContext(log.Err(err)).Msg("Failed to save reaction to database") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } return EventHandlingResultSuccess } @@ -2954,7 +2954,7 @@ func (portal *Portal) handleRemoteReactionRemove(ctx context.Context, source *Us targetReaction, err := portal.getTargetReaction(ctx, evt) if err != nil { log.Err(err).Msg("Failed to get target reaction for removal") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } else if targetReaction == nil { log.Warn().Msg("Target reaction not found") return EventHandlingResultIgnored @@ -2978,7 +2978,7 @@ func (portal *Portal) handleRemoteReactionRemove(ctx context.Context, source *Us }, &MatrixSendExtra{Timestamp: ts, ReactionMeta: targetReaction}) if err != nil { log.Err(err).Stringer("reaction_mxid", targetReaction.MXID).Msg("Failed to redact reaction") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } err = portal.Bridge.DB.Reaction.Delete(ctx, targetReaction) if err != nil { @@ -2992,7 +2992,7 @@ func (portal *Portal) handleRemoteMessageRemove(ctx context.Context, source *Use targetParts, err := portal.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, evt.GetTargetMessage()) if err != nil { log.Err(err).Msg("Failed to get target message for removal") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } else if len(targetParts) == 0 { log.Debug().Msg("Target message not found") return EventHandlingResultIgnored @@ -3003,7 +3003,7 @@ func (portal *Portal) handleRemoteMessageRemove(ctx context.Context, source *Use logins, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) if err != nil { log.Err(err).Msg("Failed to check if portal has other logins") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } else if len(logins) > 1 { log.Debug().Msg("Ignoring delete for me event in portal with multiple logins") return EventHandlingResultIgnored @@ -3069,7 +3069,7 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL if err != nil { log.Err(err).Str("last_target_id", string(lastTargetID)). Msg("Failed to get last target message for read receipt") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } else if lastTarget == nil { log.Debug().Str("last_target_id", string(lastTargetID)). Msg("Last target message not found") @@ -3088,7 +3088,7 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL if err != nil { log.Err(err).Str("target_id", string(targetID)). Msg("Failed to get target message for read receipt") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } else if target != nil && !target.HasFakeMXID() && (lastTarget == nil || target.Timestamp.After(lastTarget.Timestamp)) { lastTarget = target } @@ -3126,7 +3126,7 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL } if err != nil { addTargetLog(log.Err(err)).Msg("Failed to bridge read receipt") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } else { addTargetLog(log.Debug()).Msg("Bridged read receipt") } @@ -3148,7 +3148,7 @@ func (portal *Portal) handleRemoteMarkUnread(ctx context.Context, source *UserLo err := dp.MarkUnread(ctx, portal.MXID, evt.GetUnread()) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to bridge mark unread event") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } return EventHandlingResultSuccess } @@ -3166,7 +3166,7 @@ func (portal *Portal) handleRemoteDeliveryReceipt(ctx context.Context, source *U targetParts, err := portal.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, target) if err != nil { log.Err(err).Str("target_id", string(target)).Msg("Failed to get target message for delivery receipt") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } else if len(targetParts) == 0 { continue } else if _, sentByGhost := portal.Bridge.Matrix.ParseGhostMXID(targetParts[0].SenderMXID); sentByGhost { @@ -3201,7 +3201,7 @@ func (portal *Portal) handleRemoteTyping(ctx context.Context, source *UserLogin, err := intent.MarkTyping(ctx, portal.MXID, typingType, timeout) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to bridge typing event") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } if timeout == 0 { portal.currentlyTypingGhosts.Remove(intent.GetMXID()) @@ -3215,7 +3215,7 @@ func (portal *Portal) handleRemoteChatInfoChange(ctx context.Context, source *Us info, err := evt.GetChatInfoChange(ctx) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to get chat info change") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } portal.ProcessChatInfoChange(ctx, evt.GetSender(), source, info, getEventTS(evt)) return EventHandlingResultSuccess @@ -3259,7 +3259,7 @@ func (portal *Portal) handleRemoteChatDelete(ctx context.Context, source *UserLo logins, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) if err != nil { log.Err(err).Msg("Failed to check if portal has other logins") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } var ownUP *database.UserPortal logins = slices.DeleteFunc(logins, func(up *database.UserPortal) bool { @@ -3289,7 +3289,7 @@ func (portal *Portal) handleRemoteChatDelete(ctx context.Context, source *UserLo ) if err != nil { log.Err(err).Msg("Failed to send leave state event for user after remote chat delete") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } else { log.Debug().Msg("Sent leave state event for user after remote chat delete") return EventHandlingResultSuccess @@ -3299,12 +3299,12 @@ func (portal *Portal) handleRemoteChatDelete(ctx context.Context, source *UserLo err := portal.Delete(ctx) if err != nil { log.Err(err).Msg("Failed to delete portal from database") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, false) if err != nil { log.Err(err).Msg("Failed to delete Matrix room") - return EventHandlingResultFailed + return EventHandlingResultFailed.WithError(err) } else { log.Info().Msg("Deleted room after remote chat delete event") return EventHandlingResultSuccess From 94f53c5853c63065369dc4b23ad920124e806dec Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 31 Jul 2025 14:00:00 +0300 Subject: [PATCH 202/581] bridgev2/cryptostore: add missing escape clause to not like --- bridgev2/matrix/cryptostore.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/matrix/cryptostore.go b/bridgev2/matrix/cryptostore.go index 234797a6..4c3b5d30 100644 --- a/bridgev2/matrix/cryptostore.go +++ b/bridgev2/matrix/cryptostore.go @@ -45,7 +45,7 @@ func (store *SQLCryptoStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, WHERE room_id=$1 AND (membership='join' OR membership='invite') AND user_id<>$2 - AND user_id NOT LIKE $3 + AND user_id NOT LIKE $3 ESCAPE '\' `, roomID, store.UserID, store.GhostIDFormat) if err != nil { return From 10b26b507df823bee68b80c15832eca9fa383e7e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 1 Aug 2025 10:38:02 +0300 Subject: [PATCH 203/581] client: fix updating state store in CreateRoom --- client.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client.go b/client.go index f0aa3467..53ac6e10 100644 --- a/client.go +++ b/client.go @@ -1369,6 +1369,10 @@ func (cli *Client) CreateRoom(ctx context.Context, req *ReqCreateRoom) (resp *Re Msg("Failed to update creator membership in state store after creating room") } for _, evt := range req.InitialState { + evt.RoomID = resp.RoomID + if evt.StateKey == nil { + evt.StateKey = ptr.Ptr("") + } UpdateStateStore(ctx, cli.StateStore, evt) } inviteMembership := event.MembershipInvite From 190c0de94f19989a0169cf1e95cdb9664e36ec7d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 1 Aug 2025 10:51:00 +0300 Subject: [PATCH 204/581] bridgev2/matrix: always clear mx_user_profile when deleting room --- bridgev2/matrix/intent.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index 4a337e53..7d78b5a2 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -578,7 +578,15 @@ func (as *ASIntent) MarkAsDM(ctx context.Context, roomID id.RoomID, withUser id. func (as *ASIntent) DeleteRoom(ctx context.Context, roomID id.RoomID, puppetsOnly bool) error { if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) { - return as.Matrix.BeeperDeleteRoom(ctx, roomID) + err := as.Matrix.BeeperDeleteRoom(ctx, roomID) + if err != nil { + return err + } + err = as.Matrix.StateStore.ClearCachedMembers(ctx, roomID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to clear cached members while cleaning up portal") + } + return nil } members, err := as.Matrix.JoinedMembers(ctx, roomID) if err != nil { From 66ec881a741661b98ef8ceb62e3d0bbdabc4e486 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 1 Aug 2025 11:00:37 +0300 Subject: [PATCH 205/581] bridgev2/matrix: add hack for resyncing encryption state cache --- bridgev2/database/kvstore.go | 5 ++-- bridgev2/matrix/connector.go | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/bridgev2/database/kvstore.go b/bridgev2/database/kvstore.go index 5a1af019..52b4984e 100644 --- a/bridgev2/database/kvstore.go +++ b/bridgev2/database/kvstore.go @@ -20,8 +20,9 @@ import ( type Key string const ( - KeySplitPortalsEnabled Key = "split_portals_enabled" - KeyBridgeInfoVersion Key = "bridge_info_version" + KeySplitPortalsEnabled Key = "split_portals_enabled" + KeyBridgeInfoVersion Key = "bridge_info_version" + KeyEncryptionStateResynced Key = "encryption_state_resynced" ) type KVQuery struct { diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 50d493f3..9c19c472 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -170,6 +170,17 @@ func (br *Connector) Start(ctx context.Context) error { if err != nil { return err } + needsStateResync := br.Config.Encryption.Default && + br.Bridge.DB.KV.Get(ctx, database.KeyEncryptionStateResynced) != "true" + if needsStateResync { + dbExists, err := br.StateStore.TableExists(ctx, "mx_version") + if err != nil { + return fmt.Errorf("failed to check if mx_version table exists: %w", err) + } else if !dbExists { + needsStateResync = false + br.Bridge.DB.KV.Set(ctx, database.KeyEncryptionStateResynced, "true") + } + } err = br.StateStore.Upgrade(ctx) if err != nil { return bridgev2.DBUpgradeError{Section: "matrix_state", Err: err} @@ -213,9 +224,48 @@ func (br *Connector) Start(ctx context.Context) error { br.wsStopPinger = make(chan struct{}, 1) go br.websocketServerPinger() } + if needsStateResync { + br.ResyncEncryptionState(ctx) + } return nil } +func (br *Connector) ResyncEncryptionState(ctx context.Context) { + log := zerolog.Ctx(ctx) + roomIDScanner := dbutil.ConvertRowFn[id.RoomID](dbutil.ScanSingleColumn[id.RoomID]) + rooms, err := roomIDScanner.NewRowIter(br.Bridge.DB.Query(ctx, ` + SELECT rooms.room_id + FROM (SELECT DISTINCT(room_id) FROM mx_user_profile) rooms + LEFT JOIN mx_room_state ON rooms.room_id = mx_room_state.room_id + WHERE mx_room_state.encryption IS NULL + `)).AsList() + if err != nil { + log.Err(err).Msg("Failed to get room list to resync state") + return + } + var failedCount, successCount, forbiddenCount int + for _, roomID := range rooms { + var outContent *event.EncryptionEventContent + err = br.Bot.StateEvent(ctx, roomID, event.StateEncryption, "", &outContent) + if errors.Is(err, mautrix.MForbidden) { + // Most likely non-existent room + log.Debug().Err(err).Stringer("room_id", roomID).Msg("Failed to get state for room") + forbiddenCount++ + } else if err != nil { + log.Err(err).Stringer("room_id", roomID).Msg("Failed to get state for room") + failedCount++ + } else { + successCount++ + } + } + br.Bridge.DB.KV.Set(ctx, database.KeyEncryptionStateResynced, "true") + log.Info(). + Int("success_count", successCount). + Int("forbidden_count", forbiddenCount). + Int("failed_count", failedCount). + Msg("Resynced rooms") +} + func (br *Connector) GetPublicAddress() string { if br.Config.AppService.PublicAddress == "https://bridge.example.com" { return "" From 196164ed6749a91b3ae12c6ce2fd836697afcd38 Mon Sep 17 00:00:00 2001 From: "timedout (aka nexy7574)" Date: Fri, 1 Aug 2025 09:47:53 +0100 Subject: [PATCH 206/581] event: add join_authorised_via_users_server to MemberEventContent (#395) Adds `JoinAuthorisedViaUsersServer` (`join_authorised_via_users_server`) to `MemberEventContent`, introduced in room version 8 --- event/member.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/event/member.go b/event/member.go index 02b7cae9..53387e8b 100644 --- a/event/member.go +++ b/event/member.go @@ -35,13 +35,14 @@ const ( // MemberEventContent represents the content of a m.room.member state event. // https://spec.matrix.org/v1.2/client-server-api/#mroommember type MemberEventContent struct { - Membership Membership `json:"membership"` - AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` - Displayname string `json:"displayname,omitempty"` - IsDirect bool `json:"is_direct,omitempty"` - ThirdPartyInvite *ThirdPartyInvite `json:"third_party_invite,omitempty"` - Reason string `json:"reason,omitempty"` - MSC3414File *EncryptedFileInfo `json:"org.matrix.msc3414.file,omitempty"` + Membership Membership `json:"membership"` + AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` + Displayname string `json:"displayname,omitempty"` + IsDirect bool `json:"is_direct,omitempty"` + ThirdPartyInvite *ThirdPartyInvite `json:"third_party_invite,omitempty"` + Reason string `json:"reason,omitempty"` + JoinAuthorisedViaUsersServer id.UserID `json:"join_authorised_via_users_server,omitempty"` + MSC3414File *EncryptedFileInfo `json:"org.matrix.msc3414.file,omitempty"` MSC4293RedactEvents bool `json:"org.matrix.msc4293.redact_events,omitempty"` } From 0a804c58a13af89e0bcfd2c998999be31cc9f5d0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 1 Aug 2025 12:15:38 +0300 Subject: [PATCH 207/581] bridgev2/matrix: don't ensure joined for state resync --- bridgev2/matrix/connector.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 9c19c472..51eeb42b 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -246,7 +246,7 @@ func (br *Connector) ResyncEncryptionState(ctx context.Context) { var failedCount, successCount, forbiddenCount int for _, roomID := range rooms { var outContent *event.EncryptionEventContent - err = br.Bot.StateEvent(ctx, roomID, event.StateEncryption, "", &outContent) + err = br.Bot.Client.StateEvent(ctx, roomID, event.StateEncryption, "", &outContent) if errors.Is(err, mautrix.MForbidden) { // Most likely non-existent room log.Debug().Err(err).Stringer("room_id", roomID).Msg("Failed to get state for room") From aeeea095495b4c4e9e454ad1d415970d728f572d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 1 Aug 2025 12:19:51 +0300 Subject: [PATCH 208/581] sqlstatestore: ensure empty room/user ids aren't stored in db --- bridgev2/matrix/connector.go | 5 +++- sqlstatestore/statestore.go | 27 ++++++++++++++++++++++ sqlstatestore/v00-latest-revision.sql | 2 +- sqlstatestore/v09-clear-empty-room-ids.sql | 3 +++ 4 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 sqlstatestore/v09-clear-empty-room-ids.sql diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 51eeb42b..a1f7d140 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -235,7 +235,7 @@ func (br *Connector) ResyncEncryptionState(ctx context.Context) { roomIDScanner := dbutil.ConvertRowFn[id.RoomID](dbutil.ScanSingleColumn[id.RoomID]) rooms, err := roomIDScanner.NewRowIter(br.Bridge.DB.Query(ctx, ` SELECT rooms.room_id - FROM (SELECT DISTINCT(room_id) FROM mx_user_profile) rooms + FROM (SELECT DISTINCT(room_id) FROM mx_user_profile WHERE room_id<>'') rooms LEFT JOIN mx_room_state ON rooms.room_id = mx_room_state.room_id WHERE mx_room_state.encryption IS NULL `)).AsList() @@ -245,6 +245,9 @@ func (br *Connector) ResyncEncryptionState(ctx context.Context) { } var failedCount, successCount, forbiddenCount int for _, roomID := range rooms { + if roomID == "" { + continue + } var outContent *event.EncryptionEventContent err = br.Bot.Client.StateEvent(ctx, roomID, event.StateEncryption, "", &outContent) if errors.Is(err, mautrix.MForbidden) { diff --git a/sqlstatestore/statestore.go b/sqlstatestore/statestore.go index f9a7e421..0ed4b698 100644 --- a/sqlstatestore/statestore.go +++ b/sqlstatestore/statestore.go @@ -62,6 +62,9 @@ func (store *SQLStateStore) IsRegistered(ctx context.Context, userID id.UserID) } func (store *SQLStateStore) MarkRegistered(ctx context.Context, userID id.UserID) error { + if userID == "" { + return fmt.Errorf("user ID is empty") + } _, err := store.Exec(ctx, "INSERT INTO mx_registrations (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING", userID) return err } @@ -182,6 +185,11 @@ func (store *SQLStateStore) IsMembership(ctx context.Context, roomID id.RoomID, } func (store *SQLStateStore) SetMembership(ctx context.Context, roomID id.RoomID, userID id.UserID, membership event.Membership) error { + if roomID == "" { + return fmt.Errorf("room ID is empty") + } else if userID == "" { + return fmt.Errorf("user ID is empty") + } _, err := store.Exec(ctx, ` INSERT INTO mx_user_profile (room_id, user_id, membership, displayname, avatar_url) VALUES ($1, $2, $3, '', '') ON CONFLICT (room_id, user_id) DO UPDATE SET membership=excluded.membership @@ -214,6 +222,11 @@ func (u *userProfileRow) GetMassInsertValues() [5]any { var userProfileMassInserter = dbutil.NewMassInsertBuilder[*userProfileRow, [1]any](insertUserProfileQuery, "($1, $%d, $%d, $%d, $%d, $%d)") func (store *SQLStateStore) SetMember(ctx context.Context, roomID id.RoomID, userID id.UserID, member *event.MemberEventContent) error { + if roomID == "" { + return fmt.Errorf("room ID is empty") + } else if userID == "" { + return fmt.Errorf("user ID is empty") + } var nameSkeleton []byte if !store.DisableNameDisambiguation && len(member.Displayname) > 0 { nameSkeletonArr := confusable.SkeletonHash(member.Displayname) @@ -235,6 +248,9 @@ func (store *SQLStateStore) IsConfusableName(ctx context.Context, roomID id.Room const userProfileMassInsertBatchSize = 500 func (store *SQLStateStore) ReplaceCachedMembers(ctx context.Context, roomID id.RoomID, evts []*event.Event, onlyMemberships ...event.Membership) error { + if roomID == "" { + return fmt.Errorf("room ID is empty") + } return store.DoTxn(ctx, nil, func(ctx context.Context) error { err := store.ClearCachedMembers(ctx, roomID, onlyMemberships...) if err != nil { @@ -305,6 +321,9 @@ func (store *SQLStateStore) HasFetchedMembers(ctx context.Context, roomID id.Roo } func (store *SQLStateStore) MarkMembersFetched(ctx context.Context, roomID id.RoomID) error { + if roomID == "" { + return fmt.Errorf("room ID is empty") + } _, err := store.Exec(ctx, ` INSERT INTO mx_room_state (room_id, members_fetched) VALUES ($1, true) ON CONFLICT (room_id) DO UPDATE SET members_fetched=true @@ -334,6 +353,9 @@ func (store *SQLStateStore) GetAllMembers(ctx context.Context, roomID id.RoomID) } func (store *SQLStateStore) SetEncryptionEvent(ctx context.Context, roomID id.RoomID, content *event.EncryptionEventContent) error { + if roomID == "" { + return fmt.Errorf("room ID is empty") + } contentBytes, err := json.Marshal(content) if err != nil { return fmt.Errorf("failed to marshal content JSON: %w", err) @@ -371,6 +393,9 @@ func (store *SQLStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) ( } func (store *SQLStateStore) SetPowerLevels(ctx context.Context, roomID id.RoomID, levels *event.PowerLevelsEventContent) error { + if roomID == "" { + return fmt.Errorf("room ID is empty") + } _, err := store.Exec(ctx, ` INSERT INTO mx_room_state (room_id, power_levels) VALUES ($1, $2) ON CONFLICT (room_id) DO UPDATE SET power_levels=excluded.power_levels @@ -421,6 +446,8 @@ func (store *SQLStateStore) HasPowerLevel(ctx context.Context, roomID id.RoomID, func (store *SQLStateStore) SetCreate(ctx context.Context, evt *event.Event) error { if evt.Type != event.StateCreate { return fmt.Errorf("invalid event type for create event: %s", evt.Type) + } else if evt.RoomID == "" { + return fmt.Errorf("room ID is empty") } _, err := store.Exec(ctx, ` INSERT INTO mx_room_state (room_id, create_event) VALUES ($1, $2) diff --git a/sqlstatestore/v00-latest-revision.sql b/sqlstatestore/v00-latest-revision.sql index 132ed1ab..b5a858ec 100644 --- a/sqlstatestore/v00-latest-revision.sql +++ b/sqlstatestore/v00-latest-revision.sql @@ -1,4 +1,4 @@ --- v0 -> v8 (compatible with v3+): Latest revision +-- v0 -> v9 (compatible with v3+): Latest revision CREATE TABLE mx_registrations ( user_id TEXT PRIMARY KEY diff --git a/sqlstatestore/v09-clear-empty-room-ids.sql b/sqlstatestore/v09-clear-empty-room-ids.sql new file mode 100644 index 00000000..ca951068 --- /dev/null +++ b/sqlstatestore/v09-clear-empty-room-ids.sql @@ -0,0 +1,3 @@ +-- v9 (compatible with v3+): Clear invalid rows +DELETE FROM mx_room_state WHERE room_id=''; +DELETE FROM mx_user_profile WHERE room_id='' OR user_id=''; From 09e4706fdba6a6f05900e69a723b1c24fa983cdb Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 1 Aug 2025 14:13:55 +0300 Subject: [PATCH 209/581] crypto/backup: allow encrypting session without private key --- crypto/backup/encryptedsessiondata.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crypto/backup/encryptedsessiondata.go b/crypto/backup/encryptedsessiondata.go index ec551dbe..25250178 100644 --- a/crypto/backup/encryptedsessiondata.go +++ b/crypto/backup/encryptedsessiondata.go @@ -68,6 +68,10 @@ func calculateCompatMAC(macKey []byte) []byte { // // [Section 11.12.3.2.2 of the Spec]: https://spec.matrix.org/v1.9/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 func EncryptSessionData[T any](backupKey *MegolmBackupKey, sessionData T) (*EncryptedSessionData[T], error) { + return EncryptSessionDataWithPubkey(backupKey.PublicKey(), sessionData) +} + +func EncryptSessionDataWithPubkey[T any](pubkey *ecdh.PublicKey, sessionData T) (*EncryptedSessionData[T], error) { sessionJSON, err := json.Marshal(sessionData) if err != nil { return nil, err @@ -78,7 +82,7 @@ func EncryptSessionData[T any](backupKey *MegolmBackupKey, sessionData T) (*Encr return nil, err } - sharedSecret, err := ephemeralKey.ECDH(backupKey.PublicKey()) + sharedSecret, err := ephemeralKey.ECDH(pubkey) if err != nil { return nil, err } From 654b6b1d4574e926fc6534654a2d18446f93b168 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 2 Aug 2025 11:39:18 -0600 Subject: [PATCH 210/581] crypto: replace t.Fatal and t.Error with require and assert Signed-off-by: Sumner Evans --- crypto/aescbc/aes_cbc_test.go | 41 ++--- crypto/canonicaljson/json_test.go | 79 +++++---- crypto/cross_sign_test.go | 72 ++++---- crypto/machine_test.go | 59 ++----- crypto/store_test.go | 273 ++++++++++-------------------- crypto/utils/utils_test.go | 36 ++-- 6 files changed, 210 insertions(+), 350 deletions(-) diff --git a/crypto/aescbc/aes_cbc_test.go b/crypto/aescbc/aes_cbc_test.go index bb03f706..d6611dc9 100644 --- a/crypto/aescbc/aes_cbc_test.go +++ b/crypto/aescbc/aes_cbc_test.go @@ -7,11 +7,13 @@ package aescbc_test import ( - "bytes" "crypto/aes" "crypto/rand" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix/crypto/aescbc" ) @@ -22,32 +24,23 @@ func TestAESCBC(t *testing.T) { // The key length can be 32, 24, 16 bytes (OR in bits: 128, 192 or 256) key := make([]byte, 32) _, err = rand.Read(key) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) iv := make([]byte, aes.BlockSize) _, err = rand.Read(iv) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) plaintext = []byte("secret message for testing") //increase to next block size for len(plaintext)%8 != 0 { plaintext = append(plaintext, []byte("-")...) } - if ciphertext, err = aescbc.Encrypt(key, iv, plaintext); err != nil { - t.Fatal(err) - } + ciphertext, err = aescbc.Encrypt(key, iv, plaintext) + require.NoError(t, err) resultPlainText, err := aescbc.Decrypt(key, iv, ciphertext) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) - if string(resultPlainText) != string(plaintext) { - t.Fatalf("message '%s' (length %d) != '%s'", resultPlainText, len(resultPlainText), plaintext) - } + assert.Equal(t, string(resultPlainText), string(plaintext)) } func TestAESCBCCase1(t *testing.T) { @@ -61,18 +54,10 @@ func TestAESCBCCase1(t *testing.T) { key := make([]byte, 32) iv := make([]byte, aes.BlockSize) encrypted, err := aescbc.Encrypt(key, iv, input) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(expected, encrypted) { - t.Fatalf("encrypted did not match expected:\n%v\n%v\n", encrypted, expected) - } + require.NoError(t, err) + assert.Equal(t, expected, encrypted, "encrypted output does not match expected") decrypted, err := aescbc.Decrypt(key, iv, encrypted) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(input, decrypted) { - t.Fatalf("decrypted did not match expected:\n%v\n%v\n", decrypted, input) - } + require.NoError(t, err) + assert.Equal(t, input, decrypted, "decrypted output does not match input") } diff --git a/crypto/canonicaljson/json_test.go b/crypto/canonicaljson/json_test.go index d1a7f0a5..36476aa4 100644 --- a/crypto/canonicaljson/json_test.go +++ b/crypto/canonicaljson/json_test.go @@ -17,31 +17,43 @@ package canonicaljson import ( "testing" + + "github.com/stretchr/testify/assert" ) -func testSortJSON(t *testing.T, input, want string) { - got := SortJSON([]byte(input), nil) - - // Squash out the whitespace before comparing the JSON in case SortJSON had inserted whitespace. - if string(CompactJSON(got, nil)) != want { - t.Errorf("SortJSON(%q): want %q got %q", input, want, got) - } -} - func TestSortJSON(t *testing.T) { - testSortJSON(t, `[{"b":"two","a":1}]`, `[{"a":1,"b":"two"}]`) - testSortJSON(t, `{"B":{"4":4,"3":3},"A":{"1":1,"2":2}}`, - `{"A":{"1":1,"2":2},"B":{"3":3,"4":4}}`) - testSortJSON(t, `[true,false,null]`, `[true,false,null]`) - testSortJSON(t, `[9007199254740991]`, `[9007199254740991]`) - testSortJSON(t, "\t\n[9007199254740991]", `[9007199254740991]`) + var tests = []struct { + input string + want string + }{ + {"{}", "{}"}, + {`[{"b":"two","a":1}]`, `[{"a":1,"b":"two"}]`}, + {`{"B":{"4":4,"3":3},"A":{"1":1,"2":2}}`, `{"A":{"1":1,"2":2},"B":{"3":3,"4":4}}`}, + {`[true,false,null]`, `[true,false,null]`}, + {`[9007199254740991]`, `[9007199254740991]`}, + {"\t\n[9007199254740991]", `[9007199254740991]`}, + {`[true,false,null]`, `[true,false,null]`}, + {`[{"b":"two","a":1}]`, `[{"a":1,"b":"two"}]`}, + {`{"B":{"4":4,"3":3},"A":{"1":1,"2":2}}`, `{"A":{"1":1,"2":2},"B":{"3":3,"4":4}}`}, + {`[true,false,null]`, `[true,false,null]`}, + {`[9007199254740991]`, `[9007199254740991]`}, + {"\t\n[9007199254740991]", `[9007199254740991]`}, + {`[true,false,null]`, `[true,false,null]`}, + } + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + got := SortJSON([]byte(test.input), nil) + + // Squash out the whitespace before comparing the JSON in case SortJSON had inserted whitespace. + assert.EqualValues(t, test.want, string(CompactJSON(got, nil))) + }) + } } func testCompactJSON(t *testing.T, input, want string) { + t.Helper() got := string(CompactJSON([]byte(input), nil)) - if got != want { - t.Errorf("CompactJSON(%q): want %q got %q", input, want, got) - } + assert.EqualValues(t, want, got) } func TestCompactJSON(t *testing.T) { @@ -74,18 +86,23 @@ func TestCompactJSON(t *testing.T) { testCompactJSON(t, `["\"\\\/"]`, `["\"\\/"]`) } -func testReadHex(t *testing.T, input string, want uint32) { - got := readHexDigits([]byte(input)) - if want != got { - t.Errorf("readHexDigits(%q): want 0x%x got 0x%x", input, want, got) +func TestReadHex(t *testing.T) { + tests := []struct { + input string + want uint32 + }{ + + {"0123", 0x0123}, + {"4567", 0x4567}, + {"89AB", 0x89AB}, + {"CDEF", 0xCDEF}, + {"89ab", 0x89AB}, + {"cdef", 0xCDEF}, + } + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + got := readHexDigits([]byte(test.input)) + assert.Equal(t, test.want, got) + }) } } - -func TestReadHex(t *testing.T) { - testReadHex(t, "0123", 0x0123) - testReadHex(t, "4567", 0x4567) - testReadHex(t, "89AB", 0x89AB) - testReadHex(t, "CDEF", 0xCDEF) - testReadHex(t, "89ab", 0x89AB) - testReadHex(t, "cdef", 0xCDEF) -} diff --git a/crypto/cross_sign_test.go b/crypto/cross_sign_test.go index 5e1ffd50..b70370a2 100644 --- a/crypto/cross_sign_test.go +++ b/crypto/cross_sign_test.go @@ -13,6 +13,8 @@ import ( "testing" "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.mau.fi/util/dbutil" "maunium.net/go/mautrix" @@ -24,17 +26,12 @@ var noopLogger = zerolog.Nop() func getOlmMachine(t *testing.T) *OlmMachine { rawDB, err := sql.Open("sqlite3", ":memory:?_busy_timeout=5000") - if err != nil { - t.Fatalf("Error opening db: %v", err) - } + require.NoError(t, err, "Error opening raw database") db, err := dbutil.NewWithDB(rawDB, "sqlite3") - if err != nil { - t.Fatalf("Error opening db: %v", err) - } + require.NoError(t, err, "Error creating database wrapper") sqlStore := NewSQLCryptoStore(db, nil, "accid", id.DeviceID("dev"), []byte("test")) - if err = sqlStore.DB.Upgrade(context.TODO()); err != nil { - t.Fatalf("Error creating tables: %v", err) - } + err = sqlStore.DB.Upgrade(context.TODO()) + require.NoError(t, err, "Error upgrading database") userID := id.UserID("@mautrix") mk, _ := olm.NewPKSigning() @@ -66,29 +63,25 @@ func TestTrustOwnDevice(t *testing.T) { DeviceID: "device", SigningKey: id.Ed25519("deviceKey"), } - if m.IsDeviceTrusted(context.TODO(), ownDevice) { - t.Error("Own device trusted while it shouldn't be") - } + assert.False(t, m.IsDeviceTrusted(context.TODO(), ownDevice), "Own device trusted while it shouldn't be") m.CryptoStore.PutSignature(context.TODO(), ownDevice.UserID, m.CrossSigningKeys.SelfSigningKey.PublicKey(), ownDevice.UserID, m.CrossSigningKeys.MasterKey.PublicKey(), "sig1") m.CryptoStore.PutSignature(context.TODO(), ownDevice.UserID, ownDevice.SigningKey, ownDevice.UserID, m.CrossSigningKeys.SelfSigningKey.PublicKey(), "sig2") - if trusted, _ := m.IsUserTrusted(context.TODO(), ownDevice.UserID); !trusted { - t.Error("Own user not trusted while they should be") - } - if !m.IsDeviceTrusted(context.TODO(), ownDevice) { - t.Error("Own device not trusted while it should be") - } + trusted, err := m.IsUserTrusted(context.TODO(), ownDevice.UserID) + require.NoError(t, err, "Error checking if own user is trusted") + assert.True(t, trusted, "Own user not trusted while they should be") + assert.True(t, m.IsDeviceTrusted(context.TODO(), ownDevice), "Own device not trusted while it should be") } func TestTrustOtherUser(t *testing.T) { m := getOlmMachine(t) otherUser := id.UserID("@user") - if trusted, _ := m.IsUserTrusted(context.TODO(), otherUser); trusted { - t.Error("Other user trusted while they shouldn't be") - } + trusted, err := m.IsUserTrusted(context.TODO(), otherUser) + require.NoError(t, err, "Error checking if other user is trusted") + assert.False(t, trusted, "Other user trusted while they shouldn't be") theirMasterKey, _ := olm.NewPKSigning() m.CryptoStore.PutCrossSigningKey(context.TODO(), otherUser, id.XSUsageMaster, theirMasterKey.PublicKey()) @@ -100,16 +93,16 @@ func TestTrustOtherUser(t *testing.T) { m.CryptoStore.PutSignature(context.TODO(), otherUser, theirMasterKey.PublicKey(), m.Client.UserID, m.CrossSigningKeys.SelfSigningKey.PublicKey(), "invalid_sig") - if trusted, _ := m.IsUserTrusted(context.TODO(), otherUser); trusted { - t.Error("Other user trusted before their master key has been signed with our user-signing key") - } + trusted, err = m.IsUserTrusted(context.TODO(), otherUser) + require.NoError(t, err, "Error checking if other user is trusted") + assert.False(t, trusted, "Other user trusted before their master key has been signed with our user-signing key") m.CryptoStore.PutSignature(context.TODO(), otherUser, theirMasterKey.PublicKey(), m.Client.UserID, m.CrossSigningKeys.UserSigningKey.PublicKey(), "sig2") - if trusted, _ := m.IsUserTrusted(context.TODO(), otherUser); !trusted { - t.Error("Other user not trusted while they should be") - } + trusted, err = m.IsUserTrusted(context.TODO(), otherUser) + require.NoError(t, err, "Error checking if other user is trusted") + assert.True(t, trusted, "Other user not trusted while they should be") } func TestTrustOtherDevice(t *testing.T) { @@ -120,12 +113,11 @@ func TestTrustOtherDevice(t *testing.T) { DeviceID: "theirDevice", SigningKey: id.Ed25519("theirDeviceKey"), } - if trusted, _ := m.IsUserTrusted(context.TODO(), otherUser); trusted { - t.Error("Other user trusted while they shouldn't be") - } - if m.IsDeviceTrusted(context.TODO(), theirDevice) { - t.Error("Other device trusted while it shouldn't be") - } + + trusted, err := m.IsUserTrusted(context.TODO(), otherUser) + require.NoError(t, err, "Error checking if other user is trusted") + assert.False(t, trusted, "Other user trusted while they shouldn't be") + assert.False(t, m.IsDeviceTrusted(context.TODO(), theirDevice), "Other device trusted while it shouldn't be") theirMasterKey, _ := olm.NewPKSigning() m.CryptoStore.PutCrossSigningKey(context.TODO(), otherUser, id.XSUsageMaster, theirMasterKey.PublicKey()) @@ -137,21 +129,17 @@ func TestTrustOtherDevice(t *testing.T) { m.CryptoStore.PutSignature(context.TODO(), otherUser, theirMasterKey.PublicKey(), m.Client.UserID, m.CrossSigningKeys.UserSigningKey.PublicKey(), "sig2") - if trusted, _ := m.IsUserTrusted(context.TODO(), otherUser); !trusted { - t.Error("Other user not trusted while they should be") - } + trusted, err = m.IsUserTrusted(context.TODO(), otherUser) + require.NoError(t, err, "Error checking if other user is trusted") + assert.True(t, trusted, "Other user not trusted while they should be") m.CryptoStore.PutSignature(context.TODO(), otherUser, theirSSK.PublicKey(), otherUser, theirMasterKey.PublicKey(), "sig3") - if m.IsDeviceTrusted(context.TODO(), theirDevice) { - t.Error("Other device trusted before it has been signed with user's SSK") - } + assert.False(t, m.IsDeviceTrusted(context.TODO(), theirDevice), "Other device trusted before it has been signed with user's SSK") m.CryptoStore.PutSignature(context.TODO(), otherUser, theirDevice.SigningKey, otherUser, theirSSK.PublicKey(), "sig4") - if !m.IsDeviceTrusted(context.TODO(), theirDevice) { - t.Error("Other device not trusted while it should be") - } + assert.True(t, m.IsDeviceTrusted(context.TODO(), theirDevice), "Other device not trusted after it has been signed with user's SSK") } diff --git a/crypto/machine_test.go b/crypto/machine_test.go index 59c86236..872c3ac4 100644 --- a/crypto/machine_test.go +++ b/crypto/machine_test.go @@ -36,20 +36,15 @@ func (mockStateStore) FindSharedRooms(context.Context, id.UserID) ([]id.RoomID, func newMachine(t *testing.T, userID id.UserID) *OlmMachine { client, err := mautrix.NewClient("http://localhost", userID, "token") - if err != nil { - t.Fatalf("Error creating client: %v", err) - } + require.NoError(t, err, "Error creating client") client.DeviceID = "device1" gobStore := NewMemoryStore(nil) - if err != nil { - t.Fatalf("Error creating Gob store: %v", err) - } + require.NoError(t, err, "Error creating Gob store") machine := NewOlmMachine(client, nil, gobStore, mockStateStore{}) - if err := machine.Load(context.TODO()); err != nil { - t.Fatalf("Error creating account: %v", err) - } + err = machine.Load(context.TODO()) + require.NoError(t, err, "Error creating account") return machine } @@ -82,9 +77,7 @@ func TestOlmMachineOlmMegolmSessions(t *testing.T) { // create outbound olm session for sending machine using OTK olmSession, err := machineOut.account.Internal.NewOutboundSession(machineIn.account.IdentityKey(), otk.Key) - if err != nil { - t.Errorf("Failed to create outbound olm session: %v", err) - } + require.NoError(t, err, "Error creating outbound olm session") // store sender device identity in receiving machine store machineIn.CryptoStore.PutDevices(context.TODO(), "user1", map[id.DeviceID]*id.Device{ @@ -121,29 +114,21 @@ func TestOlmMachineOlmMegolmSessions(t *testing.T) { Type: event.ToDeviceEncrypted, Sender: "user1", }, senderKey, content.Type, content.Body) - if err != nil { - t.Errorf("Error decrypting olm content: %v", err) - } + require.NoError(t, err, "Error decrypting olm ciphertext") + // store room key in new inbound group session roomKeyEvt := decrypted.Content.AsRoomKey() igs, err := NewInboundGroupSession(senderKey, signingKey, "room1", roomKeyEvt.SessionKey, 0, 0, false) - if err != nil { - t.Errorf("Error creating inbound megolm session: %v", err) - } - if err = machineIn.CryptoStore.PutGroupSession(context.TODO(), igs); err != nil { - t.Errorf("Error storing inbound megolm session: %v", err) - } + require.NoError(t, err, "Error creating inbound group session") + err = machineIn.CryptoStore.PutGroupSession(context.TODO(), igs) + require.NoError(t, err, "Error storing inbound group session") } // encrypt event with megolm session in sending machine eventContent := map[string]string{"hello": "world"} encryptedEvtContent, err := machineOut.EncryptMegolmEvent(context.TODO(), "room1", event.EventMessage, eventContent) - if err != nil { - t.Errorf("Error encrypting megolm event: %v", err) - } - if megolmOutSession.MessageCount != 1 { - t.Errorf("Megolm outbound session message count is not 1 but %d", megolmOutSession.MessageCount) - } + require.NoError(t, err, "Error encrypting megolm event") + assert.Equal(t, 1, megolmOutSession.MessageCount) encryptedEvt := &event.Event{ Content: event.Content{Parsed: encryptedEvtContent}, @@ -155,22 +140,12 @@ func TestOlmMachineOlmMegolmSessions(t *testing.T) { // decrypt event on receiving machine and confirm decryptedEvt, err := machineIn.DecryptMegolmEvent(context.TODO(), encryptedEvt) - if err != nil { - t.Errorf("Error decrypting megolm event: %v", err) - } - if decryptedEvt.Type != event.EventMessage { - t.Errorf("Expected event type %v, got %v", event.EventMessage, decryptedEvt.Type) - } - if decryptedEvt.Content.Raw["hello"] != "world" { - t.Errorf("Expected event content %v, got %v", eventContent, decryptedEvt.Content.Raw) - } + require.NoError(t, err, "Error decrypting megolm event") + assert.Equal(t, event.EventMessage, decryptedEvt.Type) + assert.Equal(t, "world", decryptedEvt.Content.Raw["hello"]) machineOut.EncryptMegolmEvent(context.TODO(), "room1", event.EventMessage, eventContent) - if megolmOutSession.Expired() { - t.Error("Megolm outbound session expired before 3rd message") - } + assert.False(t, megolmOutSession.Expired(), "Megolm outbound session expired before 3rd message") machineOut.EncryptMegolmEvent(context.TODO(), "room1", event.EventMessage, eventContent) - if !megolmOutSession.Expired() { - t.Error("Megolm outbound session not expired after 3rd message") - } + assert.True(t, megolmOutSession.Expired(), "Megolm outbound session not expired after 3rd message") } diff --git a/crypto/store_test.go b/crypto/store_test.go index a7c4d75a..8aeae7af 100644 --- a/crypto/store_test.go +++ b/crypto/store_test.go @@ -13,6 +13,7 @@ import ( "testing" _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mau.fi/util/dbutil" @@ -29,22 +30,14 @@ const groupSession = "9ZbsRqJuETbjnxPpKv29n3dubP/m5PSLbr9I9CIWS2O86F/Og1JZXhqT+4 func getCryptoStores(t *testing.T) map[string]Store { rawDB, err := sql.Open("sqlite3", ":memory:?_busy_timeout=5000") - if err != nil { - t.Fatalf("Error opening db: %v", err) - } + require.NoError(t, err, "Error opening raw database") db, err := dbutil.NewWithDB(rawDB, "sqlite3") - if err != nil { - t.Fatalf("Error opening db: %v", err) - } + require.NoError(t, err, "Error creating database wrapper") sqlStore := NewSQLCryptoStore(db, nil, "accid", id.DeviceID("dev"), []byte("test")) - if err = sqlStore.DB.Upgrade(context.TODO()); err != nil { - t.Fatalf("Error creating tables: %v", err) - } + err = sqlStore.DB.Upgrade(context.TODO()) + require.NoError(t, err, "Error upgrading database") gobStore := NewMemoryStore(nil) - if err != nil { - t.Fatalf("Error creating Gob store: %v", err) - } return map[string]Store{ "sql": sqlStore, @@ -56,9 +49,10 @@ func TestPutNextBatch(t *testing.T) { stores := getCryptoStores(t) store := stores["sql"].(*SQLCryptoStore) store.PutNextBatch(context.Background(), "batch1") - if batch, _ := store.GetNextBatch(context.Background()); batch != "batch1" { - t.Errorf("Expected batch1, got %v", batch) - } + + batch, err := store.GetNextBatch(context.Background()) + require.NoError(t, err, "Error retrieving next batch") + assert.Equal(t, "batch1", batch) } func TestPutAccount(t *testing.T) { @@ -68,15 +62,9 @@ func TestPutAccount(t *testing.T) { acc := NewOlmAccount() store.PutAccount(context.TODO(), acc) retrieved, err := store.GetAccount(context.TODO()) - if err != nil { - t.Fatalf("Error retrieving account: %v", err) - } - if acc.IdentityKey() != retrieved.IdentityKey() { - t.Errorf("Stored identity key %v, got %v", acc.IdentityKey(), retrieved.IdentityKey()) - } - if acc.SigningKey() != retrieved.SigningKey() { - t.Errorf("Stored signing key %v, got %v", acc.SigningKey(), retrieved.SigningKey()) - } + require.NoError(t, err, "Error retrieving account") + assert.Equal(t, acc.IdentityKey(), retrieved.IdentityKey(), "Identity key does not match") + assert.Equal(t, acc.SigningKey(), retrieved.SigningKey(), "Signing key does not match") }) } } @@ -86,18 +74,26 @@ func TestValidateMessageIndex(t *testing.T) { for storeName, store := range stores { t.Run(storeName, func(t *testing.T) { acc := NewOlmAccount() - if ok, _ := store.ValidateMessageIndex(context.TODO(), acc.IdentityKey(), "sess1", "event1", 0, 1000); !ok { - t.Error("First message not validated successfully") - } - if ok, _ := store.ValidateMessageIndex(context.TODO(), acc.IdentityKey(), "sess1", "event1", 0, 1001); ok { - t.Error("First message validated successfully after changing timestamp") - } - if ok, _ := store.ValidateMessageIndex(context.TODO(), acc.IdentityKey(), "sess1", "event2", 0, 1000); ok { - t.Error("First message validated successfully after changing event ID") - } - if ok, _ := store.ValidateMessageIndex(context.TODO(), acc.IdentityKey(), "sess1", "event1", 0, 1000); !ok { - t.Error("First message not validated successfully for a second time") - } + + // First message should validate successfully + ok, err := store.ValidateMessageIndex(context.TODO(), acc.IdentityKey(), "sess1", "event1", 0, 1000) + require.NoError(t, err, "Error validating message index") + assert.True(t, ok, "First message validation should be valid") + + // Edit the timestamp and ensure validate fails + ok, err = store.ValidateMessageIndex(context.TODO(), acc.IdentityKey(), "sess1", "event1", 0, 1001) + require.NoError(t, err, "Error validating message index after timestamp change") + assert.False(t, ok, "First message validation should fail after timestamp change") + + // Edit the event ID and ensure validate fails + ok, err = store.ValidateMessageIndex(context.TODO(), acc.IdentityKey(), "sess1", "event2", 0, 1000) + require.NoError(t, err, "Error validating message index after event ID change") + assert.False(t, ok, "First message validation should fail after event ID change") + + // Validate again with the original parameters and ensure that it still passes + ok, err = store.ValidateMessageIndex(context.TODO(), acc.IdentityKey(), "sess1", "event1", 0, 1000) + require.NoError(t, err, "Error validating message index") + assert.True(t, ok, "First message validation should be valid") }) } } @@ -106,43 +102,26 @@ func TestStoreOlmSession(t *testing.T) { stores := getCryptoStores(t) for storeName, store := range stores { t.Run(storeName, func(t *testing.T) { - if store.HasSession(context.TODO(), olmSessID) { - t.Error("Found Olm session before inserting it") - } + require.False(t, store.HasSession(context.TODO(), olmSessID), "Found Olm session before inserting it") + olmInternal, err := olm.SessionFromPickled([]byte(olmPickled), []byte("test")) - if err != nil { - t.Fatalf("Error creating internal Olm session: %v", err) - } + require.NoError(t, err, "Error creating internal Olm session") olmSess := OlmSession{ id: olmSessID, Internal: olmInternal, } err = store.AddSession(context.TODO(), olmSessID, &olmSess) - if err != nil { - t.Errorf("Error storing Olm session: %v", err) - } - if !store.HasSession(context.TODO(), olmSessID) { - t.Error("Not found Olm session after inserting it") - } + require.NoError(t, err, "Error storing Olm session") + assert.True(t, store.HasSession(context.TODO(), olmSessID), "Olm session not found after inserting it") retrieved, err := store.GetLatestSession(context.TODO(), olmSessID) - if err != nil { - t.Errorf("Failed retrieving Olm session: %v", err) - } - - if retrieved.ID() != olmSessID { - t.Errorf("Expected session ID to be %v, got %v", olmSessID, retrieved.ID()) - } + require.NoError(t, err, "Error retrieving Olm session") + assert.EqualValues(t, olmSessID, retrieved.ID()) pickled, err := retrieved.Internal.Pickle([]byte("test")) - if err != nil { - t.Fatalf("Error pickling Olm session: %v", err) - } - - if string(pickled) != olmPickled { - t.Error("Pickled Olm session does not match original") - } + require.NoError(t, err, "Error pickling Olm session") + assert.EqualValues(t, pickled, olmPickled, "Pickled Olm session does not match original") }) } } @@ -154,9 +133,7 @@ func TestStoreMegolmSession(t *testing.T) { acc := NewOlmAccount() internal, err := olm.InboundGroupSessionFromPickled([]byte(groupSession), []byte("test")) - if err != nil { - t.Fatalf("Error creating internal inbound group session: %v", err) - } + require.NoError(t, err, "Error creating internal inbound group session") igs := &InboundGroupSession{ Internal: internal, @@ -166,20 +143,14 @@ func TestStoreMegolmSession(t *testing.T) { } err = store.PutGroupSession(context.TODO(), igs) - if err != nil { - t.Errorf("Error storing inbound group session: %v", err) - } + require.NoError(t, err, "Error storing inbound group session") retrieved, err := store.GetGroupSession(context.TODO(), "room1", igs.ID()) - if err != nil { - t.Errorf("Error retrieving inbound group session: %v", err) - } + require.NoError(t, err, "Error retrieving inbound group session") - if pickled, err := retrieved.Internal.Pickle([]byte("test")); err != nil { - t.Fatalf("Error pickling inbound group session: %v", err) - } else if string(pickled) != groupSession { - t.Error("Pickled inbound group session does not match original") - } + pickled, err := retrieved.Internal.Pickle([]byte("test")) + require.NoError(t, err, "Error pickling inbound group session") + assert.EqualValues(t, pickled, groupSession, "Pickled inbound group session does not match original") }) } } @@ -189,40 +160,24 @@ func TestStoreOutboundMegolmSession(t *testing.T) { for storeName, store := range stores { t.Run(storeName, func(t *testing.T) { sess, err := store.GetOutboundGroupSession(context.TODO(), "room1") - if sess != nil { - t.Error("Got outbound session before inserting") - } - if err != nil { - t.Errorf("Error retrieving outbound session: %v", err) - } + require.NoError(t, err, "Error retrieving outbound session") + require.Nil(t, sess, "Got outbound session before inserting") outbound, err := NewOutboundGroupSession("room1", nil) require.NoError(t, err) err = store.AddOutboundGroupSession(context.TODO(), outbound) - if err != nil { - t.Errorf("Error inserting outbound session: %v", err) - } + require.NoError(t, err, "Error inserting outbound session") sess, err = store.GetOutboundGroupSession(context.TODO(), "room1") - if sess == nil { - t.Error("Did not get outbound session after inserting") - } - if err != nil { - t.Errorf("Error retrieving outbound session: %v", err) - } + require.NoError(t, err, "Error retrieving outbound session") + assert.NotNil(t, sess, "Did not get outbound session after inserting") err = store.RemoveOutboundGroupSession(context.TODO(), "room1") - if err != nil { - t.Errorf("Error deleting outbound session: %v", err) - } + require.NoError(t, err, "Error deleting outbound session") sess, err = store.GetOutboundGroupSession(context.TODO(), "room1") - if sess != nil { - t.Error("Got outbound session after deleting") - } - if err != nil { - t.Errorf("Error retrieving outbound session: %v", err) - } + require.NoError(t, err, "Error retrieving outbound session after deletion") + assert.Nil(t, sess, "Got outbound session after deleting") }) } } @@ -244,58 +199,41 @@ func TestStoreOutboundMegolmSessionSharing(t *testing.T) { t.Run(storeName, func(t *testing.T) { device := resetDevice() err := store.PutDevice(context.TODO(), "user1", device) - if err != nil { - t.Errorf("Error storing devices: %v", err) - } + require.NoError(t, err, "Error storing device") shared, err := store.IsOutboundGroupSessionShared(context.TODO(), device.UserID, device.IdentityKey, "session1") - if err != nil { - t.Errorf("Error checking if outbound group session is shared: %v", err) - } else if shared { - t.Errorf("Outbound group session shared when it shouldn't") - } + require.NoError(t, err, "Error checking if outbound group session is shared") + assert.False(t, shared, "Outbound group session should not be shared initially") err = store.MarkOutboundGroupSessionShared(context.TODO(), device.UserID, device.IdentityKey, "session1") - if err != nil { - t.Errorf("Error marking outbound group session as shared: %v", err) - } + require.NoError(t, err, "Error marking outbound group session as shared") shared, err = store.IsOutboundGroupSessionShared(context.TODO(), device.UserID, device.IdentityKey, "session1") - if err != nil { - t.Errorf("Error checking if outbound group session is shared: %v", err) - } else if !shared { - t.Errorf("Outbound group session not shared when it should") - } + require.NoError(t, err, "Error checking if outbound group session is shared") + assert.True(t, shared, "Outbound group session should be shared after marking it as such") device = resetDevice() err = store.PutDevice(context.TODO(), "user1", device) - if err != nil { - t.Errorf("Error storing devices: %v", err) - } + require.NoError(t, err, "Error storing device after resetting") shared, err = store.IsOutboundGroupSessionShared(context.TODO(), device.UserID, device.IdentityKey, "session1") - if err != nil { - t.Errorf("Error checking if outbound group session is shared: %v", err) - } else if shared { - t.Errorf("Outbound group session shared when it shouldn't") - } + require.NoError(t, err, "Error checking if outbound group session is shared") + assert.False(t, shared, "Outbound group session should not be shared after resetting device") }) } } func TestStoreDevices(t *testing.T) { + devicesToCreate := 17 stores := getCryptoStores(t) for storeName, store := range stores { t.Run(storeName, func(t *testing.T) { outdated, err := store.GetOutdatedTrackedUsers(context.TODO()) - if err != nil { - t.Errorf("Error filtering tracked users: %v", err) - } - if len(outdated) > 0 { - t.Errorf("Got %d outdated tracked users when expected none", len(outdated)) - } + require.NoError(t, err, "Error filtering tracked users") + assert.Empty(t, outdated, "Expected no outdated tracked users initially") + deviceMap := make(map[id.DeviceID]*id.Device) - for i := 0; i < 17; i++ { + for i := 0; i < devicesToCreate; i++ { iStr := strconv.Itoa(i) acc := NewOlmAccount() deviceMap[id.DeviceID("dev"+iStr)] = &id.Device{ @@ -306,59 +244,33 @@ func TestStoreDevices(t *testing.T) { } } err = store.PutDevices(context.TODO(), "user1", deviceMap) - if err != nil { - t.Errorf("Error storing devices: %v", err) - } + require.NoError(t, err, "Error storing devices") devs, err := store.GetDevices(context.TODO(), "user1") - if err != nil { - t.Errorf("Error getting devices: %v", err) - } - if len(devs) != 17 { - t.Errorf("Stored 17 devices, got back %v", len(devs)) - } - if devs["dev0"].IdentityKey != deviceMap["dev0"].IdentityKey { - t.Errorf("First device identity key does not match") - } - if devs["dev16"].IdentityKey != deviceMap["dev16"].IdentityKey { - t.Errorf("Last device identity key does not match") - } + require.NoError(t, err, "Error getting devices") + assert.Len(t, devs, devicesToCreate, "Expected to get %d devices back", devicesToCreate) + assert.Equal(t, deviceMap, devs, "Stored devices do not match retrieved devices") filtered, err := store.FilterTrackedUsers(context.TODO(), []id.UserID{"user0", "user1", "user2"}) - if err != nil { - t.Errorf("Error filtering tracked users: %v", err) - } else if len(filtered) != 1 || filtered[0] != "user1" { - t.Errorf("Expected to get 'user1' from filter, got %v", filtered) - } + require.NoError(t, err, "Error filtering tracked users") + assert.Equal(t, []id.UserID{"user1"}, filtered, "Expected to get 'user1' from filter") outdated, err = store.GetOutdatedTrackedUsers(context.TODO()) - if err != nil { - t.Errorf("Error filtering tracked users: %v", err) - } - if len(outdated) > 0 { - t.Errorf("Got %d outdated tracked users when expected none", len(outdated)) - } + require.NoError(t, err, "Error filtering tracked users") + assert.Empty(t, outdated, "Expected no outdated tracked users after initial storage") + err = store.MarkTrackedUsersOutdated(context.TODO(), []id.UserID{"user0", "user1"}) - if err != nil { - t.Errorf("Error marking tracked users outdated: %v", err) - } + require.NoError(t, err, "Error marking tracked users outdated") + outdated, err = store.GetOutdatedTrackedUsers(context.TODO()) - if err != nil { - t.Errorf("Error filtering tracked users: %v", err) - } - if len(outdated) != 1 || outdated[0] != id.UserID("user1") { - t.Errorf("Got outdated tracked users %v when expected 'user1'", outdated) - } + require.NoError(t, err, "Error filtering tracked users") + assert.Equal(t, []id.UserID{"user1"}, outdated, "Expected 'user1' to be marked as outdated") + err = store.PutDevices(context.TODO(), "user1", deviceMap) - if err != nil { - t.Errorf("Error storing devices: %v", err) - } + require.NoError(t, err, "Error storing devices again") + outdated, err = store.GetOutdatedTrackedUsers(context.TODO()) - if err != nil { - t.Errorf("Error filtering tracked users: %v", err) - } - if len(outdated) > 0 { - t.Errorf("Got outdated tracked users %v when expected none", outdated) - } + require.NoError(t, err, "Error filtering tracked users") + assert.Empty(t, outdated, "Expected no outdated tracked users after re-storing devices") }) } } @@ -369,16 +281,11 @@ func TestStoreSecrets(t *testing.T) { t.Run(storeName, func(t *testing.T) { storedSecret := "trustno1" err := store.PutSecret(context.TODO(), id.SecretMegolmBackupV1, storedSecret) - if err != nil { - t.Errorf("Error storing secret: %v", err) - } + require.NoError(t, err, "Error storing secret") secret, err := store.GetSecret(context.TODO(), id.SecretMegolmBackupV1) - if err != nil { - t.Errorf("Error storing secret: %v", err) - } else if secret != storedSecret { - t.Errorf("Stored secret did not match: '%s' != '%s'", secret, storedSecret) - } + require.NoError(t, err, "Error retrieving secret") + assert.Equal(t, storedSecret, secret, "Retrieved secret does not match stored secret") }) } } diff --git a/crypto/utils/utils_test.go b/crypto/utils/utils_test.go index c4f01a68..b12fd9e2 100644 --- a/crypto/utils/utils_test.go +++ b/crypto/utils/utils_test.go @@ -9,6 +9,9 @@ package utils import ( "encoding/base64" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAES256Ctr(t *testing.T) { @@ -16,9 +19,7 @@ func TestAES256Ctr(t *testing.T) { key, iv := GenAttachmentA256CTR() enc := XorA256CTR([]byte(expected), key, iv) dec := XorA256CTR(enc, key, iv) - if string(dec) != expected { - t.Errorf("Expected decrypted using generated key/iv to be `%v`, got %v", expected, string(dec)) - } + assert.EqualValues(t, expected, dec, "Decrypted text should match original") var key2 [AESCTRKeyLength]byte var iv2 [AESCTRIVLength]byte @@ -29,9 +30,7 @@ func TestAES256Ctr(t *testing.T) { iv2[i] = byte(i) + 32 } dec2 := XorA256CTR([]byte{0x29, 0xc3, 0xff, 0x02, 0x21, 0xaf, 0x67, 0x73, 0x6e, 0xad, 0x9d}, key2, iv2) - if string(dec2) != expected { - t.Errorf("Expected decrypted using constant key/iv to be `%v`, got %v", expected, string(dec2)) - } + assert.EqualValues(t, expected, dec2, "Decrypted text with constant key/iv should match original") } func TestPBKDF(t *testing.T) { @@ -42,9 +41,7 @@ func TestPBKDF(t *testing.T) { key := PBKDF2SHA512([]byte("Hello world"), salt, 1000, 256) expected := "ffk9YdbVE1cgqOWgDaec0lH+rJzO+MuCcxpIn3Z6D0E=" keyB64 := base64.StdEncoding.EncodeToString([]byte(key)) - if keyB64 != expected { - t.Errorf("Expected base64 of generated key to be `%v`, got `%v`", expected, keyB64) - } + assert.Equal(t, expected, keyB64) } func TestDecodeSSSSKey(t *testing.T) { @@ -53,13 +50,10 @@ func TestDecodeSSSSKey(t *testing.T) { expected := "QCFDrXZYLEFnwf4NikVm62rYGJS2mNBEmAWLC3CgNPw=" decodedB64 := base64.StdEncoding.EncodeToString(decoded[:]) - if expected != decodedB64 { - t.Errorf("Expected decoded recovery key b64 to be `%v`, got `%v`", expected, decodedB64) - } + assert.Equal(t, expected, decodedB64) - if encoded := EncodeBase58RecoveryKey(decoded); encoded != recoveryKey { - t.Errorf("Expected recovery key to be `%v`, got `%v`", recoveryKey, encoded) - } + encoded := EncodeBase58RecoveryKey(decoded) + assert.Equal(t, recoveryKey, encoded) } func TestKeyDerivationAndHMAC(t *testing.T) { @@ -69,15 +63,11 @@ func TestKeyDerivationAndHMAC(t *testing.T) { aesKey, hmacKey := DeriveKeysSHA256(decoded[:], "m.cross_signing.master") ciphertextBytes, err := base64.StdEncoding.DecodeString("Fx16KlJ9vkd3Dd6CafIq5spaH5QmK5BALMzbtFbQznG2j1VARKK+klc4/Qo=") - if err != nil { - t.Error(err) - } + require.NoError(t, err) calcMac := HMACSHA256B64(ciphertextBytes, hmacKey) expectedMac := "0DABPNIZsP9iTOh1o6EM0s7BfHHXb96dN7Eca88jq2E" - if calcMac != expectedMac { - t.Errorf("Expected MAC `%v`, got `%v`", expectedMac, calcMac) - } + assert.Equal(t, expectedMac, calcMac) var ivBytes [AESCTRIVLength]byte decodedIV, _ := base64.StdEncoding.DecodeString("zxT/W5LpZ0Q819pfju6hZw==") @@ -85,7 +75,5 @@ func TestKeyDerivationAndHMAC(t *testing.T) { decrypted := string(XorA256CTR(ciphertextBytes, aesKey, ivBytes)) expectedDec := "Ec8eZDyvVkO3EDsEG6ej5c0cCHnX7PINqFXZjnaTV2s=" - if expectedDec != decrypted { - t.Errorf("Expected decrypted text to be `%v`, got `%v`", expectedDec, decrypted) - } + assert.Equal(t, expectedDec, decrypted) } From e27e00b391746627eb000da85b5e38704bc2046a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 3 Aug 2025 11:51:47 +0300 Subject: [PATCH 211/581] id: move room version from event package and add flags --- bridgev2/portal.go | 2 +- bridgev2/space.go | 2 +- bridgev2/user.go | 2 +- event/state.go | 43 ++++---- id/roomversion.go | 265 +++++++++++++++++++++++++++++++++++++++++++++ requests.go | 2 +- responses.go | 14 +-- 7 files changed, 297 insertions(+), 33 deletions(-) create mode 100644 id/roomversion.go diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 5aae45e9..25865080 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4296,7 +4296,7 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo IsDirect: portal.RoomType == database.RoomTypeDM, PowerLevelOverride: powerLevels, BeeperLocalRoomID: portal.Bridge.Matrix.GenerateDeterministicRoomID(portal.PortalKey), - RoomVersion: event.RoomV11, + RoomVersion: id.RoomV11, } autoJoinInvites := portal.Bridge.Matrix.GetCapabilities().AutoJoinInvites if autoJoinInvites { diff --git a/bridgev2/space.go b/bridgev2/space.go index ccb74b26..ae9013cb 100644 --- a/bridgev2/space.go +++ b/bridgev2/space.go @@ -164,7 +164,7 @@ func (ul *UserLogin) GetSpaceRoom(ctx context.Context) (id.RoomID, error) { ul.UserMXID: 50, }, }, - RoomVersion: event.RoomV11, + RoomVersion: id.RoomV11, Invite: []id.UserID{ul.UserMXID}, } if autoJoin { diff --git a/bridgev2/user.go b/bridgev2/user.go index 350cecd1..87ced1d7 100644 --- a/bridgev2/user.go +++ b/bridgev2/user.go @@ -225,7 +225,7 @@ func (user *User) GetManagementRoom(ctx context.Context) (id.RoomID, error) { user.MXID: 50, }, }, - RoomVersion: event.RoomV11, + RoomVersion: id.RoomV11, Invite: []id.UserID{user.MXID}, IsDirect: true, } diff --git a/event/state.go b/event/state.go index 83390c90..44a45a57 100644 --- a/event/state.go +++ b/event/state.go @@ -75,30 +75,32 @@ type Predecessor struct { EventID id.EventID `json:"event_id"` } -type RoomVersion string +// Deprecated: use id.RoomVersion instead +type RoomVersion = id.RoomVersion +// Deprecated: use id.RoomVX constants instead const ( - RoomV1 RoomVersion = "1" - RoomV2 RoomVersion = "2" - RoomV3 RoomVersion = "3" - RoomV4 RoomVersion = "4" - RoomV5 RoomVersion = "5" - RoomV6 RoomVersion = "6" - RoomV7 RoomVersion = "7" - RoomV8 RoomVersion = "8" - RoomV9 RoomVersion = "9" - RoomV10 RoomVersion = "10" - RoomV11 RoomVersion = "11" - RoomV12 RoomVersion = "12" + RoomV1 = id.RoomV1 + RoomV2 = id.RoomV2 + RoomV3 = id.RoomV3 + RoomV4 = id.RoomV4 + RoomV5 = id.RoomV5 + RoomV6 = id.RoomV6 + RoomV7 = id.RoomV7 + RoomV8 = id.RoomV8 + RoomV9 = id.RoomV9 + RoomV10 = id.RoomV10 + RoomV11 = id.RoomV11 + RoomV12 = id.RoomV12 ) // CreateEventContent represents the content of a m.room.create state event. // https://spec.matrix.org/v1.2/client-server-api/#mroomcreate type CreateEventContent struct { - Type RoomType `json:"type,omitempty"` - Federate *bool `json:"m.federate,omitempty"` - RoomVersion RoomVersion `json:"room_version,omitempty"` - Predecessor *Predecessor `json:"predecessor,omitempty"` + Type RoomType `json:"type,omitempty"` + Federate *bool `json:"m.federate,omitempty"` + RoomVersion id.RoomVersion `json:"room_version,omitempty"` + Predecessor *Predecessor `json:"predecessor,omitempty"` // Room v12+ only AdditionalCreators []id.UserID `json:"additional_creators,omitempty"` @@ -108,13 +110,10 @@ type CreateEventContent struct { } func (cec *CreateEventContent) SupportsCreatorPower() bool { - switch cec.RoomVersion { - case "", RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11: + if cec == nil { return false - default: - // Assume anything except known old versions supports creator power. - return true } + return cec.RoomVersion.PrivilegedRoomCreators() } // JoinRule specifies how open a room is to new members. diff --git a/id/roomversion.go b/id/roomversion.go new file mode 100644 index 00000000..578c10bd --- /dev/null +++ b/id/roomversion.go @@ -0,0 +1,265 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package id + +import ( + "errors" + "fmt" + "slices" +) + +type RoomVersion string + +const ( + RoomV0 RoomVersion = "" // No room version, used for rooms created before room versions were introduced, equivalent to v1 + RoomV1 RoomVersion = "1" + RoomV2 RoomVersion = "2" + RoomV3 RoomVersion = "3" + RoomV4 RoomVersion = "4" + RoomV5 RoomVersion = "5" + RoomV6 RoomVersion = "6" + RoomV7 RoomVersion = "7" + RoomV8 RoomVersion = "8" + RoomV9 RoomVersion = "9" + RoomV10 RoomVersion = "10" + RoomV11 RoomVersion = "11" + RoomV12 RoomVersion = "12" +) + +func (rv RoomVersion) Equals(versions ...RoomVersion) bool { + return slices.Contains(versions, rv) +} + +func (rv RoomVersion) NotEquals(versions ...RoomVersion) bool { + return !rv.Equals(versions...) +} + +var ErrUnknownRoomVersion = errors.New("unknown room version") + +func (rv RoomVersion) unknownVersionError() error { + return fmt.Errorf("%w %s", ErrUnknownRoomVersion, rv) +} + +func (rv RoomVersion) IsKnown() bool { + switch rv { + case RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11, RoomV12: + return true + default: + return false + } +} + +type StateResVersion int + +const ( + // StateResV1 is the original state resolution algorithm. + StateResV1 StateResVersion = 0 + // StateResV2 is state resolution v2 introduced by https://github.com/matrix-org/matrix-spec-proposals/pull/1759 + StateResV2 StateResVersion = 1 + // StateResV2_1 is state resolution v2.1 introduced by https://github.com/matrix-org/matrix-spec-proposals/pull/4297 + StateResV2_1 StateResVersion = 2 +) + +// StateResVersion returns the version of the state resolution algorithm used by this room version. +func (rv RoomVersion) StateResVersion() StateResVersion { + switch rv { + case RoomV0, RoomV1: + return StateResV1 + case RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11: + return StateResV2 + case RoomV12: + return StateResV2_1 + default: + panic(rv.unknownVersionError()) + } +} + +type EventIDFormat int + +const ( + // EventIDFormatCustom is the original format used by room v1 and v2. + // Event IDs in this format are an arbitrary string followed by a colon and the server name. + EventIDFormatCustom EventIDFormat = 0 + // EventIDFormatBase64 is the format used by room v3 introduced by https://github.com/matrix-org/matrix-spec-proposals/pull/1659. + // Event IDs in this format are the standard unpadded base64-encoded SHA256 reference hash of the event. + EventIDFormatBase64 EventIDFormat = 1 + // EventIDFormatURLSafeBase64 is the format used by room v4 and later introduced by https://github.com/matrix-org/matrix-spec-proposals/pull/2002. + // Event IDs in this format are the url-safe unpadded base64-encoded SHA256 reference hash of the event. + EventIDFormatURLSafeBase64 EventIDFormat = 2 +) + +// EventIDFormat returns the format of event IDs used by this room version. +func (rv RoomVersion) EventIDFormat() EventIDFormat { + switch rv { + case RoomV0, RoomV1, RoomV2: + return EventIDFormatCustom + case RoomV3: + return EventIDFormatBase64 + default: + return EventIDFormatURLSafeBase64 + } +} + +///////////////////// +// Room v5 changes // +///////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/2077 + +// EnforceSigningKeyValidity returns true if the `valid_until_ts` field of federation signing keys +// must be enforced on received events. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2076 +func (rv RoomVersion) EnforceSigningKeyValidity() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4) +} + +///////////////////// +// Room v6 changes // +///////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/2240 + +// SpecialCasedAliasesAuth returns true if the `m.room.aliases` event authorization is special cased +// to only always allow servers to modify the state event with their own server name as state key. +// This also implies that the `aliases` field is protected from redactions. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2432 +func (rv RoomVersion) SpecialCasedAliasesAuth() bool { + return rv.Equals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5) +} + +// ForbidFloatsAndBigInts returns true if floats and integers greater than 2^53-1 or lower than -2^53+1 are forbidden everywhere. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2540 +func (rv RoomVersion) ForbidFloatsAndBigInts() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5) +} + +// NotificationsPowerLevels returns true if the `notifications` field in `m.room.power_levels` is validated in event auth. +// However, the field is not protected from redactions. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2209 +func (rv RoomVersion) NotificationsPowerLevels() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5) +} + +///////////////////// +// Room v7 changes // +///////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/2998 + +// Knocks returns true if the `knock` join rule is supported. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2403 +func (rv RoomVersion) Knocks() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6) +} + +///////////////////// +// Room v8 changes // +///////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/3289 + +// RestrictedJoins returns true if the `restricted` join rule is supported. +// This also implies that the `allow` field in the `m.room.join_rules` event is supported and protected from redactions. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/3083 +func (rv RoomVersion) RestrictedJoins() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7) +} + +///////////////////// +// Room v9 changes // +///////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/3375 + +// RestrictedJoinsFix returns true if the `join_authorised_via_users_server` field in `m.room.member` events is protected from redactions. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/3375 +func (rv RoomVersion) RestrictedJoinsFix() bool { + return rv.RestrictedJoins() && rv != RoomV8 +} + +////////////////////// +// Room v10 changes // +////////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/3604 + +// ValidatePowerLevelInts returns true if the known values in `m.room.power_levels` must be integers (and not strings). +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/3667 +func (rv RoomVersion) ValidatePowerLevelInts() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9) +} + +// KnockRestricted returns true if the `knock_restricted` join rule is supported. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/3787 +func (rv RoomVersion) KnockRestricted() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9) +} + +////////////////////// +// Room v11 changes // +////////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/3820 + +// CreatorInContent returns true if the `m.room.create` event has a `creator` field in content. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2175 +func (rv RoomVersion) CreatorInContent() bool { + return rv.Equals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10) +} + +// RedactsInContent returns true if the `m.room.redaction` event has the `redacts` field in content instead of at the top level. +// The redaction protection is also moved from the top level to the content field. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2174 +// (and https://github.com/matrix-org/matrix-spec-proposals/pull/2176 for the redaction protection). +func (rv RoomVersion) RedactsInContent() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10) +} + +// UpdatedRedactionRules returns true if various updates to the redaction algorithm are applied. +// +// Specifically: +// +// * the `membership`, `origin`, and `prev_state` fields at the top level of all events are no longer protected. +// * the entire content of `m.room.create` is protected. +// * the `redacts` field in `m.room.redaction` content is protected instead of the top-level field. +// * the `m.room.power_levels` event protects the `invite` field in content. +// * the `signed` field inside the `third_party_invite` field in content of `m.room.member` events is protected. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2176, +// https://github.com/matrix-org/matrix-spec-proposals/pull/3821, and +// https://github.com/matrix-org/matrix-spec-proposals/pull/3989 +func (rv RoomVersion) UpdatedRedactionRules() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10) +} + +////////////////////// +// Room v12 changes // +////////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/4304 + +// Return value of StateResVersion was changed to StateResV2_1 + +// PrivilegedRoomCreators returns true if the creator(s) of a room always have infinite power level. +// This also implies that the `m.room.create` event has an `additional_creators` field, +// and that the creators can't be present in the `m.room.power_levels` event. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/4289 +func (rv RoomVersion) PrivilegedRoomCreators() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11) +} + +// RoomIDIsCreateEventID returns true if the ID of rooms is the same as the ID of the `m.room.create` event. +// This also implies that `m.room.create` events do not have a `room_id` field. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/4291 +func (rv RoomVersion) RoomIDIsCreateEventID() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11) +} diff --git a/requests.go b/requests.go index 17eda7d2..eade0757 100644 --- a/requests.go +++ b/requests.go @@ -120,7 +120,7 @@ type ReqCreateRoom struct { InitialState []*event.Event `json:"initial_state,omitempty"` Preset string `json:"preset,omitempty"` IsDirect bool `json:"is_direct,omitempty"` - RoomVersion event.RoomVersion `json:"room_version,omitempty"` + RoomVersion id.RoomVersion `json:"room_version,omitempty"` PowerLevelOverride *event.PowerLevelsEventContent `json:"power_level_content_override,omitempty"` diff --git a/responses.go b/responses.go index 2e8005d4..27d96ffe 100644 --- a/responses.go +++ b/responses.go @@ -221,14 +221,14 @@ type RespMutualRooms struct { type RespRoomSummary struct { PublicRoomInfo - Membership event.Membership `json:"membership,omitempty"` - RoomVersion event.RoomVersion `json:"room_version,omitempty"` - Encryption id.Algorithm `json:"encryption,omitempty"` - AllowedRoomIDs []id.RoomID `json:"allowed_room_ids,omitempty"` + Membership event.Membership `json:"membership,omitempty"` + RoomVersion id.RoomVersion `json:"room_version,omitempty"` + Encryption id.Algorithm `json:"encryption,omitempty"` + AllowedRoomIDs []id.RoomID `json:"allowed_room_ids,omitempty"` - UnstableRoomVersion event.RoomVersion `json:"im.nheko.summary.room_version,omitempty"` - UnstableRoomVersionOld event.RoomVersion `json:"im.nheko.summary.version,omitempty"` - UnstableEncryption id.Algorithm `json:"im.nheko.summary.encryption,omitempty"` + UnstableRoomVersion id.RoomVersion `json:"im.nheko.summary.room_version,omitempty"` + UnstableRoomVersionOld id.RoomVersion `json:"im.nheko.summary.version,omitempty"` + UnstableEncryption id.Algorithm `json:"im.nheko.summary.encryption,omitempty"` } // RespRegisterAvailable is the JSON response for https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv3registeravailable From 1215f6237ee7d517f36d5159298ad6fa65391c5e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 3 Aug 2025 15:16:24 +0300 Subject: [PATCH 212/581] event: fix json tag in power levels --- event/powerlevels.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/event/powerlevels.go b/event/powerlevels.go index 79dbd1f3..50df2c1f 100644 --- a/event/powerlevels.go +++ b/event/powerlevels.go @@ -39,7 +39,7 @@ type PowerLevelsEventContent struct { // This is not a part of power levels, it's added by mautrix-go internally in certain places // in order to detect creator power accurately. - CreateEvent *Event `json:"-,omitempty"` + CreateEvent *Event `json:"-"` } func (pl *PowerLevelsEventContent) Clone() *PowerLevelsEventContent { From 7a791e908c99fc61f378d47a92650783fa3cd316 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 3 Aug 2025 20:37:11 +0300 Subject: [PATCH 213/581] federation: extract VerifyJSON into subpackage --- federation/client.go | 3 +- federation/signingkey.go | 53 +++------------------------- federation/signutil/verify.go | 65 +++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 50 deletions(-) create mode 100644 federation/signutil/verify.go diff --git a/federation/client.go b/federation/client.go index 7c460d44..8f454516 100644 --- a/federation/client.go +++ b/federation/client.go @@ -21,6 +21,7 @@ import ( "go.mau.fi/util/jsontime" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/federation/signutil" "maunium.net/go/mautrix/id" ) @@ -404,7 +405,7 @@ func (r *signableRequest) Verify(key id.SigningKey, sig string) error { if err != nil { return fmt.Errorf("failed to marshal data: %w", err) } - return VerifyJSONRaw(key, sig, message) + return signutil.VerifyJSONRaw(key, sig, message) } func (r *signableRequest) Sign(key *SigningKey) (string, error) { diff --git a/federation/signingkey.go b/federation/signingkey.go index 0ae6a571..a4ad9679 100644 --- a/federation/signingkey.go +++ b/federation/signingkey.go @@ -10,17 +10,15 @@ import ( "crypto/ed25519" "encoding/base64" "encoding/json" - "errors" "fmt" "strings" "time" - "github.com/tidwall/gjson" "github.com/tidwall/sjson" - "go.mau.fi/util/exgjson" "go.mau.fi/util/jsontime" "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/federation/signutil" "maunium.net/go/mautrix/id" ) @@ -35,8 +33,8 @@ type SigningKey struct { // // The output of this function can be parsed back into a [SigningKey] using the [ParseSynapseKey] function. func (sk *SigningKey) SynapseString() string { - alg, id := sk.ID.Parse() - return fmt.Sprintf("%s %s %s", alg, id, base64.RawStdEncoding.EncodeToString(sk.Priv.Seed())) + alg, keyID := sk.ID.Parse() + return fmt.Sprintf("%s %s %s", alg, keyID, base64.RawStdEncoding.EncodeToString(sk.Priv.Seed())) } // ParseSynapseKey parses a Synapse-compatible private key string into a SigningKey. @@ -100,56 +98,13 @@ func (skr *ServerKeyResponse) HasKey(keyID id.KeyID) bool { func (skr *ServerKeyResponse) VerifySelfSignature() error { for keyID, key := range skr.VerifyKeys { - if err := VerifyJSON(skr.ServerName, keyID, key.Key, skr.Raw); err != nil { + if err := signutil.VerifyJSON(skr.ServerName, keyID, key.Key, skr.Raw); err != nil { return fmt.Errorf("failed to verify self signature for key %s: %w", keyID, err) } } return nil } -func VerifyJSON(serverName string, keyID id.KeyID, key id.SigningKey, data any) error { - var err error - message, ok := data.(json.RawMessage) - if !ok { - message, err = json.Marshal(data) - if err != nil { - return fmt.Errorf("failed to marshal data: %w", err) - } - } - sigVal := gjson.GetBytes(message, exgjson.Path("signatures", serverName, string(keyID))) - if sigVal.Type != gjson.String { - return ErrSignatureNotFound - } - message, err = sjson.DeleteBytes(message, "signatures") - if err != nil { - return fmt.Errorf("failed to delete signatures: %w", err) - } - message, err = sjson.DeleteBytes(message, "unsigned") - if err != nil { - return fmt.Errorf("failed to delete unsigned: %w", err) - } - return VerifyJSONRaw(key, sigVal.Str, message) -} - -var ErrSignatureNotFound = errors.New("signature not found") -var ErrInvalidSignature = errors.New("invalid signature") - -func VerifyJSONRaw(key id.SigningKey, sig string, message json.RawMessage) error { - sigBytes, err := base64.RawStdEncoding.DecodeString(sig) - if err != nil { - return fmt.Errorf("failed to decode signature: %w", err) - } - keyBytes, err := base64.RawStdEncoding.DecodeString(string(key)) - if err != nil { - return fmt.Errorf("failed to decode key: %w", err) - } - message = canonicaljson.CanonicalJSONAssumeValid(message) - if !ed25519.Verify(keyBytes, message, sigBytes) { - return ErrInvalidSignature - } - return nil -} - type marshalableSKR ServerKeyResponse func (skr *ServerKeyResponse) UnmarshalJSON(data []byte) error { diff --git a/federation/signutil/verify.go b/federation/signutil/verify.go new file mode 100644 index 00000000..8fe55b2f --- /dev/null +++ b/federation/signutil/verify.go @@ -0,0 +1,65 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package signutil + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "go.mau.fi/util/exgjson" + + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/id" +) + +var ErrSignatureNotFound = errors.New("signature not found") +var ErrInvalidSignature = errors.New("invalid signature") + +func VerifyJSON(serverName string, keyID id.KeyID, key id.SigningKey, data any) error { + var err error + message, ok := data.(json.RawMessage) + if !ok { + message, err = json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal data: %w", err) + } + } + sigVal := gjson.GetBytes(message, exgjson.Path("signatures", serverName, string(keyID))) + if sigVal.Type != gjson.String { + return ErrSignatureNotFound + } + message, err = sjson.DeleteBytes(message, "signatures") + if err != nil { + return fmt.Errorf("failed to delete signatures: %w", err) + } + message, err = sjson.DeleteBytes(message, "unsigned") + if err != nil { + return fmt.Errorf("failed to delete unsigned: %w", err) + } + return VerifyJSONRaw(key, sigVal.Str, message) +} + +func VerifyJSONRaw(key id.SigningKey, sig string, message json.RawMessage) error { + sigBytes, err := base64.RawStdEncoding.DecodeString(sig) + if err != nil { + return fmt.Errorf("failed to decode signature: %w", err) + } + keyBytes, err := base64.RawStdEncoding.DecodeString(string(key)) + if err != nil { + return fmt.Errorf("failed to decode key: %w", err) + } + message = canonicaljson.CanonicalJSONAssumeValid(message) + if !ed25519.Verify(keyBytes, message, sigBytes) { + return ErrInvalidSignature + } + return nil +} From 90e3427ac519001ce1ce917cb59be5c6c7f85a89 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 7 Aug 2025 12:08:25 +0300 Subject: [PATCH 214/581] bridgev2: check that avatar mxc is set before ignoring update --- bridgev2/ghost.go | 4 ++-- bridgev2/portal.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bridgev2/ghost.go b/bridgev2/ghost.go index f06c0363..6cef6f06 100644 --- a/bridgev2/ghost.go +++ b/bridgev2/ghost.go @@ -158,7 +158,7 @@ func (ghost *Ghost) UpdateName(ctx context.Context, name string) bool { } func (ghost *Ghost) UpdateAvatar(ctx context.Context, avatar *Avatar) bool { - if ghost.AvatarID == avatar.ID && ghost.AvatarSet { + if ghost.AvatarID == avatar.ID && (avatar.Remove || ghost.AvatarMXC != "") && ghost.AvatarSet { return false } ghost.AvatarID = avatar.ID @@ -168,7 +168,7 @@ func (ghost *Ghost) UpdateAvatar(ctx context.Context, avatar *Avatar) bool { ghost.AvatarSet = false zerolog.Ctx(ctx).Err(err).Msg("Failed to reupload avatar") return true - } else if newHash == ghost.AvatarHash && ghost.AvatarSet { + } else if newHash == ghost.AvatarHash && ghost.AvatarMXC != "" && ghost.AvatarSet { return true } ghost.AvatarHash = newHash diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 25865080..d343a651 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -3533,7 +3533,7 @@ func (portal *Portal) updateTopic(ctx context.Context, topic string, sender Matr } func (portal *Portal) updateAvatar(ctx context.Context, avatar *Avatar, sender MatrixAPI, ts time.Time) bool { - if portal.AvatarID == avatar.ID && (portal.AvatarSet || portal.MXID == "") { + if portal.AvatarID == avatar.ID && (avatar.Remove || portal.AvatarMXC != "") && (portal.AvatarSet || portal.MXID == "") { return false } portal.AvatarID = avatar.ID @@ -3549,7 +3549,7 @@ func (portal *Portal) updateAvatar(ctx context.Context, avatar *Avatar, sender M portal.AvatarSet = false zerolog.Ctx(ctx).Err(err).Msg("Failed to reupload room avatar") return true - } else if newHash == portal.AvatarHash && portal.AvatarSet { + } else if newHash == portal.AvatarHash && portal.AvatarMXC != "" && portal.AvatarSet { return true } portal.AvatarMXC = newMXC From 3865abb3b820bfa24b676737f00b7051f299dd31 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 9 Aug 2025 13:10:18 +0300 Subject: [PATCH 215/581] dependencies: update go-util and use new UnsafeString helper --- bridgev2/matrix/connector.go | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index a1f7d140..19eb399b 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -19,12 +19,12 @@ import ( "strings" "sync" "time" - "unsafe" _ "github.com/lib/pq" "github.com/rs/zerolog" "go.mau.fi/util/dbutil" _ "go.mau.fi/util/dbutil/litestream" + "go.mau.fi/util/exbytes" "go.mau.fi/util/exsync" "go.mau.fi/util/random" "golang.org/x/sync/semaphore" @@ -674,7 +674,7 @@ func (br *Connector) GenerateDeterministicEventID(roomID id.RoomID, _ networkid. eventID[1+hashB64Len] = ':' copy(eventID[1+hashB64Len+1:], br.deterministicEventIDServer) - return id.EventID(unsafe.String(unsafe.SliceData(eventID), len(eventID))) + return id.EventID(exbytes.UnsafeString(eventID)) } func (br *Connector) GenerateDeterministicRoomID(key networkid.PortalKey) id.RoomID { diff --git a/go.mod b/go.mod index 1133313f..a8c1f26d 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.12 - go.mau.fi/util v0.8.9-0.20250723171559-474867266038 + go.mau.fi/util v0.8.9-0.20250808135321-09699c48d2fa go.mau.fi/zeroconfig v0.1.3 golang.org/x/crypto v0.40.0 golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 diff --git a/go.sum b/go.sum index 461ee542..4f9bfaeb 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.8.9-0.20250723171559-474867266038 h1:RVL8TVaYc3LTBBopfjCNDtD+6eZks0O+qgXN/9hsz7k= -go.mau.fi/util v0.8.9-0.20250723171559-474867266038/go.mod h1:GZZp5f9r2MgEu4GDvtB0XxCF7i6Z7Z8fM0w9a5oZH3Y= +go.mau.fi/util v0.8.9-0.20250808135321-09699c48d2fa h1:xVnyD0gaIvK+7xA5lWSqWJf5EB2URW2Y0R4ABisAHD0= +go.mau.fi/util v0.8.9-0.20250808135321-09699c48d2fa/go.mod h1:GZZp5f9r2MgEu4GDvtB0XxCF7i6Z7Z8fM0w9a5oZH3Y= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= From 135cffc7c1b9d7796f72bc5b5d9f335395002795 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 9 Aug 2025 13:10:31 +0300 Subject: [PATCH 216/581] requests: add json un/marshaler for Direction rune --- requests.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/requests.go b/requests.go index eade0757..8f31e52f 100644 --- a/requests.go +++ b/requests.go @@ -2,6 +2,7 @@ package mautrix import ( "encoding/json" + "fmt" "strconv" "time" @@ -39,6 +40,26 @@ const ( type Direction rune +func (d Direction) MarshalJSON() ([]byte, error) { + return json.Marshal(string(d)) +} + +func (d *Direction) UnmarshalJSON(data []byte) error { + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + switch str { + case "f": + *d = DirectionForward + case "b": + *d = DirectionBackward + default: + return fmt.Errorf("invalid direction %q, must be 'f' or 'b'", str) + } + return nil +} + const ( DirectionForward Direction = 'f' DirectionBackward Direction = 'b' From 87d599c491fe4ce8132d798939fac540328e3277 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 9 Aug 2025 17:42:34 +0300 Subject: [PATCH 217/581] crypto: remove group session already shared error --- crypto/encryptmegolm.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crypto/encryptmegolm.go b/crypto/encryptmegolm.go index 804e15de..14ba2449 100644 --- a/crypto/encryptmegolm.go +++ b/crypto/encryptmegolm.go @@ -25,7 +25,6 @@ import ( ) var ( - AlreadyShared = errors.New("group session already shared") NoGroupSession = errors.New("no group session created") ) @@ -209,7 +208,8 @@ func (mach *OlmMachine) ShareGroupSession(ctx context.Context, roomID id.RoomID, if err != nil { return fmt.Errorf("failed to get previous outbound group session: %w", err) } else if session != nil && session.Shared && !session.Expired() { - return AlreadyShared + mach.machOrContextLog(ctx).Debug().Stringer("room_id", roomID).Msg("Not re-sharing group session, already shared") + return nil } log := mach.machOrContextLog(ctx).With(). Str("room_id", roomID.String()). From 6ea2337283856db17207b422ab1d1daf9a5ad676 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 10 Aug 2025 23:22:25 +0300 Subject: [PATCH 218/581] event: add policy server spammy flag to unsigned --- event/events.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/event/events.go b/event/events.go index 1a57fb4b..72c1e161 100644 --- a/event/events.go +++ b/event/events.go @@ -146,7 +146,8 @@ type Unsigned struct { BeeperHSOrderString *BeeperEncodedOrder `json:"com.beeper.hs.order_string,omitempty"` BeeperFromBackup bool `json:"com.beeper.from_backup,omitempty"` - ElementSoftFailed bool `json:"io.element.synapse.soft_failed,omitempty"` + ElementSoftFailed bool `json:"io.element.synapse.soft_failed,omitempty"` + ElementPolicyServerSpammy bool `json:"io.element.synapse.policy_server_spammy,omitempty"` } func (us *Unsigned) IsEmpty() bool { From 78aea00999ceb8a9440f411d0e5a79d73116bf32 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 10 Aug 2025 23:23:15 +0300 Subject: [PATCH 219/581] format/htmlparser: collapse spaces when parsing html --- format/htmlparser.go | 3 ++- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/format/htmlparser.go b/format/htmlparser.go index b4b1b9a4..e5f92896 100644 --- a/format/htmlparser.go +++ b/format/htmlparser.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" + "go.mau.fi/util/exstrings" "golang.org/x/net/html" "maunium.net/go/mautrix/event" @@ -371,7 +372,7 @@ func (parser *HTMLParser) singleNodeToString(node *html.Node, ctx Context) Tagge switch node.Type { case html.TextNode: if !ctx.PreserveWhitespace { - node.Data = strings.Replace(node.Data, "\n", "", -1) + node.Data = exstrings.CollapseSpaces(strings.ReplaceAll(node.Data, "\n", "")) } if parser.TextConverter != nil { node.Data = parser.TextConverter(node.Data, ctx) diff --git a/go.mod b/go.mod index a8c1f26d..c109af31 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.12 - go.mau.fi/util v0.8.9-0.20250808135321-09699c48d2fa + go.mau.fi/util v0.8.9-0.20250810202017-1d053aac320a go.mau.fi/zeroconfig v0.1.3 golang.org/x/crypto v0.40.0 golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 diff --git a/go.sum b/go.sum index 4f9bfaeb..dae44df7 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.8.9-0.20250808135321-09699c48d2fa h1:xVnyD0gaIvK+7xA5lWSqWJf5EB2URW2Y0R4ABisAHD0= -go.mau.fi/util v0.8.9-0.20250808135321-09699c48d2fa/go.mod h1:GZZp5f9r2MgEu4GDvtB0XxCF7i6Z7Z8fM0w9a5oZH3Y= +go.mau.fi/util v0.8.9-0.20250810202017-1d053aac320a h1:AviXwC+XRYNvlmLieSQxBjj5/K5JUIjBgduYNVSrPTo= +go.mau.fi/util v0.8.9-0.20250810202017-1d053aac320a/go.mod h1:GZZp5f9r2MgEu4GDvtB0XxCF7i6Z7Z8fM0w9a5oZH3Y= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= From 23df81f1cccde05094a7b26aabedff9f1e987be4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 11 Aug 2025 10:45:32 +0300 Subject: [PATCH 220/581] crypto/attachments: fix hash check when decrypting --- crypto/attachment/attachments.go | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/crypto/attachment/attachments.go b/crypto/attachment/attachments.go index cfa1c3e5..65c76f5a 100644 --- a/crypto/attachment/attachments.go +++ b/crypto/attachment/attachments.go @@ -9,6 +9,7 @@ package attachment import ( "crypto/aes" "crypto/cipher" + "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" @@ -217,9 +218,7 @@ func (r *encryptingReader) Close() (err error) { err = closer.Close() } if r.isDecrypting { - var downloadedChecksum [utils.SHAHashLength]byte - r.hash.Sum(downloadedChecksum[:]) - if downloadedChecksum != r.file.decoded.sha256 { + if !hmac.Equal(r.hash.Sum(nil), r.file.decoded.sha256[:]) { return HashMismatch } } else { @@ -274,12 +273,13 @@ func (ef *EncryptedFile) PrepareForDecryption() error { func (ef *EncryptedFile) DecryptInPlace(data []byte) error { if err := ef.PrepareForDecryption(); err != nil { return err - } else if ef.decoded.sha256 != sha256.Sum256(data) { - return HashMismatch - } else { - utils.XorA256CTR(data, ef.decoded.key, ef.decoded.iv) - return nil } + dataHash := sha256.Sum256(data) + if !hmac.Equal(ef.decoded.sha256[:], dataHash[:]) { + return HashMismatch + } + utils.XorA256CTR(data, ef.decoded.key, ef.decoded.iv) + return nil } // DecryptStream wraps the given io.Reader in order to decrypt the data. @@ -292,9 +292,10 @@ func (ef *EncryptedFile) DecryptInPlace(data []byte) error { func (ef *EncryptedFile) DecryptStream(reader io.Reader) io.ReadSeekCloser { block, _ := aes.NewCipher(ef.decoded.key[:]) return &encryptingReader{ - stream: cipher.NewCTR(block, ef.decoded.iv[:]), - hash: sha256.New(), - source: reader, - file: ef, + isDecrypting: true, + stream: cipher.NewCTR(block, ef.decoded.iv[:]), + hash: sha256.New(), + source: reader, + file: ef, } } From 5d84bddc62e658c946fbba67265fe13d1e2705b2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 11 Aug 2025 10:58:24 +0300 Subject: [PATCH 221/581] crypto/attachments: hash correct data while decrypting --- crypto/attachment/attachments.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crypto/attachment/attachments.go b/crypto/attachment/attachments.go index 65c76f5a..155cca5c 100644 --- a/crypto/attachment/attachments.go +++ b/crypto/attachment/attachments.go @@ -207,8 +207,13 @@ func (r *encryptingReader) Read(dst []byte) (n int, err error) { } } n, err = r.source.Read(dst) + if r.isDecrypting { + r.hash.Write(dst[:n]) + } r.stream.XORKeyStream(dst[:n], dst[:n]) - r.hash.Write(dst[:n]) + if !r.isDecrypting { + r.hash.Write(dst[:n]) + } return } From 7dcd45eba21c1740e63bdf34462d84edb4321558 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 12 Aug 2025 23:50:50 +0300 Subject: [PATCH 222/581] changelog: update --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e71381e..08749f34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,36 @@ +## v0.25.0 (unreleased) + +* **Breaking change *(appservice,bridgev2,federation)** Replaced gorilla/mux + with standard library ServeMux. +* *(client,bridgev2)* Added support for creator power in room v12. +* *(bridgev2)* Added support for following tombstones. +* *(bridgev2)* Added interface for getting arbitrary state event from Matrix. +* *(bridgev2)* Added batching to disappearing message queue to ensure it doesn't + use too many resources even if there are a large number of messages. +* *(bridgev2/commands)* Added support for canceling QR login with `cancel` + command. +* *(client)* Added option to override HTTP client used for .well-known + resolution. +* *(crypto/backup)* Added method for encrypting key backup session without + private keys. +* *(event->id)* Moved room version type and constants to id package. +* *(bridgev2)* Bots in DM portals will now be added to the functional members + state event to hide them from the room name calculation. +* *(bridgev2)* Changed message delete handling to ignore "delete for me" events + if there are multiple Matrix users in the room. +* *(format/htmlparser)* Changed text processing to collapse multiple spaces into + one when outside `pre`/`code` tags. +* *(format/htmlparser)* Removed link suffix in plaintext output when link text + is only missing protocol part of href. + * e.g. `example.com` will turn into + `example.com` rather than `example.com (https://example.com)` +* *(appservice)* Switched appservice websockets from gorilla/websocket to + coder/websocket. +* *(bridgev2/matrix)* Fixed encryption key sharing not ignoring ghosts properly. +* *(crypto/attachments)* Fixed hash check when decrypting file streams. +* *(crypto)* Removed unnecessary `AlreadyShared` error in `ShareGroupSession`. + The function will now act as if it was successful instead. + ## v0.24.2 (2025-07-16) * *(bridgev2)* Added support for return values from portal event handlers. Note From 809333fcc57269669f4589a65c73fcb505cb7049 Mon Sep 17 00:00:00 2001 From: V02460 Date: Wed, 13 Aug 2025 19:32:21 +0200 Subject: [PATCH 223/581] verificationhelper: use static format strings (#390) --- crypto/verificationhelper/verificationhelper.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto/verificationhelper/verificationhelper.go b/crypto/verificationhelper/verificationhelper.go index 9d843ea8..0a781c16 100644 --- a/crypto/verificationhelper/verificationhelper.go +++ b/crypto/verificationhelper/verificationhelper.go @@ -848,7 +848,7 @@ func (vh *VerificationHelper) onVerificationStart(ctx context.Context, txn Verif // here, since the start command for scanning and showing QR codes // should be of type m.reciprocate.v1. log.Error().Str("method", string(txn.StartEventContent.Method)).Msg("Unsupported verification method in start event") - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnknownMethod, fmt.Sprintf("unknown method %s", txn.StartEventContent.Method)) + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnknownMethod, "unknown method %s", txn.StartEventContent.Method) } } From ee869b97e6c241819579ca9e84291d4835af7d8b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 13 Aug 2025 20:33:04 +0300 Subject: [PATCH 224/581] dependencies: update --- .github/workflows/go.yml | 8 ++++---- CHANGELOG.md | 1 + go.mod | 22 +++++++++++----------- go.sum | 36 ++++++++++++++++++------------------ 4 files changed, 34 insertions(+), 33 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 71c1988b..3cf412b4 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -34,8 +34,8 @@ jobs: strategy: fail-fast: false matrix: - go-version: ["1.23", "1.24"] - name: Build (${{ matrix.go-version == '1.24' && 'latest' || 'old' }}, libolm) + go-version: ["1.24", "1.25"] + name: Build (${{ matrix.go-version == '1.25' && 'latest' || 'old' }}, libolm) steps: - uses: actions/checkout@v4 @@ -65,8 +65,8 @@ jobs: strategy: fail-fast: false matrix: - go-version: ["1.23", "1.24"] - name: Build (${{ matrix.go-version == '1.24' && 'latest' || 'old' }}, goolm) + go-version: ["1.24", "1.25"] + name: Build (${{ matrix.go-version == '1.25' && 'latest' || 'old' }}, goolm) steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 08749f34..6e87ffdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## v0.25.0 (unreleased) +* Bumped minimum Go version to 1.24. * **Breaking change *(appservice,bridgev2,federation)** Replaced gorilla/mux with standard library ServeMux. * *(client,bridgev2)* Added support for creator power in room v12. diff --git a/go.mod b/go.mod index c109af31..5e351b73 100644 --- a/go.mod +++ b/go.mod @@ -1,27 +1,27 @@ module maunium.net/go/mautrix -go 1.23.0 +go 1.24.0 -toolchain go1.24.5 +toolchain go1.25.0 require ( filippo.io/edwards25519 v1.1.0 github.com/chzyer/readline v1.5.1 github.com/coder/websocket v1.8.13 github.com/lib/pq v1.10.9 - github.com/mattn/go-sqlite3 v1.14.28 + github.com/mattn/go-sqlite3 v1.14.31 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.34.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 - github.com/yuin/goldmark v1.7.12 - go.mau.fi/util v0.8.9-0.20250810202017-1d053aac320a + github.com/yuin/goldmark v1.7.13 + go.mau.fi/util v0.8.9-0.20250813172851-79bf3eba563d go.mau.fi/zeroconfig v0.1.3 - golang.org/x/crypto v0.40.0 - golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 - golang.org/x/net v0.42.0 + golang.org/x/crypto v0.41.0 + golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 + golang.org/x/net v0.43.0 golang.org/x/sync v0.16.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 @@ -32,11 +32,11 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e // indirect + github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.27.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index dae44df7..dd517202 100644 --- a/go.sum +++ b/go.sum @@ -24,10 +24,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= -github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e h1:D0bJD+4O3G4izvrQUmzCL80zazlN7EwJ0PPDhpJWC/I= -github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/mattn/go-sqlite3 v1.14.31 h1:ldt6ghyPJsokUIlksH63gWZkG6qVGeEAu4zLeS4aVZM= +github.com/mattn/go-sqlite3 v1.14.31/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE= +github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -49,28 +49,28 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= -github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.8.9-0.20250810202017-1d053aac320a h1:AviXwC+XRYNvlmLieSQxBjj5/K5JUIjBgduYNVSrPTo= -go.mau.fi/util v0.8.9-0.20250810202017-1d053aac320a/go.mod h1:GZZp5f9r2MgEu4GDvtB0XxCF7i6Z7Z8fM0w9a5oZH3Y= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mau.fi/util v0.8.9-0.20250813172851-79bf3eba563d h1:lQCuHA1gVXxIxRhkNSOmhNWuqa8XwMX1mynD6IUELuk= +go.mau.fi/util v0.8.9-0.20250813172851-79bf3eba563d/go.mod h1:FtuGEQbVcfzQpTMDclFsq0NQ9GMtB2Gkd54Uq+TmsMk= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= -golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= From cd022c9010d5036f54831b57923b653cf91b1bc8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 15 Aug 2025 16:45:18 +0300 Subject: [PATCH 225/581] client: don't set user-agent header on wasm --- client.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/client.go b/client.go index 53ac6e10..4906169f 100644 --- a/client.go +++ b/client.go @@ -13,6 +13,7 @@ import ( "net/http" "net/url" "os" + "runtime" "slices" "strconv" "strings" @@ -154,8 +155,10 @@ func DiscoverClientAPIWithClient(ctx context.Context, client *http.Client, serve return nil, err } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", DefaultUserAgent+" (.well-known fetcher)") + if runtime.GOOS != "js" { + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", DefaultUserAgent+" (.well-known fetcher)") + } resp, err := client.Do(req) if err != nil { @@ -516,7 +519,9 @@ func (cli *Client) MakeFullRequestWithResp(ctx context.Context, params FullReque params.Handler = handleNormalResponse } } - req.Header.Set("User-Agent", cli.UserAgent) + if cli.UserAgent != "" { + req.Header.Set("User-Agent", cli.UserAgent) + } if len(cli.AccessToken) > 0 { req.Header.Set("Authorization", "Bearer "+cli.AccessToken) } @@ -1803,7 +1808,9 @@ func (cli *Client) tryUploadMediaToURL(ctx context.Context, url, contentType str } req.ContentLength = contentLength req.Header.Set("Content-Type", contentType) - req.Header.Set("User-Agent", cli.UserAgent+" (external media uploader)") + if cli.UserAgent != "" { + req.Header.Set("User-Agent", cli.UserAgent+" (external media uploader)") + } if cli.ExternalClient != nil { return cli.ExternalClient.Do(req) From 0bbfafe02f50c5d4d641f2f95e35f48939fffced Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 16 Aug 2025 13:12:52 +0300 Subject: [PATCH 226/581] Bump version to v0.25.0 --- CHANGELOG.md | 4 +++- go.mod | 6 +++--- go.sum | 12 ++++++------ version.go | 2 +- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e87ffdf..22ff47f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,11 @@ ## v0.25.0 (unreleased) * Bumped minimum Go version to 1.24. -* **Breaking change *(appservice,bridgev2,federation)** Replaced gorilla/mux +* **Breaking change *(appservice,bridgev2,federation)*** Replaced gorilla/mux with standard library ServeMux. * *(client,bridgev2)* Added support for creator power in room v12. +* *(client)* Added option to not set `User-Agent` header for improved Wasm + compatibility. * *(bridgev2)* Added support for following tombstones. * *(bridgev2)* Added interface for getting arbitrary state event from Matrix. * *(bridgev2)* Added batching to disappearing message queue to ensure it doesn't diff --git a/go.mod b/go.mod index 5e351b73..4abdc4ff 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/chzyer/readline v1.5.1 github.com/coder/websocket v1.8.13 github.com/lib/pq v1.10.9 - github.com/mattn/go-sqlite3 v1.14.31 + github.com/mattn/go-sqlite3 v1.14.32 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.34.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e @@ -17,8 +17,8 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.13 - go.mau.fi/util v0.8.9-0.20250813172851-79bf3eba563d - go.mau.fi/zeroconfig v0.1.3 + go.mau.fi/util v0.9.0 + go.mau.fi/zeroconfig v0.2.0 golang.org/x/crypto v0.41.0 golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 golang.org/x/net v0.43.0 diff --git a/go.sum b/go.sum index dd517202..bb5d5cdb 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.31 h1:ldt6ghyPJsokUIlksH63gWZkG6qVGeEAu4zLeS4aVZM= -github.com/mattn/go-sqlite3 v1.14.31/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE= github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -51,10 +51,10 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.8.9-0.20250813172851-79bf3eba563d h1:lQCuHA1gVXxIxRhkNSOmhNWuqa8XwMX1mynD6IUELuk= -go.mau.fi/util v0.8.9-0.20250813172851-79bf3eba563d/go.mod h1:FtuGEQbVcfzQpTMDclFsq0NQ9GMtB2Gkd54Uq+TmsMk= -go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= -go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= +go.mau.fi/util v0.9.0 h1:ya3s3pX+Y8R2fgp0DbE7a0o3FwncoelDX5iyaeVE8ls= +go.mau.fi/util v0.9.0/go.mod h1:pdL3lg2aaeeHIreGXNnPwhJPXkXdc3ZxsI6le8hOWEA= +go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= +go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= diff --git a/version.go b/version.go index 6b8af5ef..fd0d0a8d 100644 --- a/version.go +++ b/version.go @@ -7,7 +7,7 @@ import ( "strings" ) -const Version = "v0.24.2" +const Version = "v0.25.0" var GoModVersion = "" var Commit = "" From 2d4850a188fbc027a5e785471cddf3e69c8284f9 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 16 Aug 2025 13:21:06 +0300 Subject: [PATCH 227/581] changelog: fix date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22ff47f7..f8a15550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## v0.25.0 (unreleased) +## v0.25.0 (2025-08-16) * Bumped minimum Go version to 1.24. * **Breaking change *(appservice,bridgev2,federation)*** Replaced gorilla/mux From 80c0b950dc0ca367cef4fc56e7eaff9efb8e65c3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 17 Aug 2025 12:52:58 +0300 Subject: [PATCH 228/581] federation/pdu: add utilities for PDU generation and validation --- federation/pdu/pdu.go | 215 +++++++++++++++++++++++++++++++++++++ federation/pdu/pdu_test.go | 197 +++++++++++++++++++++++++++++++++ federation/pdu/redact.go | 108 +++++++++++++++++++ federation/pdu/v1.go | 59 ++++++++++ 4 files changed, 579 insertions(+) create mode 100644 federation/pdu/pdu.go create mode 100644 federation/pdu/pdu_test.go create mode 100644 federation/pdu/redact.go create mode 100644 federation/pdu/v1.go diff --git a/federation/pdu/pdu.go b/federation/pdu/pdu.go new file mode 100644 index 00000000..2ac970dc --- /dev/null +++ b/federation/pdu/pdu.go @@ -0,0 +1,215 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu + +import ( + "bytes" + "crypto/ed25519" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "fmt" + "time" + + "github.com/tidwall/gjson" + "go.mau.fi/util/jsonbytes" + + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/federation/signutil" + "maunium.net/go/mautrix/id" +) + +var ErrPDUIsNil = errors.New("PDU is nil") + +type Hashes struct { + SHA256 jsonbytes.UnpaddedBytes `json:"sha256"` + + Unknown jsontext.Value `json:",unknown"` +} + +type PDU struct { + AuthEvents []id.EventID `json:"auth_events"` + Content jsontext.Value `json:"content"` + Depth int64 `json:"depth"` + Hashes *Hashes `json:"hashes,omitzero"` + OriginServerTS int64 `json:"origin_server_ts"` + PrevEvents []id.EventID `json:"prev_events"` + Redacts *id.EventID `json:"redacts,omitzero"` + RoomID id.RoomID `json:"room_id,omitzero"` // not present for room v12+ create events + Sender id.UserID `json:"sender"` + Signatures map[string]map[id.KeyID]string `json:"signatures,omitzero"` + StateKey *string `json:"state_key,omitzero"` + Type string `json:"type"` + Unsigned jsontext.Value `json:"unsigned,omitzero"` + + Unknown jsontext.Value `json:",unknown"` + + // Deprecated legacy fields + DeprecatedPrevState any `json:"prev_state,omitzero"` + DeprecatedOrigin any `json:"origin,omitzero"` + DeprecatedMembership any `json:"membership,omitzero"` +} + +func (pdu *PDU) CalculateContentHash() ([32]byte, error) { + if pdu == nil { + return [32]byte{}, ErrPDUIsNil + } + pduClone := pdu.Clone() + pduClone.Signatures = nil + pduClone.Unsigned = nil + pduClone.Hashes = nil + rawJSON, err := marshalCanonical(pduClone) + if err != nil { + return [32]byte{}, fmt.Errorf("failed to marshal PDU to calculate content hash: %w", err) + } + return sha256.Sum256(rawJSON), nil +} + +func (pdu *PDU) FillContentHash() error { + if pdu == nil { + return ErrPDUIsNil + } else if pdu.Hashes != nil { + return nil + } else if hash, err := pdu.CalculateContentHash(); err != nil { + return err + } else { + pdu.Hashes = &Hashes{SHA256: hash[:]} + return nil + } +} + +func (pdu *PDU) VerifyContentHash() bool { + if pdu == nil || pdu.Hashes == nil { + return false + } + calculatedHash, err := pdu.CalculateContentHash() + if err != nil { + return false + } + return hmac.Equal(calculatedHash[:], pdu.Hashes.SHA256) +} + +func (pdu *PDU) Sign(roomVersion id.RoomVersion, serverName string, keyID id.KeyID, privateKey ed25519.PrivateKey) error { + err := pdu.FillContentHash() + if err != nil { + return err + } + rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature(roomVersion)) + if err != nil { + return fmt.Errorf("failed to marshal redacted PDU to sign: %w", err) + } + signature := ed25519.Sign(privateKey, rawJSON) + if pdu.Signatures == nil { + pdu.Signatures = make(map[string]map[id.KeyID]string) + } + if _, ok := pdu.Signatures[serverName]; !ok { + pdu.Signatures[serverName] = make(map[id.KeyID]string) + } + pdu.Signatures[serverName][keyID] = base64.RawStdEncoding.EncodeToString(signature) + return nil +} + +func marshalCanonical(data any) (jsontext.Value, error) { + marshaledBytes, err := json.Marshal(data) + if err != nil { + return nil, err + } + marshaled := jsontext.Value(marshaledBytes) + err = marshaled.Canonicalize() + if err != nil { + return nil, err + } + check := canonicaljson.CanonicalJSONAssumeValid(marshaled) + if !bytes.Equal(marshaled, check) { + fmt.Println(string(marshaled)) + fmt.Println(string(check)) + return nil, fmt.Errorf("canonical JSON mismatch for %s", string(marshaled)) + } + return marshaled, nil +} + +func (pdu *PDU) VerifySignature( + roomVersion id.RoomVersion, + serverName string, + getKey func(keyID id.KeyID, minValidUntil time.Time) (id.SigningKey, time.Time, error), +) error { + rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature(roomVersion)) + if err != nil { + return fmt.Errorf("failed to marshal redacted PDU to verify signature: %w", err) + } + verified := false + for keyID, sig := range pdu.Signatures[serverName] { + originServerTS := time.UnixMilli(pdu.OriginServerTS) + key, validUntil, err := getKey(keyID, originServerTS) + if err != nil { + return fmt.Errorf("failed to get key %s: %w", keyID, err) + } else if key == "" || validUntil.Before(originServerTS) { + continue + } + err = signutil.VerifyJSONRaw(key, sig, rawJSON) + if err != nil { + return fmt.Errorf("failed to verify signature from key %s: %w", keyID, err) + } + verified = true + } + if !verified { + return fmt.Errorf("no verifiable signatures found for server %s", serverName) + } + return nil +} + +func (pdu *PDU) CalculateRoomID() (id.RoomID, error) { + if pdu == nil { + return "", ErrPDUIsNil + } else if pdu.Type != "m.room.create" { + return "", fmt.Errorf("room ID can only be calculated for m.room.create events") + } else if roomVersion := id.RoomVersion(gjson.GetBytes(pdu.Content, "room_version").Str); !roomVersion.RoomIDIsCreateEventID() { + return "", fmt.Errorf("room version %s does not use m.room.create event ID as room ID", roomVersion) + } else if evtID, err := pdu.calculateEventID(roomVersion, '!'); err != nil { + return "", fmt.Errorf("failed to calculate event ID: %w", err) + } else { + return id.RoomID(evtID), nil + } +} + +func (pdu *PDU) CalculateEventID(roomVersion id.RoomVersion) (id.EventID, error) { + return pdu.calculateEventID(roomVersion, '$') +} + +func (pdu *PDU) calculateEventID(roomVersion id.RoomVersion, prefix byte) (id.EventID, error) { + if pdu == nil { + return "", ErrPDUIsNil + } + if pdu.Hashes == nil || pdu.Hashes.SHA256 == nil { + if err := pdu.FillContentHash(); err != nil { + return "", err + } + } + rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature(roomVersion)) + if err != nil { + return "", fmt.Errorf("failed to marshal redacted PDU to calculate event ID: %w", err) + } + referenceHash := sha256.Sum256(rawJSON) + eventID := make([]byte, 44) + eventID[0] = prefix + switch roomVersion.EventIDFormat() { + case id.EventIDFormatCustom: + return "", fmt.Errorf("*pdu.PDU can only be used for room v3+") + case id.EventIDFormatBase64: + base64.RawStdEncoding.Encode(eventID[1:], referenceHash[:]) + case id.EventIDFormatURLSafeBase64: + base64.RawURLEncoding.Encode(eventID[1:], referenceHash[:]) + default: + return "", fmt.Errorf("unknown event ID format %v", roomVersion.EventIDFormat()) + } + return id.EventID(eventID), nil +} diff --git a/federation/pdu/pdu_test.go b/federation/pdu/pdu_test.go new file mode 100644 index 00000000..a650672c --- /dev/null +++ b/federation/pdu/pdu_test.go @@ -0,0 +1,197 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu_test + +import ( + "encoding/base64" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.mau.fi/util/exerrors" + + "maunium.net/go/mautrix/federation/pdu" + "maunium.net/go/mautrix/id" +) + +type serverKey struct { + key id.SigningKey + validUntilTS time.Time +} + +type serverDetails struct { + serverName string + keys map[id.KeyID]serverKey +} + +var mauniumNet = serverDetails{ + serverName: "maunium.net", + keys: map[id.KeyID]serverKey{ + "ed25519:a_xxeS": { + key: "lVt/CC3tv74OH6xTph2JrUmeRj/j+1q0HVa0Xf4QlCg", + validUntilTS: time.Now(), + }, + }, +} +var envsNet = serverDetails{ + serverName: "envs.net", + keys: map[id.KeyID]serverKey{ + "ed25519:a_zIqy": { + key: "vCUcZpt9hUn0aabfh/9GP/6sZvXcydww8DUstPHdJm0", + validUntilTS: time.UnixMilli(1722360538068), + }, + "ed25519:wuJyKT": { + key: "xbE1QssgomL4wCSlyMYF5/7KxVyM4HPwAbNa+nFFnx0", + validUntilTS: time.Now(), + }, + }, +} +var matrixOrg = serverDetails{ + serverName: "matrix.org", + keys: map[id.KeyID]serverKey{ + "ed25519:auto": { + key: "Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw", + validUntilTS: time.UnixMilli(1576767829750), + }, + "ed25519:a_RXGa": { + key: "l8Hft5qXKn1vfHrg3p4+W8gELQVo8N13JkluMfmn2sQ", + validUntilTS: time.Now(), + }, + }, +} + +type testPDU struct { + name string + pdu string + eventID id.EventID + roomVersion id.RoomVersion + serverDetails +} + +var testPDUs = []testPDU{{ + name: "m.room.message in v5 room", + pdu: `{"auth_events":["$hp0ImHqYgHTRbLeWKPeTeFmxdb5SdMJN9cfmTrTk7d0","$KAj7X7tnJbR9qYYMWJSw-1g414_KlPptbbkZm7_kUtg","$V-2ShOwZYhA_nxMijaf3lqFgIJgzE2UMeFPtOLnoBYM"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":2248,"hashes":{"sha256":"kV+JuLbWXJ2r6PjHT3wt8bFc/TfI1nTaSN3Lamg/xHs"},"origin_server_ts":1755422945654,"prev_events":["$49lFLem2Nk4dxHk9RDXxTdaq9InIJpmkHpzVnjKcYwg"],"room_id":"!vzBgJsjNzgHSdWsmki:mozilla.org","sender":"@tulir:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"JIl60uVgfCLBZLPoSiE7wVkJ9U5cNEPVPuv1sCCYUOq5yOW56WD1adgpBUdX2UFpYkCHvkRnyQGxU0+6HBp5BA"}},"unsigned":{"age_ts":1755422945673}}`, + eventID: "$Qn4tHfuAe6PlnKXPZnygAU9wd6RXqMKtt_ZzstHTSgA", + roomVersion: id.RoomV5, + serverDetails: mauniumNet, +}, { + name: "m.room.message in v10 room", + pdu: `{"auth_events":["$--ilpwnsHaEdHrwiMrZNu5xHP6TthWG0FIXMHnlHCcs","$tn1FZUI_YUpfTr_a3Y_r8kC3inliIZZratzg0UsNdCQ","$Z-qMWmiMvm-aIEffcfSO6lN7TyjyTOsIcHIymfzoo20"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":100885,"hashes":{"sha256":"jc9272JPpPIVreJC3UEAm3BNVnLX8sm3U/TZs23wsHo"},"origin_server_ts":1755422792518,"prev_events":["$HDtbzpSys36Hk-F2NsiXfp9slsGXBH0b58qyddj_q5E"],"room_id":"!UzZHbJYcgggctGnlzr:envs.net","sender":"@tulir:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"sAMLo9jPtNB0Jq67IQm06siEBx82qZa2edu56IDQ4tDylEV4Mq7iFO23gCghqXA7B/MqBsjXotGBxv6AvlJ2Dw"}},"unsigned":{"age_ts":1755422792540}}`, + eventID: "$4ZFr_ypfp4DyZQP4zyxM_cvuOMFkl07doJmwi106YFY", + roomVersion: id.RoomV10, + serverDetails: mauniumNet, +}, { + name: "m.room.message in v11 room", + pdu: `{"auth_events":["$L8Ak6A939llTRIsZrytMlLDXQhI4uLEjx-wb1zSg-Bw","$QJmr7mmGeXGD4Tof0ZYSPW2oRGklseyHTKtZXnF-YNM","$7bkKK_Z-cGQ6Ae4HXWGBwXyZi3YjC6rIcQzGfVyl3Eo"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":3212,"hashes":{"sha256":"K549YdTnv62Jn84Y7sS5ZN3+AdmhleZHbenbhUpR2R8"},"origin_server_ts":1754242687127,"prev_events":["$DAhJg4jVsqk5FRatE2hbT1dSA8D2ASy5DbjEHIMSHwY"],"room_id":"!offtopic-2:continuwuity.org","sender":"@tulir:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"SkzZdZ+rH22kzCBBIAErTdB0Vg6vkFmzvwjlOarGul72EnufgtE/tJcd3a8szAdK7f1ZovRyQxDgVm/Ib2u0Aw"}},"unsigned":{"age_ts":1754242687146}}`, + eventID: `$qkWfTL7_l3oRZO2CItW8-Q0yAmi_l_1ua629ZDqponE`, + roomVersion: id.RoomV11, + serverDetails: mauniumNet, +}, { + name: "m.room.message in v12 room", + pdu: `{"auth_events":["$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA","$hyeL_nU_L3tsZ2dtZZpAHk0Skv-PqFQIipuII_By584"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":122,"hashes":{"sha256":"IQ0zlc+PXeEs6R3JvRkW3xTPV3zlGKSSd3x07KXGjzs"},"origin_server_ts":1755384351627,"prev_events":["$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA"],"room_id":"!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ","sender":"@tulir_test:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"0GDMddL2k7gF4V1VU8sL3wTfhAIzAu5iVH5jeavZ2VEg3J9/tHLWXAOn2tzkLaMRWl0/XpINT2YlH/rd2U21Ag"}},"unsigned":{"age_ts":1755384351627}}`, + eventID: "$xmP-wZfpannuHG-Akogi6c4YvqxChMtdyYbUMGOrMWc", + roomVersion: id.RoomV12, + serverDetails: mauniumNet, +}, { + name: "m.room.create in v4 room", + pdu: `{"auth_events": [], "prev_events": [], "type": "m.room.create", "room_id": "!jxlRxnrZCsjpjDubDX:matrix.org", "sender": "@neilj:matrix.org", "content": {"room_version": "4", "predecessor": {"room_id": "!DYgXKezaHgMbiPMzjX:matrix.org", "event_id": "$156171636353XwPJT:matrix.org"}, "creator": "@neilj:matrix.org"}, "depth": 1, "prev_state": [], "state_key": "", "origin": "matrix.org", "origin_server_ts": 1561716363993, "hashes": {"sha256": "9tj8GpXjTAJvdNAbnuKLemZZk+Tjv2LAbGodSX6nJAo"}, "signatures": {"matrix.org": {"ed25519:auto": "2+sNt8uJUhzU4GPxnFVYtU2ZRgFdtVLT1vEZGUdJYN40zBpwYEGJy+kyb5matA+8/yLeYD9gu1O98lhleH0aCA"}}, "unsigned": {"age": 104769}}`, + eventID: "$ay_9_nPilrTpb3UxIwHHBBfFjTJb6hBAE_JzQwSjqeY", + roomVersion: id.RoomV4, + serverDetails: matrixOrg, +}, { + name: "m.room.create in v10 room", + pdu: `{"auth_events":[],"content":{"creator":"@creme:envs.net","predecessor":{"event_id":"$BxYNisKcyBDhPLiVC06t18qhv7wsT72MzMCqn5vRhfY","room_id":"!tEyFYiMHhwJlDXTxwf:envs.net"},"room_version":"10"},"depth":1,"hashes":{"sha256":"us3TrsIjBWpwbm+k3F9fUVnz9GIuhnb+LcaY47fWwUI"},"origin":"envs.net","origin_server_ts":1664394769527,"prev_events":[],"room_id":"!UzZHbJYcgggctGnlzr:envs.net","sender":"@creme:envs.net","state_key":"","type":"m.room.create","signatures":{"envs.net":{"ed25519:a_zIqy":"0g3FDaD1e5BekJYW2sR7dgxuKoZshrf8P067c9+jmH6frsWr2Ua86Ax08CFa/n46L8uvV2SGofP8iiVYgXCRBg"}},"unsigned":{"age":2060}}`, + eventID: "$tn1FZUI_YUpfTr_a3Y_r8kC3inliIZZratzg0UsNdCQ", + roomVersion: id.RoomV10, + serverDetails: envsNet, +}, { + name: "m.room.create in v12 room", + pdu: `{"auth_events":[],"content":{"fi.mau.randomness":"AAXZ6aIc","predecessor":{"room_id":"!#test/room\nversion 11, with @\ud83d\udc08\ufe0f:maunium.net"},"room_version":"12"},"depth":1,"hashes":{"sha256":"d3L1M3KUdyIKWcShyW6grUoJ8GOjCdSIEvQrDVHSpE8"},"origin_server_ts":1754940000000,"prev_events":[],"sender":"@tulir:maunium.net","state_key":"","type":"m.room.create","signatures":{"maunium.net":{"ed25519:a_xxeS":"ebjIRpzToc82cjb/RGY+VUzZic0yeRZrjctgx0SUTJxkprXn3/i1KdiYULfl/aD0cUJ5eL8gLakOSk2glm+sBw"}},"unsigned":{"age_ts":1754939139045}}`, + eventID: "$mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", + roomVersion: id.RoomV12, + serverDetails: mauniumNet, +}, { + name: "m.room.member in v4 room", + pdu: `{"auth_events":["$ay_9_nPilrTpb3UxIwHHBBfFjTJb6hBAE_JzQwSjqeY","$jg2AgCfnwnjR-osoyM0lVYS21QrtfmZxhGO90PRkmO4","$wMGMP4Ucij2_d4h_fVDgIT2xooLZAgMcBruT9oo3Jio","$yyDgV8w0_e8qslmn0nh9OeSq_fO0zjpjTjSEdKFxDso"],"prev_events":["$zSjNuTXhUe3Rq6NpKD3sNyl8a_asMnBhGC5IbacHlJ4"],"type":"m.room.member","room_id":"!jxlRxnrZCsjpjDubDX:matrix.org","sender":"@tulir:maunium.net","content":{"membership":"join","displayname":"tulir","avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","clicked \"send membership event with no changes\"":true},"depth":14370,"prev_state":[],"state_key":"@tulir:maunium.net","origin":"maunium.net","origin_server_ts":1600871136259,"hashes":{"sha256":"Ga6bG9Mk0887ruzM9TAAfa1O3DbNssb+qSFtE9oeRL4"},"signatures":{"maunium.net":{"ed25519:a_xxeS":"fzOyDG3G3pEzixtWPttkRA1DfnHETiKbiG8SEBQe2qycQbZWPky7xX8WujSrUJH/+bxTABpQwEH49d+RakxtBw"}},"unsigned":{"age_ts":1600871136259,"replaces_state":"$jg2AgCfnwnjR-osoyM0lVYS21QrtfmZxhGO90PRkmO4"}}`, + eventID: "$VtuCNOfAWGow-cxy0ajeK3fvONcC8QzF2yWa43g0Gwo", + roomVersion: id.RoomV4, + serverDetails: mauniumNet, +}, { + name: "m.room.member in v10 room", + pdu: `{"auth_events":["$HQC4hWaioLKVbMH94qKbfb3UnL4ocql2vi-VdUYI48I","$R9FUDgNAp9ms7b6ASunZOIkpqmsIRq_ROrNEznu62fs","$kEPF8Aj87EzRmFPriu2zdyEY0rY15XSqywTYVLUUlCA","$tn1FZUI_YUpfTr_a3Y_r8kC3inliIZZratzg0UsNdCQ"],"content":{"avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","displayname":"tulir","membership":"join"},"depth":182,"hashes":{"sha256":"0HscBc921QV2dxK2qY7qrnyoAgfxBM7kKvqAXlEk+GE"},"origin":"maunium.net","origin_server_ts":1665402609039,"prev_events":["$R9FUDgNAp9ms7b6ASunZOIkpqmsIRq_ROrNEznu62fs"],"room_id":"!UzZHbJYcgggctGnlzr:envs.net","sender":"@tulir:maunium.net","state_key":"@tulir:maunium.net","type":"m.room.member","signatures":{"maunium.net":{"ed25519:a_xxeS":"lkOW0FSJ8MJ0wZpdwLH1Uf6FSl2q9/u6KthRIlM0CwHDJG4sIZ9DrMA8BdU8L/PWoDS/CoDUlLanDh99SplgBw"}},"unsigned":{"age_ts":1665402609039,"replaces_state":"$R9FUDgNAp9ms7b6ASunZOIkpqmsIRq_ROrNEznu62fs"}}`, + eventID: "$--ilpwnsHaEdHrwiMrZNu5xHP6TthWG0FIXMHnlHCcs", + roomVersion: id.RoomV10, + serverDetails: mauniumNet, +}, { + name: "m.room.member of creator in v12 room", + pdu: `{"auth_events":[],"content":{"avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","displayname":"tulir","membership":"join"},"depth":2,"hashes":{"sha256":"IebdOBYaaWYIx2zq/lkVCnjWIXTLk1g+vgFpJMgd2/E"},"origin_server_ts":1754939139117,"prev_events":["$mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ"],"room_id":"!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ","sender":"@tulir:maunium.net","state_key":"@tulir:maunium.net","type":"m.room.member","signatures":{"maunium.net":{"ed25519:a_xxeS":"rFCgF2hmavdm6+P6/f7rmuOdoSOmELFaH3JdWjgBLZXS2z51Ma7fa2v2+BkAH1FvBo9FLhvEoFVM4WbNQLXtAA"}},"unsigned":{"age_ts":1754939139117}}`, + eventID: "$accqGxfvhBvMP4Sf6P7t3WgnaJK6UbonO2ZmwqSE5Sg", + roomVersion: id.RoomV12, + serverDetails: mauniumNet, +}, { + name: "custom message event in v4 room", + pdu: `{"auth_events":["$VtuCNOfAWGow-cxy0ajeK3fvONcC8QzF2yWa43g0Gwo","$ay_9_nPilrTpb3UxIwHHBBfFjTJb6hBAE_JzQwSjqeY","$Gau_XwziYsr-rt3SouhbKN14twgmbKjcZZc_hz-nOgU"],"content":{"\ud83d\udc08\ufe0f":true,"\ud83d\udc15\ufe0f":false},"depth":69645,"hashes":{"sha256":"VHtWyCt+15ZesNnStU3FOkxrjzHJYZfd3JUgO9JWe0s"},"origin_server_ts":1755423939146,"prev_events":["$exmp4cj0OKOFSxuqBYiOYwQi5j_0XRc78d6EavAkhy0"],"room_id":"!jxlRxnrZCsjpjDubDX:matrix.org","sender":"@tulir:maunium.net","type":"\ud83d\udc08\ufe0f","signatures":{"maunium.net":{"ed25519:a_xxeS":"wfmP1XN4JBkKVkqrQnwysyEUslXt8hQRFwN9NC9vJaIeDMd0OJ6uqCas75808DuG71p23fzqbzhRnHckst6FCQ"}},"unsigned":{"age_ts":1755423939164}}`, + eventID: "$kAagtZAIEeZaLVCUSl74tAxQbdKbE22GU7FM-iAJBc0", + roomVersion: id.RoomV4, + serverDetails: mauniumNet, +}} + +func parsePDU(pdu string) (out *pdu.PDU) { + exerrors.PanicIfNotNil(json.Unmarshal([]byte(pdu), &out)) + return +} + +func TestPDU_CalculateContentHash(t *testing.T) { + for _, test := range testPDUs { + t.Run(test.name, func(t *testing.T) { + parsed := parsePDU(test.pdu) + contentHash := exerrors.Must(parsed.CalculateContentHash()) + assert.Equal( + t, + base64.RawStdEncoding.EncodeToString(parsed.Hashes.SHA256), + base64.RawStdEncoding.EncodeToString(contentHash[:]), + ) + }) + } +} + +func TestPDU_VerifyContentHash(t *testing.T) { + for _, test := range testPDUs { + t.Run(test.name, func(t *testing.T) { + parsed := parsePDU(test.pdu) + assert.True(t, parsed.VerifyContentHash()) + }) + } +} + +func TestPDU_CalculateEventID(t *testing.T) { + for _, test := range testPDUs { + t.Run(test.name, func(t *testing.T) { + gotEventID := exerrors.Must(parsePDU(test.pdu).CalculateEventID(test.roomVersion)) + assert.Equal(t, test.eventID, gotEventID) + }) + } +} + +func TestPDU_VerifySignature(t *testing.T) { + for _, test := range testPDUs { + t.Run(test.name, func(t *testing.T) { + parsed := parsePDU(test.pdu) + err := parsed.VerifySignature(test.roomVersion, test.serverName, func(keyID id.KeyID, _ time.Time) (id.SigningKey, time.Time, error) { + key, ok := test.keys[keyID] + if ok { + return key.key, key.validUntilTS, nil + } + return "", time.Time{}, nil + }) + assert.NoError(t, err) + }) + } +} diff --git a/federation/pdu/redact.go b/federation/pdu/redact.go new file mode 100644 index 00000000..56aaee1c --- /dev/null +++ b/federation/pdu/redact.go @@ -0,0 +1,108 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu + +import ( + "encoding/json/jsontext" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "go.mau.fi/util/exgjson" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix/id" +) + +func filteredObject(object jsontext.Value, allowedPaths ...string) jsontext.Value { + filtered := jsontext.Value("{}") + var err error + for _, path := range allowedPaths { + res := gjson.GetBytes(object, path) + if res.Exists() { + var raw jsontext.Value + if res.Index > 0 { + raw = object[res.Index : res.Index+len(res.Raw)] + } else { + raw = jsontext.Value(res.Raw) + } + filtered, err = sjson.SetRawBytes(filtered, path, raw) + if err != nil { + panic(err) + } + } + } + return filtered +} + +func (pdu *PDU) Clone() *PDU { + return ptr.Clone(pdu) +} + +func (pdu *PDU) RedactForSignature(roomVersion id.RoomVersion) *PDU { + pdu.Signatures = nil + return pdu.Redact(roomVersion) +} + +var emptyObject = jsontext.Value("{}") + +func (pdu *PDU) Redact(roomVersion id.RoomVersion) *PDU { + pdu.Unknown = nil + pdu.Unsigned = nil + if roomVersion.UpdatedRedactionRules() { + pdu.DeprecatedPrevState = nil + pdu.DeprecatedOrigin = nil + pdu.DeprecatedMembership = nil + } + + switch pdu.Type { + case "m.room.member": + allowedPaths := []string{"membership"} + if roomVersion.RestrictedJoinsFix() { + allowedPaths = append(allowedPaths, "join_authorised_via_users_server") + } + if roomVersion.UpdatedRedactionRules() { + allowedPaths = append(allowedPaths, exgjson.Path("third_party_invite", "signed")) + } + pdu.Content = filteredObject(pdu.Content, allowedPaths...) + case "m.room.create": + if !roomVersion.UpdatedRedactionRules() { + pdu.Content = filteredObject(pdu.Content, "creator") + } // else: all fields are protected + case "m.room.join_rules": + if roomVersion.RestrictedJoins() { + pdu.Content = filteredObject(pdu.Content, "join_rule", "allow") + } else { + pdu.Content = filteredObject(pdu.Content, "join_rule") + } + case "m.room.power_levels": + allowedKeys := []string{"ban", "events", "events_default", "kick", "redact", "state_default", "users", "users_default"} + if roomVersion.UpdatedRedactionRules() { + allowedKeys = append(allowedKeys, "invite") + } + pdu.Content = filteredObject(pdu.Content, allowedKeys...) + case "m.room.history_visibility": + pdu.Content = filteredObject(pdu.Content, "history_visibility") + case "m.room.redaction": + if roomVersion.RedactsInContent() { + pdu.Content = filteredObject(pdu.Content, "redacts") + pdu.Redacts = nil + } else { + pdu.Content = emptyObject + } + case "m.room.aliases": + if roomVersion.SpecialCasedAliasesAuth() { + pdu.Content = filteredObject(pdu.Content, "aliases") + } else { + pdu.Content = emptyObject + } + default: + pdu.Content = emptyObject + } + return pdu +} diff --git a/federation/pdu/v1.go b/federation/pdu/v1.go new file mode 100644 index 00000000..e8aa82fc --- /dev/null +++ b/federation/pdu/v1.go @@ -0,0 +1,59 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu + +import ( + "encoding/json/jsontext" + + "maunium.net/go/mautrix/id" +) + +type RoomV1PDU struct { + AuthEvents [][]string `json:"auth_events"` + Content jsontext.Value `json:"content"` + Depth int64 `json:"depth"` + EventID id.EventID `json:"event_id"` + Hashes *Hashes `json:"hashes,omitempty"` + OriginServerTS int64 `json:"origin_server_ts"` + PrevEvents [][]string `json:"prev_events"` + Redacts *id.EventID `json:"redacts,omitempty"` + RoomID id.RoomID `json:"room_id"` + Sender id.UserID `json:"sender"` + Signatures map[string]map[id.KeyID]string `json:"signatures,omitempty"` + StateKey *string `json:"state_key,omitempty"` + Type string `json:"type"` + + Unknown jsontext.Value `json:",unknown"` + + // Deprecated legacy fields + DeprecatedPrevState any `json:"prev_state,omitempty"` + DeprecatedOrigin any `json:"origin,omitempty"` + DeprecatedMembership any `json:"membership,omitempty"` +} + +func (pdu *RoomV1PDU) Redact() { + pdu.Unknown = nil + + switch pdu.Type { + case "m.room.member": + pdu.Content = filteredObject(pdu.Content, "membership") + case "m.room.create": + pdu.Content = filteredObject(pdu.Content, "creator") + case "m.room.join_rules": + pdu.Content = filteredObject(pdu.Content, "join_rule") + case "m.room.power_levels": + pdu.Content = filteredObject(pdu.Content, "ban", "events", "events_default", "kick", "redact", "state_default", "users", "users_default") + case "m.room.history_visibility": + pdu.Content = filteredObject(pdu.Content, "history_visibility") + case "m.room.aliases": + pdu.Content = filteredObject(pdu.Content, "aliases") + default: + pdu.Content = jsontext.Value("{}") + } +} From d2e7302daeb36bc352a02262fe1c3cbc72601093 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 17 Aug 2025 12:53:06 +0300 Subject: [PATCH 229/581] ci: test goolm and jsonv2 --- .github/workflows/go.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 3cf412b4..fd1dfa92 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.24" + go-version: "1.25" cache: true - name: Install libolm @@ -60,6 +60,11 @@ jobs: - name: Test run: go test -json -v ./... 2>&1 | gotestfmt + - name: Test (jsonv2) + env: + GOEXPERIMENT: jsonv2 + run: go test -json -v ./... 2>&1 | gotestfmt + build-goolm: runs-on: ubuntu-latest strategy: @@ -86,3 +91,11 @@ jobs: run: | rm -rf crypto/libolm go build -tags=goolm -v ./... + + - name: Test + run: go test -tags goolm -json -v ./... 2>&1 | gotestfmt + + - name: Test (jsonv2) + env: + GOEXPERIMENT: jsonv2 + run: go test -tags goolm -json -v ./... 2>&1 | gotestfmt From e85276fc0b014e324c78fcc21c29bf6b35788057 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 17 Aug 2025 12:59:15 +0300 Subject: [PATCH 230/581] ci: disable gotestfmt in goolm It explodes with `panic: BUG: Empty package name encountered.` --- .github/workflows/go.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index fd1dfa92..3d58aabc 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -58,12 +58,12 @@ jobs: run: go build -v ./... - name: Test - run: go test -json -v ./... 2>&1 | gotestfmt + run: go test -v ./... - name: Test (jsonv2) env: GOEXPERIMENT: jsonv2 - run: go test -json -v ./... 2>&1 | gotestfmt + run: go test -v ./... build-goolm: runs-on: ubuntu-latest From 86802be0f788f865d655e656b5e6fd87b1728a36 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 17 Aug 2025 13:00:42 +0300 Subject: [PATCH 231/581] federation/pdu: gate signing key validity check by room version --- federation/pdu/pdu.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/federation/pdu/pdu.go b/federation/pdu/pdu.go index 2ac970dc..bef7b344 100644 --- a/federation/pdu/pdu.go +++ b/federation/pdu/pdu.go @@ -152,7 +152,7 @@ func (pdu *PDU) VerifySignature( key, validUntil, err := getKey(keyID, originServerTS) if err != nil { return fmt.Errorf("failed to get key %s: %w", keyID, err) - } else if key == "" || validUntil.Before(originServerTS) { + } else if key == "" || (validUntil.Before(originServerTS) && roomVersion.EnforceSigningKeyValidity()) { continue } err = signutil.VerifyJSONRaw(key, sig, rawJSON) From 31178e9f424f005671aa8e4ed7042ea7fb17d1a8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 17 Aug 2025 13:02:47 +0300 Subject: [PATCH 232/581] federation/pdu: fail on any signature check error --- federation/pdu/pdu.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/federation/pdu/pdu.go b/federation/pdu/pdu.go index bef7b344..0027f02f 100644 --- a/federation/pdu/pdu.go +++ b/federation/pdu/pdu.go @@ -151,15 +151,16 @@ func (pdu *PDU) VerifySignature( originServerTS := time.UnixMilli(pdu.OriginServerTS) key, validUntil, err := getKey(keyID, originServerTS) if err != nil { - return fmt.Errorf("failed to get key %s: %w", keyID, err) - } else if key == "" || (validUntil.Before(originServerTS) && roomVersion.EnforceSigningKeyValidity()) { - continue - } - err = signutil.VerifyJSONRaw(key, sig, rawJSON) - if err != nil { + return fmt.Errorf("failed to get key %s for %s: %w", keyID, serverName, err) + } else if key == "" { + return fmt.Errorf("key %s not found for %s", keyID, serverName) + } else if validUntil.Before(originServerTS) && roomVersion.EnforceSigningKeyValidity() { + return fmt.Errorf("key %s for %s is only valid until %s, but event is from %s", keyID, serverName, validUntil, originServerTS) + } else if err = signutil.VerifyJSONRaw(key, sig, rawJSON); err != nil { return fmt.Errorf("failed to verify signature from key %s: %w", keyID, err) + } else { + verified = true } - verified = true } if !verified { return fmt.Errorf("no verifiable signatures found for server %s", serverName) From 0dc957fa30a8021f8a4b8405545efe9a61c5713c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 17 Aug 2025 13:11:26 +0300 Subject: [PATCH 233/581] ci: fix more things --- .github/workflows/go.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 3d58aabc..87bde5f8 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -65,6 +65,15 @@ jobs: GOEXPERIMENT: jsonv2 run: go test -v ./... + - name: Test + run: go test -tags goolm -json -v ./... 2>&1 | gotestfmt + + - name: Test (jsonv2) + if: matrix.go-version == '1.25' + env: + GOEXPERIMENT: jsonv2 + run: go test -tags goolm -json -v ./... 2>&1 | gotestfmt + build-goolm: runs-on: ubuntu-latest strategy: @@ -93,9 +102,10 @@ jobs: go build -tags=goolm -v ./... - name: Test - run: go test -tags goolm -json -v ./... 2>&1 | gotestfmt + run: go test -v ./... - name: Test (jsonv2) + if: matrix.go-version == '1.25' env: GOEXPERIMENT: jsonv2 - run: go test -tags goolm -json -v ./... 2>&1 | gotestfmt + run: go test -v ./... From 0f177058c17a20812c9c2653c12a37d32ae49992 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 17 Aug 2025 13:12:53 +0300 Subject: [PATCH 234/581] ci: move tags to correct place --- .github/workflows/go.yml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 87bde5f8..1eeff30c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -58,21 +58,13 @@ jobs: run: go build -v ./... - name: Test - run: go test -v ./... - - - name: Test (jsonv2) - env: - GOEXPERIMENT: jsonv2 - run: go test -v ./... - - - name: Test - run: go test -tags goolm -json -v ./... 2>&1 | gotestfmt + run: go test -json -v ./... 2>&1 | gotestfmt - name: Test (jsonv2) if: matrix.go-version == '1.25' env: GOEXPERIMENT: jsonv2 - run: go test -tags goolm -json -v ./... 2>&1 | gotestfmt + run: go test -json -v ./... 2>&1 | gotestfmt build-goolm: runs-on: ubuntu-latest @@ -102,10 +94,10 @@ jobs: go build -tags=goolm -v ./... - name: Test - run: go test -v ./... + run: go test -tags goolm -v ./... - name: Test (jsonv2) if: matrix.go-version == '1.25' env: GOEXPERIMENT: jsonv2 - run: go test -v ./... + run: go test -tags goolm -v ./... From 9b075f8bb9ab32ae6f39a9ef728eda16f1d15f45 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 17 Aug 2025 13:15:53 +0300 Subject: [PATCH 235/581] ci: disable tests on goolm again --- .github/workflows/go.yml | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 1eeff30c..dc4f17e2 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -83,21 +83,7 @@ jobs: go-version: ${{ matrix.go-version }} cache: true - - name: Set up gotestfmt - uses: GoTestTools/gotestfmt-action@v2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - name: Build run: | rm -rf crypto/libolm go build -tags=goolm -v ./... - - - name: Test - run: go test -tags goolm -v ./... - - - name: Test (jsonv2) - if: matrix.go-version == '1.25' - env: - GOEXPERIMENT: jsonv2 - run: go test -tags goolm -v ./... From 6eced49860c126055dcbe91464c647439202b6cd Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 17 Aug 2025 13:32:07 +0300 Subject: [PATCH 236/581] client,event: remove deprecated MSC2716 structs --- client.go | 21 --------------------- event/content.go | 1 - event/state.go | 6 ------ event/type.go | 5 +---- requests.go | 12 ------------ responses.go | 12 ------------ 6 files changed, 1 insertion(+), 56 deletions(-) diff --git a/client.go b/client.go index 4906169f..1536ae52 100644 --- a/client.go +++ b/client.go @@ -2530,27 +2530,6 @@ func (cli *Client) ReportRoom(ctx context.Context, roomID id.RoomID, reason stri return err } -// BatchSend sends a batch of historical events into a room. This is only available for appservices. -// -// Deprecated: MSC2716 has been abandoned, so this is now Beeper-specific. BeeperBatchSend should be used instead. -func (cli *Client) BatchSend(ctx context.Context, roomID id.RoomID, req *ReqBatchSend) (resp *RespBatchSend, err error) { - path := ClientURLPath{"unstable", "org.matrix.msc2716", "rooms", roomID, "batch_send"} - query := map[string]string{ - "prev_event_id": req.PrevEventID.String(), - } - if req.BeeperNewMessages { - query["com.beeper.new_messages"] = "true" - } - if req.BeeperMarkReadBy != "" { - query["com.beeper.mark_read_by"] = req.BeeperMarkReadBy.String() - } - if len(req.BatchID) > 0 { - query["batch_id"] = req.BatchID.String() - } - _, err = cli.MakeRequest(ctx, http.MethodPost, cli.BuildURLWithQuery(path, query), req, &resp) - return -} - func (cli *Client) AppservicePing(ctx context.Context, id, txnID string) (resp *RespAppservicePing, err error) { _, err = cli.MakeFullRequest(ctx, FullRequest{ Method: http.MethodPost, diff --git a/event/content.go b/event/content.go index b56e35f2..e50dfea5 100644 --- a/event/content.go +++ b/event/content.go @@ -38,7 +38,6 @@ var TypeMap = map[Type]reflect.Type{ StateHalfShotBridge: reflect.TypeOf(BridgeEventContent{}), StateSpaceParent: reflect.TypeOf(SpaceParentEventContent{}), StateSpaceChild: reflect.TypeOf(SpaceChildEventContent{}), - StateInsertionMarker: reflect.TypeOf(InsertionMarkerContent{}), StateLegacyPolicyRoom: reflect.TypeOf(ModPolicyContent{}), StateLegacyPolicyServer: reflect.TypeOf(ModPolicyContent{}), diff --git a/event/state.go b/event/state.go index 44a45a57..de46c57d 100644 --- a/event/state.go +++ b/event/state.go @@ -258,12 +258,6 @@ func (mpc *ModPolicyContent) EntityOrHash() string { return mpc.Entity } -// Deprecated: MSC2716 has been abandoned -type InsertionMarkerContent struct { - InsertionID id.EventID `json:"org.matrix.msc2716.marker.insertion"` - Timestamp int64 `json:"com.beeper.timestamp,omitempty"` -} - type ElementFunctionalMembersContent struct { ServiceMembers []id.UserID `json:"service_members"` } diff --git a/event/type.go b/event/type.go index 591d598d..b097cfe1 100644 --- a/event/type.go +++ b/event/type.go @@ -112,7 +112,7 @@ func (et *Type) GuessClass() TypeClass { StatePowerLevels.Type, StateRoomName.Type, StateRoomAvatar.Type, StateServerACL.Type, StateTopic.Type, StatePinnedEvents.Type, StateTombstone.Type, StateEncryption.Type, StateBridge.Type, StateHalfShotBridge.Type, StateSpaceParent.Type, StateSpaceChild.Type, StatePolicyRoom.Type, StatePolicyServer.Type, StatePolicyUser.Type, - StateInsertionMarker.Type, StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type: + StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type: return StateEventType case EphemeralEventReceipt.Type, EphemeralEventTyping.Type, EphemeralEventPresence.Type: return EphemeralEventType @@ -200,9 +200,6 @@ var ( StateUnstablePolicyServer = Type{"org.matrix.mjolnir.rule.server", StateEventType} StateUnstablePolicyUser = Type{"org.matrix.mjolnir.rule.user", StateEventType} - // Deprecated: MSC2716 has been abandoned - StateInsertionMarker = Type{"org.matrix.msc2716.marker", StateEventType} - StateElementFunctionalMembers = Type{"io.element.functional_members", StateEventType} StateBeeperRoomFeatures = Type{"com.beeper.room_features", StateEventType} ) diff --git a/requests.go b/requests.go index 8f31e52f..9871f044 100644 --- a/requests.go +++ b/requests.go @@ -401,18 +401,6 @@ type ReqPutPushRule struct { Pattern string `json:"pattern"` } -// Deprecated: MSC2716 was abandoned -type ReqBatchSend struct { - PrevEventID id.EventID `json:"-"` - BatchID id.BatchID `json:"-"` - - BeeperNewMessages bool `json:"-"` - BeeperMarkReadBy id.UserID `json:"-"` - - StateEventsAtStart []*event.Event `json:"state_events_at_start"` - Events []*event.Event `json:"events"` -} - type ReqBeeperBatchSend struct { // ForwardIfNoMessages should be set to true if the batch should be forward // backfilled if there are no messages currently in the room. diff --git a/responses.go b/responses.go index 27d96ffe..5b97b293 100644 --- a/responses.go +++ b/responses.go @@ -488,18 +488,6 @@ type RespDeviceInfo struct { LastSeenTS int64 `json:"last_seen_ts"` } -// Deprecated: MSC2716 was abandoned -type RespBatchSend struct { - StateEventIDs []id.EventID `json:"state_event_ids"` - EventIDs []id.EventID `json:"event_ids"` - - InsertionEventID id.EventID `json:"insertion_event_id"` - BatchEventID id.EventID `json:"batch_event_id"` - BaseInsertionEventID id.EventID `json:"base_insertion_event_id"` - - NextBatchID id.BatchID `json:"next_batch_id"` -} - type RespBeeperBatchSend struct { EventIDs []id.EventID `json:"event_ids"` } From cc80be150059fce47f46cfa9257f65c3103bb7ba Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 17 Aug 2025 13:45:34 +0300 Subject: [PATCH 237/581] federation/pdu: add method to convert to client event --- federation/pdu/pdu.go | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/federation/pdu/pdu.go b/federation/pdu/pdu.go index 0027f02f..22f7212d 100644 --- a/federation/pdu/pdu.go +++ b/federation/pdu/pdu.go @@ -18,12 +18,15 @@ import ( "encoding/json/v2" "errors" "fmt" + "strings" "time" "github.com/tidwall/gjson" "go.mau.fi/util/jsonbytes" + "go.mau.fi/util/ptr" "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/federation/signutil" "maunium.net/go/mautrix/id" ) @@ -98,6 +101,38 @@ func (pdu *PDU) VerifyContentHash() bool { return hmac.Equal(calculatedHash[:], pdu.Hashes.SHA256) } +func (pdu *PDU) ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error) { + if pdu.Type == "m.room.create" && roomVersion == "" { + roomVersion = id.RoomVersion(gjson.GetBytes(pdu.Content, "room_version").Str) + } + evtType := event.Type{Type: pdu.Type, Class: event.MessageEventType} + if pdu.StateKey != nil { + evtType.Class = event.StateEventType + } + eventID, err := pdu.CalculateEventID(roomVersion) + if err != nil { + return nil, err + } + roomID := pdu.RoomID + if pdu.Type == "m.room.create" && roomVersion.RoomIDIsCreateEventID() { + roomID = id.RoomID(strings.Replace(string(eventID), "$", "!", 1)) + } + evt := &event.Event{ + StateKey: pdu.StateKey, + Sender: pdu.Sender, + Type: evtType, + Timestamp: pdu.OriginServerTS, + ID: eventID, + RoomID: roomID, + Redacts: ptr.Val(pdu.Redacts), + } + err = json.Unmarshal(pdu.Content, &evt.Content) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal content: %w", err) + } + return evt, nil +} + func (pdu *PDU) Sign(roomVersion id.RoomVersion, serverName string, keyID id.KeyID, privateKey ed25519.PrivateKey) error { err := pdu.FillContentHash() if err != nil { From ec663b53d4774335e971951cd55b0049d8489a27 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 17 Aug 2025 20:12:38 +0300 Subject: [PATCH 238/581] federation/pdu: reorganize code and add methods to v1 struct --- federation/pdu/hash.go | 113 ++++++++++++++++++ federation/pdu/pdu.go | 164 ++++---------------------- federation/pdu/pdu_test.go | 6 +- federation/pdu/signature.go | 66 +++++++++++ federation/pdu/v1.go | 228 ++++++++++++++++++++++++++++++++++-- federation/pdu/v1_test.go | 86 ++++++++++++++ 6 files changed, 506 insertions(+), 157 deletions(-) create mode 100644 federation/pdu/hash.go create mode 100644 federation/pdu/signature.go create mode 100644 federation/pdu/v1_test.go diff --git a/federation/pdu/hash.go b/federation/pdu/hash.go new file mode 100644 index 00000000..050029df --- /dev/null +++ b/federation/pdu/hash.go @@ -0,0 +1,113 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + + "github.com/tidwall/gjson" + + "maunium.net/go/mautrix/id" +) + +func (pdu *PDU) CalculateContentHash() ([32]byte, error) { + if pdu == nil { + return [32]byte{}, ErrPDUIsNil + } + pduClone := pdu.Clone() + pduClone.Signatures = nil + pduClone.Unsigned = nil + pduClone.Hashes = nil + rawJSON, err := marshalCanonical(pduClone) + if err != nil { + return [32]byte{}, fmt.Errorf("failed to marshal PDU to calculate content hash: %w", err) + } + return sha256.Sum256(rawJSON), nil +} + +func (pdu *PDU) FillContentHash() error { + if pdu == nil { + return ErrPDUIsNil + } else if pdu.Hashes != nil { + return nil + } else if hash, err := pdu.CalculateContentHash(); err != nil { + return err + } else { + pdu.Hashes = &Hashes{SHA256: hash[:]} + return nil + } +} + +func (pdu *PDU) VerifyContentHash() bool { + if pdu == nil || pdu.Hashes == nil { + return false + } + calculatedHash, err := pdu.CalculateContentHash() + if err != nil { + return false + } + return hmac.Equal(calculatedHash[:], pdu.Hashes.SHA256) +} + +func (pdu *PDU) GetRoomID() (id.RoomID, error) { + if pdu == nil { + return "", ErrPDUIsNil + } else if pdu.Type != "m.room.create" { + return "", fmt.Errorf("room ID can only be calculated for m.room.create events") + } else if roomVersion := id.RoomVersion(gjson.GetBytes(pdu.Content, "room_version").Str); !roomVersion.RoomIDIsCreateEventID() { + return "", fmt.Errorf("room version %s does not use m.room.create event ID as room ID", roomVersion) + } else if evtID, err := pdu.calculateEventID(roomVersion, '!'); err != nil { + return "", fmt.Errorf("failed to calculate event ID: %w", err) + } else { + return id.RoomID(evtID), nil + } +} + +func (pdu *PDU) GetEventID(roomVersion id.RoomVersion) (id.EventID, error) { + return pdu.calculateEventID(roomVersion, '$') +} + +func (pdu *PDU) GetReferenceHash(roomVersion id.RoomVersion) ([32]byte, error) { + if pdu == nil { + return [32]byte{}, ErrPDUIsNil + } + if pdu.Hashes == nil || pdu.Hashes.SHA256 == nil { + if err := pdu.FillContentHash(); err != nil { + return [32]byte{}, err + } + } + rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature(roomVersion)) + if err != nil { + return [32]byte{}, fmt.Errorf("failed to marshal redacted PDU to calculate event ID: %w", err) + } + return sha256.Sum256(rawJSON), nil +} + +func (pdu *PDU) calculateEventID(roomVersion id.RoomVersion, prefix byte) (id.EventID, error) { + referenceHash, err := pdu.GetReferenceHash(roomVersion) + if err != nil { + return "", err + } + eventID := make([]byte, 44) + eventID[0] = prefix + switch roomVersion.EventIDFormat() { + case id.EventIDFormatCustom: + return "", fmt.Errorf("*pdu.PDU can only be used for room v3+") + case id.EventIDFormatBase64: + base64.RawStdEncoding.Encode(eventID[1:], referenceHash[:]) + case id.EventIDFormatURLSafeBase64: + base64.RawURLEncoding.Encode(eventID[1:], referenceHash[:]) + default: + return "", fmt.Errorf("unknown event ID format %v", roomVersion.EventIDFormat()) + } + return id.EventID(eventID), nil +} diff --git a/federation/pdu/pdu.go b/federation/pdu/pdu.go index 22f7212d..dbd4bff1 100644 --- a/federation/pdu/pdu.go +++ b/federation/pdu/pdu.go @@ -11,9 +11,6 @@ package pdu import ( "bytes" "crypto/ed25519" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" "encoding/json/jsontext" "encoding/json/v2" "errors" @@ -27,18 +24,28 @@ import ( "maunium.net/go/mautrix/crypto/canonicaljson" "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/federation/signutil" "maunium.net/go/mautrix/id" ) -var ErrPDUIsNil = errors.New("PDU is nil") +type GetKeyFunc = func(keyID id.KeyID, minValidUntil time.Time) (id.SigningKey, time.Time, error) -type Hashes struct { - SHA256 jsonbytes.UnpaddedBytes `json:"sha256"` - - Unknown jsontext.Value `json:",unknown"` +type AnyPDU interface { + GetRoomID() (id.RoomID, error) + GetEventID(roomVersion id.RoomVersion) (id.EventID, error) + GetReferenceHash(roomVersion id.RoomVersion) ([32]byte, error) + CalculateContentHash() ([32]byte, error) + FillContentHash() error + VerifyContentHash() bool + Sign(roomVersion id.RoomVersion, serverName string, keyID id.KeyID, privateKey ed25519.PrivateKey) error + VerifySignature(roomVersion id.RoomVersion, serverName string, getKey GetKeyFunc) error + ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error) } +var ( + _ AnyPDU = (*PDU)(nil) + _ AnyPDU = (*RoomV1PDU)(nil) +) + type PDU struct { AuthEvents []id.EventID `json:"auth_events"` Content jsontext.Value `json:"content"` @@ -62,43 +69,12 @@ type PDU struct { DeprecatedMembership any `json:"membership,omitzero"` } -func (pdu *PDU) CalculateContentHash() ([32]byte, error) { - if pdu == nil { - return [32]byte{}, ErrPDUIsNil - } - pduClone := pdu.Clone() - pduClone.Signatures = nil - pduClone.Unsigned = nil - pduClone.Hashes = nil - rawJSON, err := marshalCanonical(pduClone) - if err != nil { - return [32]byte{}, fmt.Errorf("failed to marshal PDU to calculate content hash: %w", err) - } - return sha256.Sum256(rawJSON), nil -} +var ErrPDUIsNil = errors.New("PDU is nil") -func (pdu *PDU) FillContentHash() error { - if pdu == nil { - return ErrPDUIsNil - } else if pdu.Hashes != nil { - return nil - } else if hash, err := pdu.CalculateContentHash(); err != nil { - return err - } else { - pdu.Hashes = &Hashes{SHA256: hash[:]} - return nil - } -} +type Hashes struct { + SHA256 jsonbytes.UnpaddedBytes `json:"sha256"` -func (pdu *PDU) VerifyContentHash() bool { - if pdu == nil || pdu.Hashes == nil { - return false - } - calculatedHash, err := pdu.CalculateContentHash() - if err != nil { - return false - } - return hmac.Equal(calculatedHash[:], pdu.Hashes.SHA256) + Unknown jsontext.Value `json:",unknown"` } func (pdu *PDU) ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error) { @@ -109,7 +85,7 @@ func (pdu *PDU) ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error) if pdu.StateKey != nil { evtType.Class = event.StateEventType } - eventID, err := pdu.CalculateEventID(roomVersion) + eventID, err := pdu.GetEventID(roomVersion) if err != nil { return nil, err } @@ -133,26 +109,6 @@ func (pdu *PDU) ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error) return evt, nil } -func (pdu *PDU) Sign(roomVersion id.RoomVersion, serverName string, keyID id.KeyID, privateKey ed25519.PrivateKey) error { - err := pdu.FillContentHash() - if err != nil { - return err - } - rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature(roomVersion)) - if err != nil { - return fmt.Errorf("failed to marshal redacted PDU to sign: %w", err) - } - signature := ed25519.Sign(privateKey, rawJSON) - if pdu.Signatures == nil { - pdu.Signatures = make(map[string]map[id.KeyID]string) - } - if _, ok := pdu.Signatures[serverName]; !ok { - pdu.Signatures[serverName] = make(map[id.KeyID]string) - } - pdu.Signatures[serverName][keyID] = base64.RawStdEncoding.EncodeToString(signature) - return nil -} - func marshalCanonical(data any) (jsontext.Value, error) { marshaledBytes, err := json.Marshal(data) if err != nil { @@ -171,81 +127,3 @@ func marshalCanonical(data any) (jsontext.Value, error) { } return marshaled, nil } - -func (pdu *PDU) VerifySignature( - roomVersion id.RoomVersion, - serverName string, - getKey func(keyID id.KeyID, minValidUntil time.Time) (id.SigningKey, time.Time, error), -) error { - rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature(roomVersion)) - if err != nil { - return fmt.Errorf("failed to marshal redacted PDU to verify signature: %w", err) - } - verified := false - for keyID, sig := range pdu.Signatures[serverName] { - originServerTS := time.UnixMilli(pdu.OriginServerTS) - key, validUntil, err := getKey(keyID, originServerTS) - if err != nil { - return fmt.Errorf("failed to get key %s for %s: %w", keyID, serverName, err) - } else if key == "" { - return fmt.Errorf("key %s not found for %s", keyID, serverName) - } else if validUntil.Before(originServerTS) && roomVersion.EnforceSigningKeyValidity() { - return fmt.Errorf("key %s for %s is only valid until %s, but event is from %s", keyID, serverName, validUntil, originServerTS) - } else if err = signutil.VerifyJSONRaw(key, sig, rawJSON); err != nil { - return fmt.Errorf("failed to verify signature from key %s: %w", keyID, err) - } else { - verified = true - } - } - if !verified { - return fmt.Errorf("no verifiable signatures found for server %s", serverName) - } - return nil -} - -func (pdu *PDU) CalculateRoomID() (id.RoomID, error) { - if pdu == nil { - return "", ErrPDUIsNil - } else if pdu.Type != "m.room.create" { - return "", fmt.Errorf("room ID can only be calculated for m.room.create events") - } else if roomVersion := id.RoomVersion(gjson.GetBytes(pdu.Content, "room_version").Str); !roomVersion.RoomIDIsCreateEventID() { - return "", fmt.Errorf("room version %s does not use m.room.create event ID as room ID", roomVersion) - } else if evtID, err := pdu.calculateEventID(roomVersion, '!'); err != nil { - return "", fmt.Errorf("failed to calculate event ID: %w", err) - } else { - return id.RoomID(evtID), nil - } -} - -func (pdu *PDU) CalculateEventID(roomVersion id.RoomVersion) (id.EventID, error) { - return pdu.calculateEventID(roomVersion, '$') -} - -func (pdu *PDU) calculateEventID(roomVersion id.RoomVersion, prefix byte) (id.EventID, error) { - if pdu == nil { - return "", ErrPDUIsNil - } - if pdu.Hashes == nil || pdu.Hashes.SHA256 == nil { - if err := pdu.FillContentHash(); err != nil { - return "", err - } - } - rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature(roomVersion)) - if err != nil { - return "", fmt.Errorf("failed to marshal redacted PDU to calculate event ID: %w", err) - } - referenceHash := sha256.Sum256(rawJSON) - eventID := make([]byte, 44) - eventID[0] = prefix - switch roomVersion.EventIDFormat() { - case id.EventIDFormatCustom: - return "", fmt.Errorf("*pdu.PDU can only be used for room v3+") - case id.EventIDFormatBase64: - base64.RawStdEncoding.Encode(eventID[1:], referenceHash[:]) - case id.EventIDFormatURLSafeBase64: - base64.RawURLEncoding.Encode(eventID[1:], referenceHash[:]) - default: - return "", fmt.Errorf("unknown event ID format %v", roomVersion.EventIDFormat()) - } - return id.EventID(eventID), nil -} diff --git a/federation/pdu/pdu_test.go b/federation/pdu/pdu_test.go index a650672c..9f6fe74a 100644 --- a/federation/pdu/pdu_test.go +++ b/federation/pdu/pdu_test.go @@ -10,7 +10,7 @@ package pdu_test import ( "encoding/base64" - "encoding/json" + "encoding/json/v2" "testing" "time" @@ -171,10 +171,10 @@ func TestPDU_VerifyContentHash(t *testing.T) { } } -func TestPDU_CalculateEventID(t *testing.T) { +func TestPDU_GetEventID(t *testing.T) { for _, test := range testPDUs { t.Run(test.name, func(t *testing.T) { - gotEventID := exerrors.Must(parsePDU(test.pdu).CalculateEventID(test.roomVersion)) + gotEventID := exerrors.Must(parsePDU(test.pdu).GetEventID(test.roomVersion)) assert.Equal(t, test.eventID, gotEventID) }) } diff --git a/federation/pdu/signature.go b/federation/pdu/signature.go new file mode 100644 index 00000000..1f8ae0b5 --- /dev/null +++ b/federation/pdu/signature.go @@ -0,0 +1,66 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + "time" + + "maunium.net/go/mautrix/federation/signutil" + "maunium.net/go/mautrix/id" +) + +func (pdu *PDU) Sign(roomVersion id.RoomVersion, serverName string, keyID id.KeyID, privateKey ed25519.PrivateKey) error { + err := pdu.FillContentHash() + if err != nil { + return err + } + rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature(roomVersion)) + if err != nil { + return fmt.Errorf("failed to marshal redacted PDU to sign: %w", err) + } + signature := ed25519.Sign(privateKey, rawJSON) + if pdu.Signatures == nil { + pdu.Signatures = make(map[string]map[id.KeyID]string) + } + if _, ok := pdu.Signatures[serverName]; !ok { + pdu.Signatures[serverName] = make(map[id.KeyID]string) + } + pdu.Signatures[serverName][keyID] = base64.RawStdEncoding.EncodeToString(signature) + return nil +} + +func (pdu *PDU) VerifySignature(roomVersion id.RoomVersion, serverName string, getKey GetKeyFunc) error { + rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature(roomVersion)) + if err != nil { + return fmt.Errorf("failed to marshal redacted PDU to verify signature: %w", err) + } + verified := false + for keyID, sig := range pdu.Signatures[serverName] { + originServerTS := time.UnixMilli(pdu.OriginServerTS) + key, validUntil, err := getKey(keyID, originServerTS) + if err != nil { + return fmt.Errorf("failed to get key %s for %s: %w", keyID, serverName, err) + } else if key == "" { + return fmt.Errorf("key %s not found for %s", keyID, serverName) + } else if validUntil.Before(originServerTS) && roomVersion.EnforceSigningKeyValidity() { + return fmt.Errorf("key %s for %s is only valid until %s, but event is from %s", keyID, serverName, validUntil, originServerTS) + } else if err = signutil.VerifyJSONRaw(key, sig, rawJSON); err != nil { + return fmt.Errorf("failed to verify signature from key %s: %w", keyID, err) + } else { + verified = true + } + } + if !verified { + return fmt.Errorf("no verifiable signatures found for server %s", serverName) + } + return nil +} diff --git a/federation/pdu/v1.go b/federation/pdu/v1.go index e8aa82fc..795253db 100644 --- a/federation/pdu/v1.go +++ b/federation/pdu/v1.go @@ -9,36 +9,95 @@ package pdu import ( + "crypto/ed25519" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "time" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/federation/signutil" "maunium.net/go/mautrix/id" ) +type V1EventReference struct { + ID id.EventID + Hashes Hashes +} + +var ( + _ json.UnmarshalerFrom = (*V1EventReference)(nil) + _ json.MarshalerTo = (*V1EventReference)(nil) +) + +func (er *V1EventReference) MarshalJSONTo(enc *jsontext.Encoder) error { + return json.MarshalEncode(enc, []any{er.ID, er.Hashes}) +} + +func (er *V1EventReference) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + var ref V1EventReference + var data []jsontext.Value + if err := json.UnmarshalDecode(dec, &data); err != nil { + return err + } else if len(data) != 2 { + return fmt.Errorf("V1EventReference.UnmarshalJSONFrom: expected array with 2 elements, got %d", len(data)) + } else if err = json.Unmarshal(data[0], &ref.ID); err != nil { + return fmt.Errorf("V1EventReference.UnmarshalJSONFrom: failed to unmarshal event ID: %w", err) + } else if err = json.Unmarshal(data[1], &ref.Hashes); err != nil { + return fmt.Errorf("V1EventReference.UnmarshalJSONFrom: failed to unmarshal hashes: %w", err) + } + *er = ref + return nil +} + type RoomV1PDU struct { - AuthEvents [][]string `json:"auth_events"` + AuthEvents []V1EventReference `json:"auth_events"` Content jsontext.Value `json:"content"` Depth int64 `json:"depth"` EventID id.EventID `json:"event_id"` - Hashes *Hashes `json:"hashes,omitempty"` + Hashes *Hashes `json:"hashes,omitzero"` OriginServerTS int64 `json:"origin_server_ts"` - PrevEvents [][]string `json:"prev_events"` - Redacts *id.EventID `json:"redacts,omitempty"` + PrevEvents []V1EventReference `json:"prev_events"` + Redacts *id.EventID `json:"redacts,omitzero"` RoomID id.RoomID `json:"room_id"` Sender id.UserID `json:"sender"` - Signatures map[string]map[id.KeyID]string `json:"signatures,omitempty"` - StateKey *string `json:"state_key,omitempty"` + Signatures map[string]map[id.KeyID]string `json:"signatures,omitzero"` + StateKey *string `json:"state_key,omitzero"` Type string `json:"type"` + Unsigned jsontext.Value `json:"unsigned,omitzero"` Unknown jsontext.Value `json:",unknown"` // Deprecated legacy fields - DeprecatedPrevState any `json:"prev_state,omitempty"` - DeprecatedOrigin any `json:"origin,omitempty"` - DeprecatedMembership any `json:"membership,omitempty"` + DeprecatedPrevState any `json:"prev_state,omitzero"` + DeprecatedOrigin any `json:"origin,omitzero"` + DeprecatedMembership any `json:"membership,omitzero"` } -func (pdu *RoomV1PDU) Redact() { +func (pdu *RoomV1PDU) GetRoomID() (id.RoomID, error) { + return pdu.RoomID, nil +} + +func (pdu *RoomV1PDU) GetEventID(roomVersion id.RoomVersion) (id.EventID, error) { + if !pdu.SupportsRoomVersion(roomVersion) { + return "", fmt.Errorf("RoomV1PDU.GetEventID: unsupported room version %s", roomVersion) + } + return pdu.EventID, nil +} + +func (pdu *RoomV1PDU) RedactForSignature() *RoomV1PDU { + pdu.Signatures = nil + return pdu.Redact() +} + +func (pdu *RoomV1PDU) Redact() *RoomV1PDU { pdu.Unknown = nil + pdu.Unsigned = nil switch pdu.Type { case "m.room.member": @@ -54,6 +113,153 @@ func (pdu *RoomV1PDU) Redact() { case "m.room.aliases": pdu.Content = filteredObject(pdu.Content, "aliases") default: - pdu.Content = jsontext.Value("{}") + pdu.Content = emptyObject + } + return pdu +} + +func (pdu *RoomV1PDU) GetReferenceHash(roomVersion id.RoomVersion) ([32]byte, error) { + if !pdu.SupportsRoomVersion(roomVersion) { + return [32]byte{}, fmt.Errorf("RoomV1PDU.GetReferenceHash: unsupported room version %s", roomVersion) + } + if pdu == nil { + return [32]byte{}, ErrPDUIsNil + } + if pdu.Hashes == nil || pdu.Hashes.SHA256 == nil { + if err := pdu.FillContentHash(); err != nil { + return [32]byte{}, err + } + } + rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature()) + if err != nil { + return [32]byte{}, fmt.Errorf("failed to marshal redacted PDU to calculate event ID: %w", err) + } + return sha256.Sum256(rawJSON), nil +} + +func (pdu *RoomV1PDU) CalculateContentHash() ([32]byte, error) { + if pdu == nil { + return [32]byte{}, ErrPDUIsNil + } + pduClone := pdu.Clone() + pduClone.Signatures = nil + pduClone.Unsigned = nil + pduClone.Hashes = nil + rawJSON, err := marshalCanonical(pduClone) + if err != nil { + return [32]byte{}, fmt.Errorf("failed to marshal PDU to calculate content hash: %w", err) + } + return sha256.Sum256(rawJSON), nil +} + +func (pdu *RoomV1PDU) FillContentHash() error { + if pdu == nil { + return ErrPDUIsNil + } else if pdu.Hashes != nil { + return nil + } else if hash, err := pdu.CalculateContentHash(); err != nil { + return err + } else { + pdu.Hashes = &Hashes{SHA256: hash[:]} + return nil } } + +func (pdu *RoomV1PDU) VerifyContentHash() bool { + if pdu == nil || pdu.Hashes == nil { + return false + } + calculatedHash, err := pdu.CalculateContentHash() + if err != nil { + return false + } + return hmac.Equal(calculatedHash[:], pdu.Hashes.SHA256) +} + +func (pdu *RoomV1PDU) Clone() *RoomV1PDU { + return ptr.Clone(pdu) +} + +func (pdu *RoomV1PDU) Sign(roomVersion id.RoomVersion, serverName string, keyID id.KeyID, privateKey ed25519.PrivateKey) error { + if !pdu.SupportsRoomVersion(roomVersion) { + return fmt.Errorf("RoomV1PDU.Sign: unsupported room version %s", roomVersion) + } + err := pdu.FillContentHash() + if err != nil { + return err + } + rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature()) + if err != nil { + return fmt.Errorf("failed to marshal redacted PDU to sign: %w", err) + } + signature := ed25519.Sign(privateKey, rawJSON) + if pdu.Signatures == nil { + pdu.Signatures = make(map[string]map[id.KeyID]string) + } + if _, ok := pdu.Signatures[serverName]; !ok { + pdu.Signatures[serverName] = make(map[id.KeyID]string) + } + pdu.Signatures[serverName][keyID] = base64.RawStdEncoding.EncodeToString(signature) + return nil +} + +func (pdu *RoomV1PDU) VerifySignature(roomVersion id.RoomVersion, serverName string, getKey GetKeyFunc) error { + if !pdu.SupportsRoomVersion(roomVersion) { + return fmt.Errorf("RoomV1PDU.VerifySignature: unsupported room version %s", roomVersion) + } + rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature()) + if err != nil { + return fmt.Errorf("failed to marshal redacted PDU to verify signature: %w", err) + } + verified := false + for keyID, sig := range pdu.Signatures[serverName] { + originServerTS := time.UnixMilli(pdu.OriginServerTS) + key, _, err := getKey(keyID, originServerTS) + if err != nil { + return fmt.Errorf("failed to get key %s for %s: %w", keyID, serverName, err) + } else if key == "" { + return fmt.Errorf("key %s not found for %s", keyID, serverName) + } else if err = signutil.VerifyJSONRaw(key, sig, rawJSON); err != nil { + return fmt.Errorf("failed to verify signature from key %s: %w", keyID, err) + } else { + verified = true + } + } + if !verified { + return fmt.Errorf("no verifiable signatures found for server %s", serverName) + } + return nil +} + +func (pdu *RoomV1PDU) SupportsRoomVersion(roomVersion id.RoomVersion) bool { + switch roomVersion { + case id.RoomV0, id.RoomV1, id.RoomV2: + return true + default: + return false + } +} + +func (pdu *RoomV1PDU) ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error) { + if !pdu.SupportsRoomVersion(roomVersion) { + return nil, fmt.Errorf("RoomV1PDU.ToClientEvent: unsupported room version %s", roomVersion) + } + evtType := event.Type{Type: pdu.Type, Class: event.MessageEventType} + if pdu.StateKey != nil { + evtType.Class = event.StateEventType + } + evt := &event.Event{ + StateKey: pdu.StateKey, + Sender: pdu.Sender, + Type: evtType, + Timestamp: pdu.OriginServerTS, + ID: pdu.EventID, + RoomID: pdu.RoomID, + Redacts: ptr.Val(pdu.Redacts), + } + err := json.Unmarshal(pdu.Content, &evt.Content) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal content: %w", err) + } + return evt, nil +} diff --git a/federation/pdu/v1_test.go b/federation/pdu/v1_test.go new file mode 100644 index 00000000..e5531b0b --- /dev/null +++ b/federation/pdu/v1_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu_test + +import ( + "encoding/base64" + "encoding/json/v2" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.mau.fi/util/exerrors" + + "maunium.net/go/mautrix/federation/pdu" + "maunium.net/go/mautrix/id" +) + +var testV1PDUs = []testPDU{{ + name: "m.room.message in v1 room", + pdu: `{"auth_events":[["$159234730483190eXavq:matrix.org",{"sha256":"VprZrhMqOQyKbfF3UE26JXE8D27ih4R/FGGc8GZ0Whs"}],["$143454825711DhCxH:matrix.org",{"sha256":"3sJh/5GOB094OKuhbjL634Gt69YIcge9GD55ciJa9ok"}],["$156837651426789wiPdh:maunium.net",{"sha256":"FGyR3sxJ/VxYabDkO/5qtwrPR3hLwGknJ0KX0w3GUHE"}]],"content":{"body":"photo-1526336024174-e58f5cdd8e13.jpg","info":{"h":1620,"mimetype":"image/jpeg","size":208053,"w":1080},"msgtype":"m.image","url":"mxc://maunium.net/aEqEghIjFPAerIhCxJCYpQeC"},"depth":16669,"event_id":"$16738169022163bokdi:maunium.net","hashes":{"sha256":"XYB47Gf2vAci3BTguIJaC75ZYGMuVY65jcvoUVgpcLA"},"origin":"maunium.net","origin_server_ts":1673816902100,"prev_events":[["$1673816901121325UMCjA:matrix.org",{"sha256":"t7e0IYHLI3ydIPoIU8a8E/pIWXH9cNLlQBEtGyGtHwc"}]],"room_id":"!jhpZBTbckszblMYjMK:matrix.org","sender":"@cat:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"uRZbEm+P+Y1ZVgwBn5I6SlaUZdzlH1bB4nv81yt5EIQ0b1fZ8YgM4UWMijrrXp3+NmqRFl0cakSM3MneJOtFCw"}},"unsigned":{"age_ts":1673816902100}}`, + eventID: "$16738169022163bokdi:maunium.net", + roomVersion: id.RoomV1, + serverDetails: mauniumNet, +}, { + name: "m.room.create in v1 room", + pdu: `{"origin": "matrix.org", "signatures": {"matrix.org": {"ed25519:auto": "XTejpXn5REoHrZWgCpJglGX7MfOWS2zUjYwJRLrwW2PQPbFdqtL+JnprBXwIP2C1NmgWSKG+am1QdApu0KoHCQ"}}, "origin_server_ts": 1434548257426, "sender": "@appservice-irc:matrix.org", "event_id": "$143454825711DhCxH:matrix.org", "prev_events": [], "unsigned": {"age": 12872287834}, "state_key": "", "content": {"creator": "@appservice-irc:matrix.org"}, "depth": 1, "prev_state": [], "room_id": "!jhpZBTbckszblMYjMK:matrix.org", "auth_events": [], "hashes": {"sha256": "+SSdmeeoKI/6yK6sY4XAFljWFiugSlCiXQf0QMCZjTs"}, "type": "m.room.create"}`, + eventID: "$143454825711DhCxH:matrix.org", + roomVersion: id.RoomV1, + serverDetails: matrixOrg, +}, { + name: "m.room.member in v1 room", + pdu: `{"auth_events": [["$1536447669931522zlyWe:matrix.org", {"sha256": "UkzPGd7cPAGvC0FVx3Yy2/Q0GZhA2kcgj8MGp5pjYV8"}], ["$143454825711DhCxH:matrix.org", {"sha256": "3sJh/5GOB094OKuhbjL634Gt69YIcge9GD55ciJa9ok"}], ["$143454825714nUEqZ:matrix.org", {"sha256": "NjuZXu8EDMfIfejPcNlC/IdnKQAGpPIcQjHaf0BZaHk"}]], "prev_events": [["$15660585503271JRRMm:maunium.net", {"sha256": "/Sm7uSLkYMHapp6I3NuEVJlk2JucW2HqjsQy9vzhciA"}]], "type": "m.room.member", "room_id": "!jhpZBTbckszblMYjMK:matrix.org", "sender": "@tulir:maunium.net", "content": {"membership": "join", "avatar_url": "mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO", "displayname": "tulir"}, "depth": 10485, "prev_state": [], "state_key": "@tulir:maunium.net", "event_id": "$15660585693272iEryv:maunium.net", "origin": "maunium.net", "origin_server_ts": 1566058569201, "hashes": {"sha256": "1D6fdDzKsMGCxSqlXPA7I9wGQNTutVuJke1enGHoWK8"}, "signatures": {"maunium.net": {"ed25519:a_xxeS": "Lj/zDK6ozr4vgsxyL8jY56wTGWoA4jnlvkTs5paCX1w3nNKHnQnSMi+wuaqI6yv5vYh9usGWco2LLMuMzYXcBg"}}, "unsigned": {"age_ts": 1566058569201, "replaces_state": "$15660585383268liyBc:maunium.net"}}`, + eventID: "$15660585693272iEryv:maunium.net", + roomVersion: id.RoomV1, + serverDetails: mauniumNet, +}} + +func parseV1PDU(pdu string) (out *pdu.RoomV1PDU) { + exerrors.PanicIfNotNil(json.Unmarshal([]byte(pdu), &out)) + return +} + +func TestRoomV1PDU_CalculateContentHash(t *testing.T) { + for _, test := range testV1PDUs { + t.Run(test.name, func(t *testing.T) { + parsed := parseV1PDU(test.pdu) + contentHash := exerrors.Must(parsed.CalculateContentHash()) + assert.Equal( + t, + base64.RawStdEncoding.EncodeToString(parsed.Hashes.SHA256), + base64.RawStdEncoding.EncodeToString(contentHash[:]), + ) + }) + } +} + +func TestRoomV1PDU_VerifyContentHash(t *testing.T) { + for _, test := range testV1PDUs { + t.Run(test.name, func(t *testing.T) { + parsed := parseV1PDU(test.pdu) + assert.True(t, parsed.VerifyContentHash()) + }) + } +} + +func TestRoomV1PDU_VerifySignature(t *testing.T) { + for _, test := range testV1PDUs { + t.Run(test.name, func(t *testing.T) { + parsed := parseV1PDU(test.pdu) + err := parsed.VerifySignature(test.roomVersion, test.serverName, func(keyID id.KeyID, _ time.Time) (id.SigningKey, time.Time, error) { + key, ok := test.keys[keyID] + if ok { + return key.key, key.validUntilTS, nil + } + return "", time.Time{}, nil + }) + assert.NoError(t, err) + }) + } +} From ca4ca62249f9906b2bf900b3dc0f60630abeb7b6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 17 Aug 2025 20:24:13 +0300 Subject: [PATCH 239/581] federation/pdu: add docs for GetKeyFunc --- federation/pdu/pdu.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/federation/pdu/pdu.go b/federation/pdu/pdu.go index dbd4bff1..860201c5 100644 --- a/federation/pdu/pdu.go +++ b/federation/pdu/pdu.go @@ -27,7 +27,13 @@ import ( "maunium.net/go/mautrix/id" ) -type GetKeyFunc = func(keyID id.KeyID, minValidUntil time.Time) (id.SigningKey, time.Time, error) +// GetKeyFunc is a callback for retrieving the key corresponding to a given key ID when verifying the signature of a PDU. +// +// The input time is the timestamp of the event. The function should attempt to fetch a key that is +// valid at or after this time, but if that is not possible, the latest available key should be +// returned without an error. The verify function will do its own validity checking based on the +// returned valid until timestamp. +type GetKeyFunc = func(keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) type AnyPDU interface { GetRoomID() (id.RoomID, error) From d1004d42b090e1d566815f4ea57608ece459d036 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 18 Aug 2025 00:24:57 +0300 Subject: [PATCH 240/581] client: add method to download media thumbnail --- client.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/client.go b/client.go index 1536ae52..78f83b85 100644 --- a/client.go +++ b/client.go @@ -1704,6 +1704,38 @@ func (cli *Client) Download(ctx context.Context, mxcURL id.ContentURI) (*http.Re return resp, err } +type DownloadThumbnailExtra struct { + Method string + Animated bool +} + +func (cli *Client) DownloadThumbnail(ctx context.Context, mxcURL id.ContentURI, height, width int, extras ...DownloadThumbnailExtra) (*http.Response, error) { + if len(extras) > 1 { + panic(fmt.Errorf("invalid number of arguments to DownloadThumbnail: %d", len(extras))) + } + var extra DownloadThumbnailExtra + if len(extras) == 1 { + extra = extras[0] + } + path := ClientURLPath{"v1", "media", "thumbnail", mxcURL.Homeserver, mxcURL.FileID} + query := map[string]string{ + "height": strconv.Itoa(height), + "width": strconv.Itoa(width), + } + if extra.Method != "" { + query["method"] = extra.Method + } + if extra.Animated { + query["animated"] = "true" + } + _, resp, err := cli.MakeFullRequestWithResp(ctx, FullRequest{ + Method: http.MethodGet, + URL: cli.BuildURLWithQuery(path, query), + DontReadResponse: true, + }) + return resp, err +} + func (cli *Client) DownloadBytes(ctx context.Context, mxcURL id.ContentURI) ([]byte, error) { resp, err := cli.Download(ctx, mxcURL) if err != nil { From 05b711d1816b1603df459995b4350008238b676c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 18 Aug 2025 00:53:23 +0300 Subject: [PATCH 241/581] federation/pdu: add more tests for signature checks --- federation/pdu/hash_test.go | 49 +++++++++++++++ federation/pdu/pdu_test.go | 85 ++++++++------------------ federation/pdu/signature_test.go | 102 +++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 59 deletions(-) create mode 100644 federation/pdu/hash_test.go create mode 100644 federation/pdu/signature_test.go diff --git a/federation/pdu/hash_test.go b/federation/pdu/hash_test.go new file mode 100644 index 00000000..35ea49df --- /dev/null +++ b/federation/pdu/hash_test.go @@ -0,0 +1,49 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu_test + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "go.mau.fi/util/exerrors" +) + +func TestPDU_CalculateContentHash(t *testing.T) { + for _, test := range testPDUs { + t.Run(test.name, func(t *testing.T) { + parsed := parsePDU(test.pdu) + contentHash := exerrors.Must(parsed.CalculateContentHash()) + assert.Equal( + t, + base64.RawStdEncoding.EncodeToString(parsed.Hashes.SHA256), + base64.RawStdEncoding.EncodeToString(contentHash[:]), + ) + }) + } +} + +func TestPDU_VerifyContentHash(t *testing.T) { + for _, test := range testPDUs { + t.Run(test.name, func(t *testing.T) { + parsed := parsePDU(test.pdu) + assert.True(t, parsed.VerifyContentHash()) + }) + } +} + +func TestPDU_GetEventID(t *testing.T) { + for _, test := range testPDUs { + t.Run(test.name, func(t *testing.T) { + gotEventID := exerrors.Must(parsePDU(test.pdu).GetEventID(test.roomVersion)) + assert.Equal(t, test.eventID, gotEventID) + }) + } +} diff --git a/federation/pdu/pdu_test.go b/federation/pdu/pdu_test.go index 9f6fe74a..f4920098 100644 --- a/federation/pdu/pdu_test.go +++ b/federation/pdu/pdu_test.go @@ -9,12 +9,9 @@ package pdu_test import ( - "encoding/base64" "encoding/json/v2" - "testing" "time" - "github.com/stretchr/testify/assert" "go.mau.fi/util/exerrors" "maunium.net/go/mautrix/federation/pdu" @@ -31,6 +28,14 @@ type serverDetails struct { keys map[id.KeyID]serverKey } +func (sd serverDetails) getKey(keyID id.KeyID, _ time.Time) (id.SigningKey, time.Time, error) { + key, ok := sd.keys[keyID] + if ok { + return key.key, key.validUntilTS, nil + } + return "", time.Time{}, nil +} + var mauniumNet = serverDetails{ serverName: "maunium.net", keys: map[id.KeyID]serverKey{ @@ -75,7 +80,23 @@ type testPDU struct { serverDetails } -var testPDUs = []testPDU{{ +var roomV4MessageTestPDU = testPDU{ + name: "m.room.message in v4 room", + pdu: `{"auth_events":["$OB87jNemaIVDHAfu0-pa_cP7OPFXUXCbFpjYVi8gll4","$RaWbTF9wQfGQgUpe1S13wzICtGTB2PNKRHUNHu9IO1c","$ZmEWOXw6cC4Rd1wTdY5OzeLJVzjhrkxFPwwKE4gguGk"],"content":{"body":"the last one is saying it shouldn't have effects","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":13103,"hashes":{"sha256":"c2wb8qMlvzIPCP1Wd+eYZ4BRgnGYxS97dR1UlJjVMeg"},"origin_server_ts":1752875275263,"prev_events":["$-7_BMI3BXwj3ayoxiJvraJxYWTKwjiQ6sh7CW_Brvj0"],"room_id":"!JiiOHXrIUCtcOJsZCa:matrix.org","sender":"@tulir:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"99TAqHpBkUEtgCraXsVXogmf/hnijPbgbG9eACtA+mbix3Y6gURI4QGQgcX/NhcE3pJQZ/YDjmbuvCnKvEccAA"}},"unsigned":{"age_ts":1752875275281}}`, + eventID: "$Jo_lmFR-e6lzrimzCA7DevIn2OwhuQYmd9xkcJBoqAA", + roomVersion: id.RoomV4, + serverDetails: mauniumNet, +} + +var roomV12MessageTestPDU = testPDU{ + name: "m.room.message in v12 room", + pdu: `{"auth_events":["$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA","$hyeL_nU_L3tsZ2dtZZpAHk0Skv-PqFQIipuII_By584"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":122,"hashes":{"sha256":"IQ0zlc+PXeEs6R3JvRkW3xTPV3zlGKSSd3x07KXGjzs"},"origin_server_ts":1755384351627,"prev_events":["$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA"],"room_id":"!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ","sender":"@tulir_test:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"0GDMddL2k7gF4V1VU8sL3wTfhAIzAu5iVH5jeavZ2VEg3J9/tHLWXAOn2tzkLaMRWl0/XpINT2YlH/rd2U21Ag"}},"unsigned":{"age_ts":1755384351627}}`, + eventID: "$xmP-wZfpannuHG-Akogi6c4YvqxChMtdyYbUMGOrMWc", + roomVersion: id.RoomV12, + serverDetails: mauniumNet, +} + +var testPDUs = []testPDU{roomV4MessageTestPDU, { name: "m.room.message in v5 room", pdu: `{"auth_events":["$hp0ImHqYgHTRbLeWKPeTeFmxdb5SdMJN9cfmTrTk7d0","$KAj7X7tnJbR9qYYMWJSw-1g414_KlPptbbkZm7_kUtg","$V-2ShOwZYhA_nxMijaf3lqFgIJgzE2UMeFPtOLnoBYM"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":2248,"hashes":{"sha256":"kV+JuLbWXJ2r6PjHT3wt8bFc/TfI1nTaSN3Lamg/xHs"},"origin_server_ts":1755422945654,"prev_events":["$49lFLem2Nk4dxHk9RDXxTdaq9InIJpmkHpzVnjKcYwg"],"room_id":"!vzBgJsjNzgHSdWsmki:mozilla.org","sender":"@tulir:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"JIl60uVgfCLBZLPoSiE7wVkJ9U5cNEPVPuv1sCCYUOq5yOW56WD1adgpBUdX2UFpYkCHvkRnyQGxU0+6HBp5BA"}},"unsigned":{"age_ts":1755422945673}}`, eventID: "$Qn4tHfuAe6PlnKXPZnygAU9wd6RXqMKtt_ZzstHTSgA", @@ -93,13 +114,7 @@ var testPDUs = []testPDU{{ eventID: `$qkWfTL7_l3oRZO2CItW8-Q0yAmi_l_1ua629ZDqponE`, roomVersion: id.RoomV11, serverDetails: mauniumNet, -}, { - name: "m.room.message in v12 room", - pdu: `{"auth_events":["$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA","$hyeL_nU_L3tsZ2dtZZpAHk0Skv-PqFQIipuII_By584"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":122,"hashes":{"sha256":"IQ0zlc+PXeEs6R3JvRkW3xTPV3zlGKSSd3x07KXGjzs"},"origin_server_ts":1755384351627,"prev_events":["$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA"],"room_id":"!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ","sender":"@tulir_test:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"0GDMddL2k7gF4V1VU8sL3wTfhAIzAu5iVH5jeavZ2VEg3J9/tHLWXAOn2tzkLaMRWl0/XpINT2YlH/rd2U21Ag"}},"unsigned":{"age_ts":1755384351627}}`, - eventID: "$xmP-wZfpannuHG-Akogi6c4YvqxChMtdyYbUMGOrMWc", - roomVersion: id.RoomV12, - serverDetails: mauniumNet, -}, { +}, roomV12MessageTestPDU, { name: "m.room.create in v4 room", pdu: `{"auth_events": [], "prev_events": [], "type": "m.room.create", "room_id": "!jxlRxnrZCsjpjDubDX:matrix.org", "sender": "@neilj:matrix.org", "content": {"room_version": "4", "predecessor": {"room_id": "!DYgXKezaHgMbiPMzjX:matrix.org", "event_id": "$156171636353XwPJT:matrix.org"}, "creator": "@neilj:matrix.org"}, "depth": 1, "prev_state": [], "state_key": "", "origin": "matrix.org", "origin_server_ts": 1561716363993, "hashes": {"sha256": "9tj8GpXjTAJvdNAbnuKLemZZk+Tjv2LAbGodSX6nJAo"}, "signatures": {"matrix.org": {"ed25519:auto": "2+sNt8uJUhzU4GPxnFVYtU2ZRgFdtVLT1vEZGUdJYN40zBpwYEGJy+kyb5matA+8/yLeYD9gu1O98lhleH0aCA"}}, "unsigned": {"age": 104769}}`, eventID: "$ay_9_nPilrTpb3UxIwHHBBfFjTJb6hBAE_JzQwSjqeY", @@ -147,51 +162,3 @@ func parsePDU(pdu string) (out *pdu.PDU) { exerrors.PanicIfNotNil(json.Unmarshal([]byte(pdu), &out)) return } - -func TestPDU_CalculateContentHash(t *testing.T) { - for _, test := range testPDUs { - t.Run(test.name, func(t *testing.T) { - parsed := parsePDU(test.pdu) - contentHash := exerrors.Must(parsed.CalculateContentHash()) - assert.Equal( - t, - base64.RawStdEncoding.EncodeToString(parsed.Hashes.SHA256), - base64.RawStdEncoding.EncodeToString(contentHash[:]), - ) - }) - } -} - -func TestPDU_VerifyContentHash(t *testing.T) { - for _, test := range testPDUs { - t.Run(test.name, func(t *testing.T) { - parsed := parsePDU(test.pdu) - assert.True(t, parsed.VerifyContentHash()) - }) - } -} - -func TestPDU_GetEventID(t *testing.T) { - for _, test := range testPDUs { - t.Run(test.name, func(t *testing.T) { - gotEventID := exerrors.Must(parsePDU(test.pdu).GetEventID(test.roomVersion)) - assert.Equal(t, test.eventID, gotEventID) - }) - } -} - -func TestPDU_VerifySignature(t *testing.T) { - for _, test := range testPDUs { - t.Run(test.name, func(t *testing.T) { - parsed := parsePDU(test.pdu) - err := parsed.VerifySignature(test.roomVersion, test.serverName, func(keyID id.KeyID, _ time.Time) (id.SigningKey, time.Time, error) { - key, ok := test.keys[keyID] - if ok { - return key.key, key.validUntilTS, nil - } - return "", time.Time{}, nil - }) - assert.NoError(t, err) - }) - } -} diff --git a/federation/pdu/signature_test.go b/federation/pdu/signature_test.go new file mode 100644 index 00000000..68e7a773 --- /dev/null +++ b/federation/pdu/signature_test.go @@ -0,0 +1,102 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu_test + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json/jsontext" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mau.fi/util/exerrors" + + "maunium.net/go/mautrix/federation/pdu" + "maunium.net/go/mautrix/id" +) + +func TestPDU_VerifySignature(t *testing.T) { + for _, test := range testPDUs { + t.Run(test.name, func(t *testing.T) { + parsed := parsePDU(test.pdu) + err := parsed.VerifySignature(test.roomVersion, test.serverName, test.getKey) + assert.NoError(t, err) + }) + } +} + +func TestPDU_VerifySignature_Fail_NoKey(t *testing.T) { + test := roomV12MessageTestPDU + parsed := parsePDU(test.pdu) + err := parsed.VerifySignature(test.roomVersion, test.serverName, func(keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { + return + }) + assert.Error(t, err) +} + +func TestPDU_VerifySignature_V4ExpiredKey(t *testing.T) { + test := roomV4MessageTestPDU + parsed := parsePDU(test.pdu) + err := parsed.VerifySignature(test.roomVersion, test.serverName, func(keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { + key = test.keys[keyID].key + validUntil = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + return + }) + assert.NoError(t, err) +} + +func TestPDU_VerifySignature_V12ExpiredKey(t *testing.T) { + test := roomV12MessageTestPDU + parsed := parsePDU(test.pdu) + err := parsed.VerifySignature(test.roomVersion, test.serverName, func(keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { + key = test.keys[keyID].key + validUntil = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + return + }) + assert.Error(t, err) +} + +func TestPDU_VerifySignature_V12InvalidSignature(t *testing.T) { + test := roomV12MessageTestPDU + parsed := parsePDU(test.pdu) + for _, sigs := range parsed.Signatures { + for key := range sigs { + sigs[key] = sigs[key][:len(sigs[key])-3] + "ABC" + } + } + err := parsed.VerifySignature(test.roomVersion, test.serverName, test.getKey) + assert.Error(t, err) +} + +func TestPDU_Sign(t *testing.T) { + pubKey, privKey := exerrors.Must2(ed25519.GenerateKey(nil)) + evt := &pdu.PDU{ + AuthEvents: []id.EventID{"$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA", "$hyeL_nU_L3tsZ2dtZZpAHk0Skv-PqFQIipuII_By584"}, + Content: jsontext.Value(`{"msgtype":"m.text","body":"Hello, world!"}`), + Depth: 123, + OriginServerTS: 1755384351627, + PrevEvents: []id.EventID{"$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA"}, + RoomID: "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", + Sender: "@tulir:example.com", + Type: "m.room.message", + } + err := evt.Sign(id.RoomV12, "example.com", "ed25519:rand", privKey) + require.NoError(t, err) + err = evt.VerifySignature(id.RoomV11, "example.com", func(keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { + if keyID == "ed25519:rand" { + key = id.SigningKey(base64.RawStdEncoding.EncodeToString(pubKey)) + validUntil = time.Now() + } + return + }) + require.NoError(t, err) + +} From baf54f57b61d1ee091b96e974c7aa9201a82a588 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 19 Aug 2025 19:44:51 +0300 Subject: [PATCH 242/581] crypto/encryptmegolm: add fallback for copying `m.relates_to` --- crypto/encryptmegolm.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crypto/encryptmegolm.go b/crypto/encryptmegolm.go index 14ba2449..cd211af5 100644 --- a/crypto/encryptmegolm.go +++ b/crypto/encryptmegolm.go @@ -41,7 +41,7 @@ func getRawJSON[T any](content json.RawMessage, path ...string) *T { return &result } -func getRelatesTo(content any) *event.RelatesTo { +func getRelatesTo(content any, plaintext json.RawMessage) *event.RelatesTo { contentJSON, ok := content.(json.RawMessage) if ok { return getRawJSON[event.RelatesTo](contentJSON, "m.relates_to") @@ -54,7 +54,7 @@ func getRelatesTo(content any) *event.RelatesTo { if ok { return relatable.OptionalGetRelatesTo() } - return nil + return getRawJSON[event.RelatesTo](plaintext, "content", "m.relates_to") } func getMentions(content any) *event.Mentions { @@ -158,7 +158,7 @@ func (mach *OlmMachine) EncryptMegolmEventWithStateKey(ctx context.Context, room Algorithm: id.AlgorithmMegolmV1, SessionID: session.ID(), MegolmCiphertext: ciphertext, - RelatesTo: getRelatesTo(content), + RelatesTo: getRelatesTo(content, plaintext), // These are deprecated SenderKey: mach.account.IdentityKey(), From 29780ffb183c92b9526c6f15e1810f9c3945b19e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 21 Aug 2025 13:18:11 +0300 Subject: [PATCH 243/581] federation/pdu: refactor redaction to allow reuse of RedactContent --- federation/pdu/redact.go | 55 +++++++++++++++++++++------------------- federation/pdu/v1.go | 31 +++++++--------------- 2 files changed, 38 insertions(+), 48 deletions(-) diff --git a/federation/pdu/redact.go b/federation/pdu/redact.go index 56aaee1c..d7ee0c15 100644 --- a/federation/pdu/redact.go +++ b/federation/pdu/redact.go @@ -51,16 +51,8 @@ func (pdu *PDU) RedactForSignature(roomVersion id.RoomVersion) *PDU { var emptyObject = jsontext.Value("{}") -func (pdu *PDU) Redact(roomVersion id.RoomVersion) *PDU { - pdu.Unknown = nil - pdu.Unsigned = nil - if roomVersion.UpdatedRedactionRules() { - pdu.DeprecatedPrevState = nil - pdu.DeprecatedOrigin = nil - pdu.DeprecatedMembership = nil - } - - switch pdu.Type { +func RedactContent(eventType string, content jsontext.Value, roomVersion id.RoomVersion) jsontext.Value { + switch eventType { case "m.room.member": allowedPaths := []string{"membership"} if roomVersion.RestrictedJoinsFix() { @@ -69,40 +61,51 @@ func (pdu *PDU) Redact(roomVersion id.RoomVersion) *PDU { if roomVersion.UpdatedRedactionRules() { allowedPaths = append(allowedPaths, exgjson.Path("third_party_invite", "signed")) } - pdu.Content = filteredObject(pdu.Content, allowedPaths...) + return filteredObject(content, allowedPaths...) case "m.room.create": if !roomVersion.UpdatedRedactionRules() { - pdu.Content = filteredObject(pdu.Content, "creator") - } // else: all fields are protected + return filteredObject(content, "creator") + } + return content case "m.room.join_rules": if roomVersion.RestrictedJoins() { - pdu.Content = filteredObject(pdu.Content, "join_rule", "allow") - } else { - pdu.Content = filteredObject(pdu.Content, "join_rule") + return filteredObject(content, "join_rule", "allow") } + return filteredObject(content, "join_rule") case "m.room.power_levels": allowedKeys := []string{"ban", "events", "events_default", "kick", "redact", "state_default", "users", "users_default"} if roomVersion.UpdatedRedactionRules() { allowedKeys = append(allowedKeys, "invite") } - pdu.Content = filteredObject(pdu.Content, allowedKeys...) + return filteredObject(content, allowedKeys...) case "m.room.history_visibility": - pdu.Content = filteredObject(pdu.Content, "history_visibility") + return filteredObject(content, "history_visibility") case "m.room.redaction": if roomVersion.RedactsInContent() { - pdu.Content = filteredObject(pdu.Content, "redacts") - pdu.Redacts = nil - } else { - pdu.Content = emptyObject + return filteredObject(content, "redacts") } + return emptyObject case "m.room.aliases": if roomVersion.SpecialCasedAliasesAuth() { - pdu.Content = filteredObject(pdu.Content, "aliases") - } else { - pdu.Content = emptyObject + return filteredObject(content, "aliases") } + return emptyObject default: - pdu.Content = emptyObject + return emptyObject } +} + +func (pdu *PDU) Redact(roomVersion id.RoomVersion) *PDU { + pdu.Unknown = nil + pdu.Unsigned = nil + if roomVersion.UpdatedRedactionRules() { + pdu.DeprecatedPrevState = nil + pdu.DeprecatedOrigin = nil + pdu.DeprecatedMembership = nil + } + if pdu.Type != "m.room.redaction" || roomVersion.RedactsInContent() { + pdu.Redacts = nil + } + pdu.Content = RedactContent(pdu.Type, pdu.Content, roomVersion) return pdu } diff --git a/federation/pdu/v1.go b/federation/pdu/v1.go index 795253db..1bc324ed 100644 --- a/federation/pdu/v1.go +++ b/federation/pdu/v1.go @@ -90,31 +90,18 @@ func (pdu *RoomV1PDU) GetEventID(roomVersion id.RoomVersion) (id.EventID, error) return pdu.EventID, nil } -func (pdu *RoomV1PDU) RedactForSignature() *RoomV1PDU { +func (pdu *RoomV1PDU) RedactForSignature(roomVersion id.RoomVersion) *RoomV1PDU { pdu.Signatures = nil - return pdu.Redact() + return pdu.Redact(roomVersion) } -func (pdu *RoomV1PDU) Redact() *RoomV1PDU { +func (pdu *RoomV1PDU) Redact(roomVersion id.RoomVersion) *RoomV1PDU { pdu.Unknown = nil pdu.Unsigned = nil - - switch pdu.Type { - case "m.room.member": - pdu.Content = filteredObject(pdu.Content, "membership") - case "m.room.create": - pdu.Content = filteredObject(pdu.Content, "creator") - case "m.room.join_rules": - pdu.Content = filteredObject(pdu.Content, "join_rule") - case "m.room.power_levels": - pdu.Content = filteredObject(pdu.Content, "ban", "events", "events_default", "kick", "redact", "state_default", "users", "users_default") - case "m.room.history_visibility": - pdu.Content = filteredObject(pdu.Content, "history_visibility") - case "m.room.aliases": - pdu.Content = filteredObject(pdu.Content, "aliases") - default: - pdu.Content = emptyObject + if pdu.Type != "m.room.redaction" { + pdu.Redacts = nil } + pdu.Content = RedactContent(pdu.Type, pdu.Content, roomVersion) return pdu } @@ -130,7 +117,7 @@ func (pdu *RoomV1PDU) GetReferenceHash(roomVersion id.RoomVersion) ([32]byte, er return [32]byte{}, err } } - rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature()) + rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature(roomVersion)) if err != nil { return [32]byte{}, fmt.Errorf("failed to marshal redacted PDU to calculate event ID: %w", err) } @@ -188,7 +175,7 @@ func (pdu *RoomV1PDU) Sign(roomVersion id.RoomVersion, serverName string, keyID if err != nil { return err } - rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature()) + rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature(roomVersion)) if err != nil { return fmt.Errorf("failed to marshal redacted PDU to sign: %w", err) } @@ -207,7 +194,7 @@ func (pdu *RoomV1PDU) VerifySignature(roomVersion id.RoomVersion, serverName str if !pdu.SupportsRoomVersion(roomVersion) { return fmt.Errorf("RoomV1PDU.VerifySignature: unsupported room version %s", roomVersion) } - rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature()) + rawJSON, err := marshalCanonical(pdu.Clone().RedactForSignature(roomVersion)) if err != nil { return fmt.Errorf("failed to marshal redacted PDU to verify signature: %w", err) } From a547c0636c72a215e6c56ef6cb72959d5fccaeee Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 21 Aug 2025 13:19:11 +0300 Subject: [PATCH 244/581] event,pushrules: replace assert.Nil with assert.NoError --- event/message_test.go | 14 +++++++------- pushrules/action_test.go | 12 ++++++------ pushrules/pushrules_test.go | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/event/message_test.go b/event/message_test.go index 562a6622..c721df35 100644 --- a/event/message_test.go +++ b/event/message_test.go @@ -33,7 +33,7 @@ const invalidMessageEvent = `{ func TestMessageEventContent__ParseInvalid(t *testing.T) { var evt *event.Event err := json.Unmarshal([]byte(invalidMessageEvent), &evt) - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, id.UserID("@tulir:maunium.net"), evt.Sender) assert.Equal(t, event.EventMessage, evt.Type) @@ -42,7 +42,7 @@ func TestMessageEventContent__ParseInvalid(t *testing.T) { assert.Equal(t, id.RoomID("!bar"), evt.RoomID) err = evt.Content.ParseRaw(evt.Type) - assert.NotNil(t, err) + assert.Error(t, err) } const messageEvent = `{ @@ -68,7 +68,7 @@ const messageEvent = `{ func TestMessageEventContent__ParseEdit(t *testing.T) { var evt *event.Event err := json.Unmarshal([]byte(messageEvent), &evt) - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, id.UserID("@tulir:maunium.net"), evt.Sender) assert.Equal(t, event.EventMessage, evt.Type) @@ -110,7 +110,7 @@ const imageMessageEvent = `{ func TestMessageEventContent__ParseMedia(t *testing.T) { var evt *event.Event err := json.Unmarshal([]byte(imageMessageEvent), &evt) - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, id.UserID("@tulir:maunium.net"), evt.Sender) assert.Equal(t, event.EventMessage, evt.Type) @@ -125,7 +125,7 @@ func TestMessageEventContent__ParseMedia(t *testing.T) { content := evt.Content.Parsed.(*event.MessageEventContent) assert.Equal(t, event.MsgImage, content.MsgType) parsedURL, err := content.URL.Parse() - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, id.ContentURI{Homeserver: "example.com", FileID: "image"}, parsedURL) assert.Nil(t, content.NewContent) assert.Equal(t, "image/png", content.GetInfo().MimeType) @@ -145,7 +145,7 @@ const expectedMarshalResult = `{"msgtype":"m.text","body":"test"}` func TestMessageEventContent__Marshal(t *testing.T) { data, err := json.Marshal(parsedMessage) - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, expectedMarshalResult, string(data)) } @@ -163,6 +163,6 @@ const expectedCustomMarshalResult = `{"body":"test","msgtype":"m.text","net.maun func TestMessageEventContent__Marshal_Custom(t *testing.T) { data, err := json.Marshal(customParsedMessage) - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, expectedCustomMarshalResult, string(data)) } diff --git a/pushrules/action_test.go b/pushrules/action_test.go index a8f68415..3c0aa168 100644 --- a/pushrules/action_test.go +++ b/pushrules/action_test.go @@ -139,9 +139,9 @@ func TestPushAction_UnmarshalJSON_InvalidTypeDoesNothing(t *testing.T) { } err := pa.UnmarshalJSON([]byte(`{"foo": "bar"}`)) - assert.Nil(t, err) + assert.NoError(t, err) err = pa.UnmarshalJSON([]byte(`9001`)) - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, pushrules.PushActionType("unchanged"), pa.Action) assert.Equal(t, pushrules.PushActionTweak("unchanged"), pa.Tweak) @@ -156,7 +156,7 @@ func TestPushAction_UnmarshalJSON_StringChangesActionType(t *testing.T) { } err := pa.UnmarshalJSON([]byte(`"foo"`)) - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, pushrules.PushActionType("foo"), pa.Action) assert.Equal(t, pushrules.PushActionTweak("unchanged"), pa.Tweak) @@ -171,7 +171,7 @@ func TestPushAction_UnmarshalJSON_SetTweakChangesTweak(t *testing.T) { } err := pa.UnmarshalJSON([]byte(`{"set_tweak": "foo", "value": 123.0}`)) - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, pushrules.ActionSetTweak, pa.Action) assert.Equal(t, pushrules.PushActionTweak("foo"), pa.Tweak) @@ -185,7 +185,7 @@ func TestPushAction_MarshalJSON_TweakOutputWorks(t *testing.T) { Value: "bar", } data, err := pa.MarshalJSON() - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, []byte(`{"set_tweak":"foo","value":"bar"}`), data) } @@ -196,6 +196,6 @@ func TestPushAction_MarshalJSON_OtherOutputWorks(t *testing.T) { Value: "bar", } data, err := pa.MarshalJSON() - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, []byte(`"something else"`), data) } diff --git a/pushrules/pushrules_test.go b/pushrules/pushrules_test.go index a531ca28..a5a0f5e7 100644 --- a/pushrules/pushrules_test.go +++ b/pushrules/pushrules_test.go @@ -25,7 +25,7 @@ func TestEventToPushRules(t *testing.T) { }, } pushRuleset, err := pushrules.EventToPushRules(evt) - assert.Nil(t, err) + assert.NoError(t, err) assert.NotNil(t, pushRuleset) assert.IsType(t, pushRuleset.Override, pushrules.PushRuleArray{}) From 1d484e01d071de51f5eccda63c7013f3f4c31de8 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Fri, 22 Aug 2025 14:46:56 +0530 Subject: [PATCH 245/581] event: implement disappearing timer types (#399) Co-authored-by: Tulir Asokan --- bridgev2/database/disappear.go | 25 +++++++++++++++++++------ bridgev2/database/portal.go | 14 +++++++++++++- bridgev2/portal.go | 28 ++++++++++++++++++++++++---- bridgev2/portalbackfill.go | 5 +++-- event/capabilities.d.ts | 21 +++++++++++++++++++++ event/capabilities.go | 26 ++++++++++++++++++++++++++ event/content.go | 1 + event/message.go | 2 ++ event/state.go | 16 ++++++++++++++++ event/type.go | 3 ++- 10 files changed, 127 insertions(+), 14 deletions(-) diff --git a/bridgev2/database/disappear.go b/bridgev2/database/disappear.go index 4e6f5e0a..e830cb14 100644 --- a/bridgev2/database/disappear.go +++ b/bridgev2/database/disappear.go @@ -12,28 +12,41 @@ import ( "time" "go.mau.fi/util/dbutil" + "go.mau.fi/util/jsontime" "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) -// DisappearingType represents the type of a disappearing message timer. -type DisappearingType string +// Deprecated: use [event.DisappearingType] +type DisappearingType = event.DisappearingType +// Deprecated: use constants in event package const ( - DisappearingTypeNone DisappearingType = "" - DisappearingTypeAfterRead DisappearingType = "after_read" - DisappearingTypeAfterSend DisappearingType = "after_send" + DisappearingTypeNone = event.DisappearingTypeNone + DisappearingTypeAfterRead = event.DisappearingTypeAfterRead + DisappearingTypeAfterSend = event.DisappearingTypeAfterSend ) // DisappearingSetting represents a disappearing message timer setting // by combining a type with a timer and an optional start timestamp. type DisappearingSetting struct { - Type DisappearingType + Type event.DisappearingType Timer time.Duration DisappearAt time.Time } +func (ds DisappearingSetting) ToEventContent() *event.BeeperDisappearingTimer { + if ds.Type == event.DisappearingTypeNone || ds.Timer == 0 { + return nil + } + return &event.BeeperDisappearingTimer{ + Type: ds.Type, + Timer: jsontime.MS(ds.Timer), + } +} + type DisappearingMessageQuery struct { BridgeID networkid.BridgeID *dbutil.QueryHelper[*DisappearingMessage] diff --git a/bridgev2/database/portal.go b/bridgev2/database/portal.go index 17e44b09..c3aa7121 100644 --- a/bridgev2/database/portal.go +++ b/bridgev2/database/portal.go @@ -16,6 +16,7 @@ import ( "go.mau.fi/util/dbutil" "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -34,9 +35,20 @@ type PortalQuery struct { *dbutil.QueryHelper[*Portal] } +type CapStateFlags uint32 + +func (csf CapStateFlags) Has(flag CapStateFlags) bool { + return csf&flag != 0 +} + +const ( + CapStateFlagDisappearingTimerSet CapStateFlags = 1 << iota +) + type CapabilityState struct { Source networkid.UserLoginID `json:"source"` ID string `json:"id"` + Flags CapStateFlags `json:"flags"` } type Portal struct { @@ -208,7 +220,7 @@ func (p *Portal) Scan(row dbutil.Scannable) (*Portal, error) { } if disappearType.Valid { p.Disappear = DisappearingSetting{ - Type: DisappearingType(disappearType.String), + Type: event.DisappearingType(disappearType.String), Timer: time.Duration(disappearTimer.Int64), } } diff --git a/bridgev2/portal.go b/bridgev2/portal.go index d343a651..7c3a56c2 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1101,7 +1101,7 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin } portal.sendSuccessStatus(ctx, evt, resp.StreamOrder, message.MXID) } - if portal.Disappear.Type != database.DisappearingTypeNone { + if portal.Disappear.Type != event.DisappearingTypeNone { go portal.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{ RoomID: portal.MXID, EventID: message.MXID, @@ -2281,6 +2281,7 @@ func (portal *Portal) sendConvertedMessage( allSuccess := true for i, part := range converted.Parts { portal.applyRelationMeta(ctx, part.Content, replyTo, threadRoot, prevThreadEvent) + part.Content.BeeperDisappearingTimer = converted.Disappear.ToEventContent() dbMessage := &database.Message{ ID: id, PartID: part.ID, @@ -2325,8 +2326,8 @@ func (portal *Portal) sendConvertedMessage( logContext(log.Err(err)).Str("part_id", string(part.ID)).Msg("Failed to save message part to database") allSuccess = false } - if converted.Disappear.Type != database.DisappearingTypeNone && !dbMessage.HasFakeMXID() { - if converted.Disappear.Type == database.DisappearingTypeAfterSend && converted.Disappear.DisappearAt.IsZero() { + if converted.Disappear.Type != event.DisappearingTypeNone && !dbMessage.HasFakeMXID() { + if converted.Disappear.Type == event.DisappearingTypeAfterSend && converted.Disappear.DisappearAt.IsZero() { converted.Disappear.DisappearAt = dbMessage.Timestamp.Add(converted.Disappear.Timer) } portal.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{ @@ -3648,6 +3649,15 @@ func (portal *Portal) UpdateCapabilities(ctx context.Context, source *UserLogin, portal.CapState = database.CapabilityState{ Source: source.ID, ID: capID, + Flags: portal.CapState.Flags, + } + if caps.DisappearingTimer != nil && !portal.CapState.Flags.Has(database.CapStateFlagDisappearingTimerSet) { + zerolog.Ctx(ctx).Debug().Msg("Disappearing timer capability was added, sending disappearing timer state event") + success = portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent()) + if !success { + return false + } + portal.CapState.Flags |= database.CapStateFlagDisappearingTimerSet } portal.lastCapUpdate = time.Now() if implicit { @@ -4030,7 +4040,7 @@ func DisappearingMessageNotice(expiration time.Duration, implicit bool) *event.M func (portal *Portal) UpdateDisappearingSetting(ctx context.Context, setting database.DisappearingSetting, sender MatrixAPI, ts time.Time, implicit, save bool) bool { if setting.Timer == 0 { - setting.Type = "" + setting.Type = event.DisappearingTypeNone } if portal.Disappear.Timer == setting.Timer && portal.Disappear.Type == setting.Type { return false @@ -4046,6 +4056,9 @@ func (portal *Portal) UpdateDisappearingSetting(ctx context.Context, setting dat if portal.MXID == "" { return true } + + portal.sendRoomMeta(ctx, sender, ts, event.StateBeeperDisappearingTimer, "", setting.ToEventContent()) + content := DisappearingMessageNotice(setting.Timer, implicit) if sender == nil { sender = portal.Bridge.Bot @@ -4333,6 +4346,13 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo Type: event.StateBeeperRoomFeatures, Content: event.Content{Parsed: roomFeatures}, }) + if roomFeatures.DisappearingTimer != nil { + req.InitialState = append(req.InitialState, &event.Event{ + Type: event.StateBeeperDisappearingTimer, + Content: event.Content{Parsed: portal.Disappear.ToEventContent()}, + }) + portal.CapState.Flags |= database.CapStateFlagDisappearingTimerSet + } if req.Topic == "" { // Add explicit topic event if topic is empty to ensure the event is set. // This ensures that there won't be an extra event later if PUT /state/... is called. diff --git a/bridgev2/portalbackfill.go b/bridgev2/portalbackfill.go index 9883fb12..f7819968 100644 --- a/bridgev2/portalbackfill.go +++ b/bridgev2/portalbackfill.go @@ -339,6 +339,7 @@ func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin for i, part := range msg.Parts { partIDs = append(partIDs, part.ID) portal.applyRelationMeta(ctx, part.Content, replyTo, threadRoot, prevThreadEvent) + part.Content.BeeperDisappearingTimer = msg.Disappear.ToEventContent() evtID := portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, msg.ID, part.ID) dbMessage := &database.Message{ ID: msg.ID, @@ -379,8 +380,8 @@ func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin prevThreadEvent.MXID = evtID out.PrevThreadEvents[*msg.ThreadRoot] = evtID } - if msg.Disappear.Type != database.DisappearingTypeNone { - if msg.Disappear.Type == database.DisappearingTypeAfterSend && msg.Disappear.DisappearAt.IsZero() { + if msg.Disappear.Type != event.DisappearingTypeNone { + if msg.Disappear.Type == event.DisappearingTypeAfterSend && msg.Disappear.DisappearAt.IsZero() { msg.Disappear.DisappearAt = msg.Timestamp.Add(msg.Disappear.Timer) } out.Disappear = append(out.Disappear, &database.DisappearingMessage{ diff --git a/event/capabilities.d.ts b/event/capabilities.d.ts index 4cf29de7..7f1dce05 100644 --- a/event/capabilities.d.ts +++ b/event/capabilities.d.ts @@ -41,6 +41,8 @@ export interface RoomFeatures { delete_max_age?: seconds /** Whether deleting messages just for yourself is supported. No message age limit. */ delete_for_me?: boolean + /** Allowed configuration options for disappearing timers. */ + disappearing_timer?: DisappearingTimerCapability /** Whether reactions are supported. */ reaction?: CapabilitySupportLevel @@ -57,6 +59,7 @@ export interface RoomFeatures { declare type integer = number declare type seconds = integer +declare type milliseconds = integer declare type MIMEClass = "image" | "audio" | "video" | "text" | "font" | "model" | "application" declare type MIMETypeOrPattern = "*/*" @@ -106,6 +109,24 @@ export interface FileFeatures { view_once?: boolean } +export enum DisappearingType { + None = "", + AfterRead = "after_read", + AfterSend = "after_send", +} + +export interface DisappearingTimerCapability { + types: DisappearingType[] + timers: milliseconds[] + /** + * Whether clients should omit the empty disappearing_timer object in messages that they don't want to disappear + * + * Generally, bridged rooms will want the object to be always present, while native Matrix rooms don't, + * so the hardcoded features for Matrix rooms should set this to true, while bridges will not. + */ + omit_empty_timer?: true +} + /** * The support level for a feature. These are integers rather than booleans * to accurately represent what the bridge is doing and hopefully make the diff --git a/event/capabilities.go b/event/capabilities.go index 9c9eb09a..f44d6600 100644 --- a/event/capabilities.go +++ b/event/capabilities.go @@ -44,6 +44,8 @@ type RoomFeatures struct { DeleteForMe bool `json:"delete_for_me,omitempty"` DeleteMaxAge *jsontime.Seconds `json:"delete_max_age,omitempty"` + DisappearingTimer *DisappearingTimerCapability `json:"disappearing_timer,omitempty"` + Reaction CapabilitySupportLevel `json:"reaction,omitempty"` ReactionCount int `json:"reaction_count,omitempty"` AllowedReactions []string `json:"allowed_reactions,omitempty"` @@ -67,6 +69,13 @@ type FormattingFeatureMap map[FormattingFeature]CapabilitySupportLevel type FileFeatureMap map[CapabilityMsgType]*FileFeatures +type DisappearingTimerCapability struct { + Types []DisappearingType `json:"types"` + Timers []jsontime.Milliseconds `json:"timers"` + + OmitEmptyTimer bool `json:"omit_empty_timer,omitempty"` +} + type CapabilityMsgType = MessageType // Message types which are used for event capability signaling, but aren't real values for the msgtype field. @@ -231,6 +240,7 @@ func (rf *RoomFeatures) Hash() []byte { hashValue(hasher, "delete", rf.Delete) hashBool(hasher, "delete_for_me", rf.DeleteForMe) hashInt(hasher, "delete_max_age", rf.DeleteMaxAge.Get()) + hashValue(hasher, "disappearing_timer", rf.DisappearingTimer) hashValue(hasher, "reaction", rf.Reaction) hashInt(hasher, "reaction_count", rf.ReactionCount) @@ -249,6 +259,22 @@ func (rf *RoomFeatures) Hash() []byte { return hasher.Sum(nil) } +func (dtc *DisappearingTimerCapability) Hash() []byte { + if dtc == nil { + return nil + } + hasher := sha256.New() + hasher.Write([]byte("types")) + for _, t := range dtc.Types { + hasher.Write([]byte(t)) + } + hasher.Write([]byte("timers")) + for _, timer := range dtc.Timers { + hashInt(hasher, "", timer.Milliseconds()) + } + return hasher.Sum(nil) +} + func (ff *FileFeatures) Hash() []byte { hasher := sha256.New() hashMap(hasher, "mime_types", ff.MimeTypes) diff --git a/event/content.go b/event/content.go index e50dfea5..779330af 100644 --- a/event/content.go +++ b/event/content.go @@ -48,6 +48,7 @@ var TypeMap = map[Type]reflect.Type{ StateElementFunctionalMembers: reflect.TypeOf(ElementFunctionalMembersContent{}), StateBeeperRoomFeatures: reflect.TypeOf(RoomFeatures{}), + StateBeeperDisappearingTimer: reflect.TypeOf(BeeperDisappearingTimer{}), EventMessage: reflect.TypeOf(MessageEventContent{}), EventSticker: reflect.TypeOf(MessageEventContent{}), diff --git a/event/message.go b/event/message.go index 51403889..f16822f2 100644 --- a/event/message.go +++ b/event/message.go @@ -138,6 +138,8 @@ type MessageEventContent struct { BeeperLinkPreviews []*BeeperLinkPreview `json:"com.beeper.linkpreviews,omitempty"` + BeeperDisappearingTimer *BeeperDisappearingTimer `json:"com.beeper.disappearing_timer,omitempty"` + MSC1767Audio *MSC1767Audio `json:"org.matrix.msc1767.audio,omitempty"` MSC3245Voice *MSC3245Voice `json:"org.matrix.msc3245.voice,omitempty"` } diff --git a/event/state.go b/event/state.go index de46c57d..66b06b14 100644 --- a/event/state.go +++ b/event/state.go @@ -10,6 +10,8 @@ import ( "encoding/base64" "slices" + "go.mau.fi/util/jsontime" + "maunium.net/go/mautrix/id" ) @@ -207,6 +209,20 @@ type BridgeEventContent struct { BeeperRoomTypeV2 string `json:"com.beeper.room_type.v2,omitempty"` } +// DisappearingType represents the type of a disappearing message timer. +type DisappearingType string + +const ( + DisappearingTypeNone DisappearingType = "" + DisappearingTypeAfterRead DisappearingType = "after_read" + DisappearingTypeAfterSend DisappearingType = "after_send" +) + +type BeeperDisappearingTimer struct { + Type DisappearingType `json:"type"` + Timer jsontime.Milliseconds `json:"timer"` +} + type SpaceChildEventContent struct { Via []string `json:"via,omitempty"` Order string `json:"order,omitempty"` diff --git a/event/type.go b/event/type.go index b097cfe1..35bf2669 100644 --- a/event/type.go +++ b/event/type.go @@ -112,7 +112,7 @@ func (et *Type) GuessClass() TypeClass { StatePowerLevels.Type, StateRoomName.Type, StateRoomAvatar.Type, StateServerACL.Type, StateTopic.Type, StatePinnedEvents.Type, StateTombstone.Type, StateEncryption.Type, StateBridge.Type, StateHalfShotBridge.Type, StateSpaceParent.Type, StateSpaceChild.Type, StatePolicyRoom.Type, StatePolicyServer.Type, StatePolicyUser.Type, - StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type: + StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type, StateBeeperDisappearingTimer.Type: return StateEventType case EphemeralEventReceipt.Type, EphemeralEventTyping.Type, EphemeralEventPresence.Type: return EphemeralEventType @@ -202,6 +202,7 @@ var ( StateElementFunctionalMembers = Type{"io.element.functional_members", StateEventType} StateBeeperRoomFeatures = Type{"com.beeper.room_features", StateEventType} + StateBeeperDisappearingTimer = Type{"com.beeper.disappearing_timer", StateEventType} ) // Message events From 206071ec034f3e59e413417ad6f08deb38a441d2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 22 Aug 2025 18:38:38 +0300 Subject: [PATCH 246/581] federation/pdu: add redacted member event --- federation/pdu/hash_test.go | 6 ++++++ federation/pdu/pdu_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/federation/pdu/hash_test.go b/federation/pdu/hash_test.go index 35ea49df..17417e12 100644 --- a/federation/pdu/hash_test.go +++ b/federation/pdu/hash_test.go @@ -18,6 +18,9 @@ import ( func TestPDU_CalculateContentHash(t *testing.T) { for _, test := range testPDUs { + if test.redacted { + continue + } t.Run(test.name, func(t *testing.T) { parsed := parsePDU(test.pdu) contentHash := exerrors.Must(parsed.CalculateContentHash()) @@ -32,6 +35,9 @@ func TestPDU_CalculateContentHash(t *testing.T) { func TestPDU_VerifyContentHash(t *testing.T) { for _, test := range testPDUs { + if test.redacted { + continue + } t.Run(test.name, func(t *testing.T) { parsed := parsePDU(test.pdu) assert.True(t, parsed.VerifyContentHash()) diff --git a/federation/pdu/pdu_test.go b/federation/pdu/pdu_test.go index f4920098..93244741 100644 --- a/federation/pdu/pdu_test.go +++ b/federation/pdu/pdu_test.go @@ -71,12 +71,31 @@ var matrixOrg = serverDetails{ }, }, } +var continuwuityOrg = serverDetails{ + serverName: "continuwuity.org", + keys: map[id.KeyID]serverKey{ + "ed25519:PwHlNsFu": { + key: "8eNx2s0zWW+heKAmOH5zKv/nCPkEpraDJfGHxDu6hFI", + validUntilTS: time.Now(), + }, + }, +} +var novaAstraltechOrg = serverDetails{ + serverName: "nova.astraltech.org", + keys: map[id.KeyID]serverKey{ + "ed25519:a_afpo": { + key: "O1Y9GWuKo9xkuzuQef6gROxtTgxxAbS3WPNghPYXF3o", + validUntilTS: time.Now(), + }, + }, +} type testPDU struct { name string pdu string eventID id.EventID roomVersion id.RoomVersion + redacted bool serverDetails } @@ -156,6 +175,13 @@ var testPDUs = []testPDU{roomV4MessageTestPDU, { eventID: "$kAagtZAIEeZaLVCUSl74tAxQbdKbE22GU7FM-iAJBc0", roomVersion: id.RoomV4, serverDetails: mauniumNet, +}, { + name: "redacted m.room.member event in v11 room with 2 signatures", + pdu: `{"auth_events":["$9f12-_stoY07BOTmyguE1QlqvghLBh9Rk6PWRLoZn_M","$IP8hyjBkIDREVadyv0fPCGAW9IXGNllaZyxqQwiY_tA","$7dN5J8EveliaPkX6_QSejl4GQtem4oieavgALMeWZyE"],"content":{"membership":"join"},"depth":96978,"hashes":{"sha256":"APYA/aj3u+P0EwNaEofuSIlfqY3cK3lBz6RkwHX+Zak"},"origin_server_ts":1755664164485,"prev_events":["$XBN9W5Ll8VEH3eYqJaemxCBTDdy0hZB0sWpmyoUp93c"],"room_id":"!main-1:continuwuity.org","sender":"@6a19abdd4766:nova.astraltech.org","state_key":"@6a19abdd4766:nova.astraltech.org","type":"m.room.member","signatures":{"continuwuity.org":{"ed25519:PwHlNsFu":"+b/Fp2vWnC+Z2lI3GnCu7ZHdo3iWNDZ2AJqMoU9owMtLBPMxs4dVIsJXvaFq0ryawsgwDwKZ7f4xaFUNARJSDg"},"nova.astraltech.org":{"ed25519:a_afpo":"pXIngyxKukCPR7WOIIy8FTZxQ5L2dLiou5Oc8XS4WyY4YzJuckQzOaToigLLZxamfbN/jXbO+XUizpRpYccDAA"}},"unsigned":{}}`, + eventID: "$r6d9m125YWG28-Tln47bWtm6Jlv4mcSUWJTHijBlXLQ", + roomVersion: id.RoomV11, + serverDetails: novaAstraltechOrg, + redacted: true, }} func parsePDU(pdu string) (out *pdu.PDU) { From 35b805440f0bad84c35b77f0df1af436cab32e10 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 22 Aug 2025 19:37:53 +0300 Subject: [PATCH 247/581] federation/pdu: add auth event selection --- event/type.go | 3 +- federation/pdu/auth.go | 71 ++++++++++++++++++++++++++++++++++++++++++ federation/pdu/pdu.go | 1 + federation/pdu/v1.go | 25 +++++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 federation/pdu/auth.go diff --git a/event/type.go b/event/type.go index 35bf2669..1ab8c517 100644 --- a/event/type.go +++ b/event/type.go @@ -108,7 +108,7 @@ func (et *Type) IsCustom() bool { func (et *Type) GuessClass() TypeClass { switch et.Type { - case StateAliases.Type, StateCanonicalAlias.Type, StateCreate.Type, StateJoinRules.Type, StateMember.Type, + case StateAliases.Type, StateCanonicalAlias.Type, StateCreate.Type, StateJoinRules.Type, StateMember.Type, StateThirdPartyInvite.Type, StatePowerLevels.Type, StateRoomName.Type, StateRoomAvatar.Type, StateServerACL.Type, StateTopic.Type, StatePinnedEvents.Type, StateTombstone.Type, StateEncryption.Type, StateBridge.Type, StateHalfShotBridge.Type, StateSpaceParent.Type, StateSpaceChild.Type, StatePolicyRoom.Type, StatePolicyServer.Type, StatePolicyUser.Type, @@ -177,6 +177,7 @@ var ( StateHistoryVisibility = Type{"m.room.history_visibility", StateEventType} StateGuestAccess = Type{"m.room.guest_access", StateEventType} StateMember = Type{"m.room.member", StateEventType} + StateThirdPartyInvite = Type{"m.room.third_party_invite", StateEventType} StatePowerLevels = Type{"m.room.power_levels", StateEventType} StateRoomName = Type{"m.room.name", StateEventType} StateTopic = Type{"m.room.topic", StateEventType} diff --git a/federation/pdu/auth.go b/federation/pdu/auth.go new file mode 100644 index 00000000..1f98de06 --- /dev/null +++ b/federation/pdu/auth.go @@ -0,0 +1,71 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu + +import ( + "slices" + + "github.com/tidwall/gjson" + "go.mau.fi/util/exgjson" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type StateKey struct { + Type string + StateKey string +} + +var thirdPartyInviteTokenPath = exgjson.Path("third_party_invite", "signed", "token") + +type AuthEventSelection []StateKey + +func (aes *AuthEventSelection) Add(evtType, stateKey string) { + key := StateKey{Type: evtType, StateKey: stateKey} + if !aes.Has(key) { + *aes = append(*aes, key) + } +} + +func (aes *AuthEventSelection) Has(key StateKey) bool { + return slices.Contains(*aes, key) +} + +func (pdu *PDU) AuthEventSelection(roomVersion id.RoomVersion) (keys AuthEventSelection) { + if pdu.Type == event.StateCreate.Type && pdu.StateKey != nil { + return AuthEventSelection{} + } + keys = make(AuthEventSelection, 0, 3) + if !roomVersion.RoomIDIsCreateEventID() { + keys.Add(event.StateCreate.Type, "") + } + keys.Add(event.StatePowerLevels.Type, "") + keys.Add(event.StateMember.Type, pdu.Sender.String()) + if pdu.Type == event.StateMember.Type && pdu.StateKey != nil { + keys.Add(event.StateMember.Type, *pdu.StateKey) + membership := event.Membership(gjson.GetBytes(pdu.Content, "membership").Str) + if membership == event.MembershipJoin || membership == event.MembershipInvite || membership == event.MembershipKnock { + keys.Add(event.StateJoinRules.Type, "") + } + if membership == event.MembershipInvite { + thirdPartyInviteToken := gjson.GetBytes(pdu.Content, thirdPartyInviteTokenPath).Str + if thirdPartyInviteToken != "" { + keys.Add(event.StateThirdPartyInvite.Type, thirdPartyInviteToken) + } + } + if membership == event.MembershipJoin && roomVersion.RestrictedJoins() { + authorizedVia := gjson.GetBytes(pdu.Content, "authorized_via_users_server").Str + if authorizedVia != "" { + keys.Add(event.StateMember.Type, authorizedVia) + } + } + } + return +} diff --git a/federation/pdu/pdu.go b/federation/pdu/pdu.go index 860201c5..0e63ea7c 100644 --- a/federation/pdu/pdu.go +++ b/federation/pdu/pdu.go @@ -45,6 +45,7 @@ type AnyPDU interface { Sign(roomVersion id.RoomVersion, serverName string, keyID id.KeyID, privateKey ed25519.PrivateKey) error VerifySignature(roomVersion id.RoomVersion, serverName string, getKey GetKeyFunc) error ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error) + AuthEventSelection(roomVersion id.RoomVersion) (keys AuthEventSelection) } var ( diff --git a/federation/pdu/v1.go b/federation/pdu/v1.go index 1bc324ed..fc958b03 100644 --- a/federation/pdu/v1.go +++ b/federation/pdu/v1.go @@ -18,6 +18,7 @@ import ( "fmt" "time" + "github.com/tidwall/gjson" "go.mau.fi/util/ptr" "maunium.net/go/mautrix/event" @@ -250,3 +251,27 @@ func (pdu *RoomV1PDU) ToClientEvent(roomVersion id.RoomVersion) (*event.Event, e } return evt, nil } + +func (pdu *RoomV1PDU) AuthEventSelection(_ id.RoomVersion) (keys AuthEventSelection) { + if pdu.Type == event.StateCreate.Type && pdu.StateKey != nil { + return AuthEventSelection{} + } + keys = make(AuthEventSelection, 0, 3) + keys.Add(event.StateCreate.Type, "") + keys.Add(event.StatePowerLevels.Type, "") + keys.Add(event.StateMember.Type, pdu.Sender.String()) + if pdu.Type == event.StateMember.Type && pdu.StateKey != nil { + keys.Add(event.StateMember.Type, *pdu.StateKey) + membership := event.Membership(gjson.GetBytes(pdu.Content, "membership").Str) + if membership == event.MembershipJoin || membership == event.MembershipInvite || membership == event.MembershipKnock { + keys.Add(event.StateJoinRules.Type, "") + } + if membership == event.MembershipInvite { + thirdPartyInviteToken := gjson.GetBytes(pdu.Content, thirdPartyInviteTokenPath).Str + if thirdPartyInviteToken != "" { + keys.Add(event.StateThirdPartyInvite.Type, thirdPartyInviteToken) + } + } + } + return +} From fd20a61d87483e212ccefd76db32eb5782758891 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 23 Aug 2025 03:08:44 +0300 Subject: [PATCH 248/581] event: add json struct tag to third party signed object --- event/member.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/event/member.go b/event/member.go index 53387e8b..3e53893a 100644 --- a/event/member.go +++ b/event/member.go @@ -53,5 +53,5 @@ type ThirdPartyInvite struct { Token string `json:"token"` Signatures json.RawMessage `json:"signatures"` MXID string `json:"mxid"` - } + } `json:"signed"` } From 363aa943895876bff46f2cae3a310ace981058f5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 23 Aug 2025 03:13:10 +0300 Subject: [PATCH 249/581] federation/pdu: add server name parameter to GetKeyFunc --- federation/pdu/pdu.go | 2 +- federation/pdu/pdu_test.go | 5 ++++- federation/pdu/signature.go | 2 +- federation/pdu/signature_test.go | 10 +++++----- federation/pdu/v1.go | 2 +- federation/pdu/v1_test.go | 2 +- 6 files changed, 13 insertions(+), 10 deletions(-) diff --git a/federation/pdu/pdu.go b/federation/pdu/pdu.go index 0e63ea7c..c6faf3d0 100644 --- a/federation/pdu/pdu.go +++ b/federation/pdu/pdu.go @@ -33,7 +33,7 @@ import ( // valid at or after this time, but if that is not possible, the latest available key should be // returned without an error. The verify function will do its own validity checking based on the // returned valid until timestamp. -type GetKeyFunc = func(keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) +type GetKeyFunc = func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) type AnyPDU interface { GetRoomID() (id.RoomID, error) diff --git a/federation/pdu/pdu_test.go b/federation/pdu/pdu_test.go index 93244741..59d7c3a6 100644 --- a/federation/pdu/pdu_test.go +++ b/federation/pdu/pdu_test.go @@ -28,7 +28,10 @@ type serverDetails struct { keys map[id.KeyID]serverKey } -func (sd serverDetails) getKey(keyID id.KeyID, _ time.Time) (id.SigningKey, time.Time, error) { +func (sd serverDetails) getKey(serverName string, keyID id.KeyID, _ time.Time) (id.SigningKey, time.Time, error) { + if serverName != sd.serverName { + return "", time.Time{}, nil + } key, ok := sd.keys[keyID] if ok { return key.key, key.validUntilTS, nil diff --git a/federation/pdu/signature.go b/federation/pdu/signature.go index 1f8ae0b5..a7685cc6 100644 --- a/federation/pdu/signature.go +++ b/federation/pdu/signature.go @@ -46,7 +46,7 @@ func (pdu *PDU) VerifySignature(roomVersion id.RoomVersion, serverName string, g verified := false for keyID, sig := range pdu.Signatures[serverName] { originServerTS := time.UnixMilli(pdu.OriginServerTS) - key, validUntil, err := getKey(keyID, originServerTS) + key, validUntil, err := getKey(serverName, keyID, originServerTS) if err != nil { return fmt.Errorf("failed to get key %s for %s: %w", keyID, serverName, err) } else if key == "" { diff --git a/federation/pdu/signature_test.go b/federation/pdu/signature_test.go index 68e7a773..01df5076 100644 --- a/federation/pdu/signature_test.go +++ b/federation/pdu/signature_test.go @@ -36,7 +36,7 @@ func TestPDU_VerifySignature(t *testing.T) { func TestPDU_VerifySignature_Fail_NoKey(t *testing.T) { test := roomV12MessageTestPDU parsed := parsePDU(test.pdu) - err := parsed.VerifySignature(test.roomVersion, test.serverName, func(keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { + err := parsed.VerifySignature(test.roomVersion, test.serverName, func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { return }) assert.Error(t, err) @@ -45,7 +45,7 @@ func TestPDU_VerifySignature_Fail_NoKey(t *testing.T) { func TestPDU_VerifySignature_V4ExpiredKey(t *testing.T) { test := roomV4MessageTestPDU parsed := parsePDU(test.pdu) - err := parsed.VerifySignature(test.roomVersion, test.serverName, func(keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { + err := parsed.VerifySignature(test.roomVersion, test.serverName, func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { key = test.keys[keyID].key validUntil = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) return @@ -56,7 +56,7 @@ func TestPDU_VerifySignature_V4ExpiredKey(t *testing.T) { func TestPDU_VerifySignature_V12ExpiredKey(t *testing.T) { test := roomV12MessageTestPDU parsed := parsePDU(test.pdu) - err := parsed.VerifySignature(test.roomVersion, test.serverName, func(keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { + err := parsed.VerifySignature(test.roomVersion, test.serverName, func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { key = test.keys[keyID].key validUntil = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) return @@ -90,8 +90,8 @@ func TestPDU_Sign(t *testing.T) { } err := evt.Sign(id.RoomV12, "example.com", "ed25519:rand", privKey) require.NoError(t, err) - err = evt.VerifySignature(id.RoomV11, "example.com", func(keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { - if keyID == "ed25519:rand" { + err = evt.VerifySignature(id.RoomV11, "example.com", func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { + if serverName == "example.com" && keyID == "ed25519:rand" { key = id.SigningKey(base64.RawStdEncoding.EncodeToString(pubKey)) validUntil = time.Now() } diff --git a/federation/pdu/v1.go b/federation/pdu/v1.go index fc958b03..0e4c95e9 100644 --- a/federation/pdu/v1.go +++ b/federation/pdu/v1.go @@ -202,7 +202,7 @@ func (pdu *RoomV1PDU) VerifySignature(roomVersion id.RoomVersion, serverName str verified := false for keyID, sig := range pdu.Signatures[serverName] { originServerTS := time.UnixMilli(pdu.OriginServerTS) - key, _, err := getKey(keyID, originServerTS) + key, _, err := getKey(serverName, keyID, originServerTS) if err != nil { return fmt.Errorf("failed to get key %s for %s: %w", keyID, serverName, err) } else if key == "" { diff --git a/federation/pdu/v1_test.go b/federation/pdu/v1_test.go index e5531b0b..ecf2dbd2 100644 --- a/federation/pdu/v1_test.go +++ b/federation/pdu/v1_test.go @@ -73,7 +73,7 @@ func TestRoomV1PDU_VerifySignature(t *testing.T) { for _, test := range testV1PDUs { t.Run(test.name, func(t *testing.T) { parsed := parseV1PDU(test.pdu) - err := parsed.VerifySignature(test.roomVersion, test.serverName, func(keyID id.KeyID, _ time.Time) (id.SigningKey, time.Time, error) { + err := parsed.VerifySignature(test.roomVersion, test.serverName, func(serverName string, keyID id.KeyID, _ time.Time) (id.SigningKey, time.Time, error) { key, ok := test.keys[keyID] if ok { return key.key, key.validUntilTS, nil From 71bbbdb3c31e5b49534434eb16dca3d0af147c80 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 23 Aug 2025 23:22:43 +0300 Subject: [PATCH 250/581] federation/pdu: use jsontext.Value instead of any for deprecated fields --- federation/pdu/pdu.go | 6 +++--- federation/pdu/v1.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/federation/pdu/pdu.go b/federation/pdu/pdu.go index c6faf3d0..b5210550 100644 --- a/federation/pdu/pdu.go +++ b/federation/pdu/pdu.go @@ -71,9 +71,9 @@ type PDU struct { Unknown jsontext.Value `json:",unknown"` // Deprecated legacy fields - DeprecatedPrevState any `json:"prev_state,omitzero"` - DeprecatedOrigin any `json:"origin,omitzero"` - DeprecatedMembership any `json:"membership,omitzero"` + DeprecatedPrevState jsontext.Value `json:"prev_state,omitzero"` + DeprecatedOrigin jsontext.Value `json:"origin,omitzero"` + DeprecatedMembership jsontext.Value `json:"membership,omitzero"` } var ErrPDUIsNil = errors.New("PDU is nil") diff --git a/federation/pdu/v1.go b/federation/pdu/v1.go index 0e4c95e9..9557f8ab 100644 --- a/federation/pdu/v1.go +++ b/federation/pdu/v1.go @@ -75,9 +75,9 @@ type RoomV1PDU struct { Unknown jsontext.Value `json:",unknown"` // Deprecated legacy fields - DeprecatedPrevState any `json:"prev_state,omitzero"` - DeprecatedOrigin any `json:"origin,omitzero"` - DeprecatedMembership any `json:"membership,omitzero"` + DeprecatedPrevState jsontext.Value `json:"prev_state,omitzero"` + DeprecatedOrigin jsontext.Value `json:"origin,omitzero"` + DeprecatedMembership jsontext.Value `json:"membership,omitzero"` } func (pdu *RoomV1PDU) GetRoomID() (id.RoomID, error) { From d2cad8c57ec07ff8b0bce06e5485eeb0071e2e1b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 24 Aug 2025 00:44:50 +0300 Subject: [PATCH 251/581] format: add MarkdownMentionWithName helper --- format/markdown.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/format/markdown.go b/format/markdown.go index 3d9979b4..3b1c1f51 100644 --- a/format/markdown.go +++ b/format/markdown.go @@ -57,7 +57,11 @@ type uriAble interface { } func MarkdownMention(id uriAble) string { - return MarkdownLink(id.String(), id.URI().MatrixToURL()) + return MarkdownMentionWithName(id.String(), id) +} + +func MarkdownMentionWithName(name string, id uriAble) string { + return MarkdownLink(name, id.URI().MatrixToURL()) } func MarkdownLink(name string, url string) string { From 7e07700a69437cea25ee99dfd3e2f213bcd70f94 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 24 Aug 2025 00:47:55 +0300 Subject: [PATCH 252/581] format: add MarkdownMentionRoomID helper --- format/markdown.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/format/markdown.go b/format/markdown.go index 3b1c1f51..77ced0dc 100644 --- a/format/markdown.go +++ b/format/markdown.go @@ -64,6 +64,13 @@ func MarkdownMentionWithName(name string, id uriAble) string { return MarkdownLink(name, id.URI().MatrixToURL()) } +func MarkdownMentionRoomID(name string, id id.RoomID, via ...string) string { + if name == "" { + name = id.String() + } + return MarkdownLink(name, id.URI(via...).MatrixToURL()) +} + func MarkdownLink(name string, url string) string { return fmt.Sprintf("[%s](%s)", EscapeMarkdown(name), EscapeMarkdown(url)) } From fa7c1ae2bcd716f29a823a4167ec6a0c9206a5d2 Mon Sep 17 00:00:00 2001 From: Brad Murray Date: Mon, 25 Aug 2025 08:03:13 -0400 Subject: [PATCH 253/581] crypto/sqlstore: add index to make finding megolm sessions to backup faster (#402) ``` 2025-08-24T22:23:19Z debug [MatrixBridgeV2] {"level":"warn","component":"matrix","component":"client_loop","subcomponent":"sync_key_backup_loop","rows":0,"duration_seconds":1.046191042,"method":"EndRows","query":"SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version FROM crypto_megolm_inbound_session WHERE account_id=?1 AND session IS NOT NULL AND key_backup_version != ?2","time":"2025-08-24T22:23:19.22077Z","message":"Query took long"} ``` before: ``` sqlite> EXPLAIN SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version FROM crypto_megolm_inbound_session WHERE account_id='@brad:beeper.com/CHNWOJWEUC' AND sessi addr opcode p1 p2 p3 p4 p5 comment ---- ------------- ---- ---- ---- ------------- -- ------------- 0 Init 0 25 0 0 Start at 25 1 OpenRead 0 48 0 15 0 root=48 iDb=0; crypto_megolm_inbound_session 2 OpenRead 1 49 0 k(3,,,) 2 root=49 iDb=0; sqlite_autoindex_crypto_megolm_inbound_session_1 3 String8 0 1 0 @brad:beeper.com/CHNWOJWEUC 0 r[1]='@brad:beeper.com/CHNWOJWEUC' 4 SeekGE 1 24 1 1 0 key=r[1] 5 IdxGT 1 24 1 1 0 key=r[1] 6 DeferredSeek 1 0 0 0 Move 0 to 1.rowid if needed 7 Column 0 5 2 128 r[2]= cursor 0 column 5 8 IsNull 2 23 0 0 if r[2]==NULL goto 23 9 Column 0 14 2 0 r[2]=crypto_megolm_inbound_session.key_backup_version 10 Eq 3 23 2 BINARY-8 82 if r[2]==r[3] goto 23 11 Column 0 4 4 0 r[4]= cursor 0 column 4 12 Column 0 2 5 0 r[5]= cursor 0 column 2 13 Column 0 3 6 0 r[6]= cursor 0 column 3 14 Column 0 5 7 0 r[7]= cursor 0 column 5 15 Column 0 6 8 0 r[8]= cursor 0 column 6 16 Column 0 9 9 0 r[9]= cursor 0 column 9 17 Column 0 10 10 0 r[10]= cursor 0 column 10 18 Column 0 11 11 0 r[11]= cursor 0 column 11 19 Column 0 12 12 0 r[12]= cursor 0 column 12 20 Column 0 13 13 0 0 r[13]=crypto_megolm_inbound_session.is_scheduled 21 Column 0 14 14 0 r[14]=crypto_megolm_inbound_session.key_backup_version 22 ResultRow 4 11 0 0 output=r[4..14] 23 Next 1 5 0 0 24 Halt 0 0 0 0 25 Transaction 0 0 55 0 1 usesStmtJournal=0 26 Integer 1 3 0 0 r[3]=1 27 Goto 0 1 0 0 sqlite> SELECT COUNT(*) FROM crypto_megolm_inbound_session ; +----------+ | COUNT(*) | +----------+ | 168792 | +----------+ sqlite> SELECT COUNT(*) FROM crypto_megolm_inbound_session WHERE session IS NULL; +----------+ | COUNT(*) | +----------+ | 39 | +----------+ sqlite> SELECT COUNT(*) FROM crypto_megolm_inbound_session WHERE key_backup_version != 1; +----------+ | COUNT(*) | +----------+ | 39 | +----------+ ``` after: ``` sqlite> CREATE INDEX idx_megolm_filtered ...> ON crypto_megolm_inbound_session(account_id, key_backup_version, session); sqlite> EXPLAIN SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version FROM crypto_megolm_inbound_session WHERE account_id='@brad:beeper.com/CHNWOJWEUC' AND session IS NOT NULL AND key_backup_version != 1; addr opcode p1 p2 p3 p4 p5 comment ---- ------------- ---- ---- ---- ------------- -- ------------- 0 Init 0 25 0 0 Start at 25 1 OpenRead 0 48 0 15 0 root=48 iDb=0; crypto_megolm_inbound_session 2 OpenRead 1 91264 0 k(4,,,,) 2 root=91264 iDb=0; idx_megolm_filtered 3 String8 0 1 0 @brad:beeper.com/CHNWOJWEUC 0 r[1]='@brad:beeper.com/CHNWOJWEUC' 4 SeekGE 1 24 1 1 0 key=r[1] 5 IdxGT 1 24 1 1 0 key=r[1] 6 DeferredSeek 1 0 0 0 Move 0 to 1.rowid if needed 7 Column 1 2 2 128 r[2]= cursor 1 column 2 8 IsNull 2 23 0 0 if r[2]==NULL goto 23 9 Column 1 1 2 0 r[2]=crypto_megolm_inbound_session.key_backup_version 10 Eq 3 23 2 BINARY-8 82 if r[2]==r[3] goto 23 11 Column 0 4 4 0 r[4]= cursor 0 column 4 12 Column 0 2 5 0 r[5]= cursor 0 column 2 13 Column 0 3 6 0 r[6]= cursor 0 column 3 14 Column 1 2 7 0 r[7]= cursor 1 column 2 15 Column 0 6 8 0 r[8]= cursor 0 column 6 16 Column 0 9 9 0 r[9]= cursor 0 column 9 17 Column 0 10 10 0 r[10]= cursor 0 column 10 18 Column 0 11 11 0 r[11]= cursor 0 column 11 19 Column 0 12 12 0 r[12]= cursor 0 column 12 20 Column 0 13 13 0 0 r[13]=crypto_megolm_inbound_session.is_scheduled 21 Column 1 1 14 0 r[14]=crypto_megolm_inbound_session.key_backup_version 22 ResultRow 4 11 0 0 output=r[4..14] 23 Next 1 5 0 0 24 Halt 0 0 0 0 25 Transaction 0 0 56 0 1 usesStmtJournal=0 26 Integer 1 3 0 0 r[3]=1 27 Goto 0 1 0 0 sqlite> ``` --- crypto/sql_store_upgrade/00-latest-revision.sql | 4 +++- .../18-megolm-inbound-session-backup-index.sql | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 crypto/sql_store_upgrade/18-megolm-inbound-session-backup-index.sql diff --git a/crypto/sql_store_upgrade/00-latest-revision.sql b/crypto/sql_store_upgrade/00-latest-revision.sql index 00dd1387..af8ab5cc 100644 --- a/crypto/sql_store_upgrade/00-latest-revision.sql +++ b/crypto/sql_store_upgrade/00-latest-revision.sql @@ -1,4 +1,4 @@ --- v0 -> v17 (compatible with v15+): Latest revision +-- v0 -> v18 (compatible with v15+): Latest revision CREATE TABLE IF NOT EXISTS crypto_account ( account_id TEXT PRIMARY KEY, device_id TEXT NOT NULL, @@ -73,6 +73,8 @@ CREATE TABLE IF NOT EXISTS crypto_megolm_inbound_session ( key_backup_version TEXT NOT NULL DEFAULT '', PRIMARY KEY (account_id, session_id) ); +-- Useful index to find keys that need backing up +CREATE INDEX crypto_megolm_inbound_session_backup_idx ON crypto_megolm_inbound_session(account_id, key_backup_version) WHERE session IS NOT NULL; CREATE TABLE IF NOT EXISTS crypto_megolm_outbound_session ( account_id TEXT, diff --git a/crypto/sql_store_upgrade/18-megolm-inbound-session-backup-index.sql b/crypto/sql_store_upgrade/18-megolm-inbound-session-backup-index.sql new file mode 100644 index 00000000..da26da0f --- /dev/null +++ b/crypto/sql_store_upgrade/18-megolm-inbound-session-backup-index.sql @@ -0,0 +1,2 @@ +-- v18 (compatible with v15+): Add an index to the megolm_inbound_session table to make finding sessions to backup faster +CREATE INDEX crypto_megolm_inbound_session_backup_idx ON crypto_megolm_inbound_session(account_id, key_backup_version) WHERE session IS NOT NULL; From c04d0b66819b5ca54425140c1bd77c100262a9c7 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 25 Aug 2025 12:58:28 +0300 Subject: [PATCH 254/581] bridgev2: merge mentions and url previews when merging caption --- bridgev2/networkinterface.go | 4 ++++ event/message.go | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index eb38bd2d..d792ed0d 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -117,11 +117,15 @@ func MergeCaption(textPart, mediaPart *ConvertedMessagePart) *ConvertedMessagePa mediaPart.Content.EnsureHasHTML() mediaPart.Content.Body += "\n\n" + textPart.Content.Body mediaPart.Content.FormattedBody += "

" + textPart.Content.FormattedBody + mediaPart.Content.Mentions = mediaPart.Content.Mentions.Merge(textPart.Content.Mentions) + mediaPart.Content.BeeperLinkPreviews = append(mediaPart.Content.BeeperLinkPreviews, textPart.Content.BeeperLinkPreviews...) } else { mediaPart.Content.FileName = mediaPart.Content.Body mediaPart.Content.Body = textPart.Content.Body mediaPart.Content.Format = textPart.Content.Format mediaPart.Content.FormattedBody = textPart.Content.FormattedBody + mediaPart.Content.Mentions = textPart.Content.Mentions + mediaPart.Content.BeeperLinkPreviews = textPart.Content.BeeperLinkPreviews } if metaMerger, ok := mediaPart.DBMetadata.(database.MetaMerger); ok { metaMerger.CopyFrom(textPart.DBMetadata) diff --git a/event/message.go b/event/message.go index f16822f2..cc7c8261 100644 --- a/event/message.go +++ b/event/message.go @@ -273,6 +273,18 @@ func (m *Mentions) Has(userID id.UserID) bool { return m != nil && slices.Contains(m.UserIDs, userID) } +func (m *Mentions) Merge(other *Mentions) *Mentions { + if m == nil { + return other + } else if other == nil { + return m + } + return &Mentions{ + UserIDs: slices.Concat(m.UserIDs, other.UserIDs), + Room: m.Room || other.Room, + } +} + type EncryptedFileInfo struct { attachment.EncryptedFile URL id.ContentURIString `json:"url"` From 0fab92dbc1cafb65688d02273c3553c01cd0dc4f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 25 Aug 2025 12:58:40 +0300 Subject: [PATCH 255/581] event: add third party invite state event content --- event/content.go | 1 + event/member.go | 30 +++++++++++++++++++++--------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/event/content.go b/event/content.go index 779330af..5924ffe3 100644 --- a/event/content.go +++ b/event/content.go @@ -18,6 +18,7 @@ import ( // This is used by Content.ParseRaw() for creating the correct type of struct. var TypeMap = map[Type]reflect.Type{ StateMember: reflect.TypeOf(MemberEventContent{}), + StateThirdPartyInvite: reflect.TypeOf(ThirdPartyInviteEventContent{}), StatePowerLevels: reflect.TypeOf(PowerLevelsEventContent{}), StateCanonicalAlias: reflect.TypeOf(CanonicalAliasEventContent{}), StateRoomName: reflect.TypeOf(RoomNameEventContent{}), diff --git a/event/member.go b/event/member.go index 3e53893a..9956a36b 100644 --- a/event/member.go +++ b/event/member.go @@ -7,8 +7,6 @@ package event import ( - "encoding/json" - "maunium.net/go/mautrix/id" ) @@ -47,11 +45,25 @@ type MemberEventContent struct { MSC4293RedactEvents bool `json:"org.matrix.msc4293.redact_events,omitempty"` } -type ThirdPartyInvite struct { - DisplayName string `json:"display_name"` - Signed struct { - Token string `json:"token"` - Signatures json.RawMessage `json:"signatures"` - MXID string `json:"mxid"` - } `json:"signed"` +type SignedThirdPartyInvite struct { + Token string `json:"token"` + Signatures map[string]map[id.KeyID]string `json:"signatures,omitempty"` + MXID string `json:"mxid"` +} + +type ThirdPartyInvite struct { + DisplayName string `json:"display_name"` + Signed SignedThirdPartyInvite `json:"signed"` +} + +type ThirdPartyInviteEventContent struct { + DisplayName string `json:"display_name"` + KeyValidityURL string `json:"key_validity_url"` + PublicKey id.Ed25519 `json:"public_key"` + PublicKeys []ThirdPartyInviteKey `json:"public_keys,omitempty"` +} + +type ThirdPartyInviteKey struct { + KeyValidityURL string `json:"key_validity_url,omitempty"` + PublicKey id.Ed25519 `json:"public_key"` } From 5ac8a888a3a5b11165172a4bab0e65c74ade1737 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 25 Aug 2025 17:15:19 +0300 Subject: [PATCH 256/581] bridgev2/portal: make UpdateDisappearingSetting more versatile --- bridgev2/portal.go | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 7c3a56c2..0aae674d 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4038,7 +4038,15 @@ func DisappearingMessageNotice(expiration time.Duration, implicit bool) *event.M return content } -func (portal *Portal) UpdateDisappearingSetting(ctx context.Context, setting database.DisappearingSetting, sender MatrixAPI, ts time.Time, implicit, save bool) bool { +type UpdateDisappearingSettingOpts struct { + Sender MatrixAPI + Timestamp time.Time + Implicit bool + Save bool + SendNotice bool +} + +func (portal *Portal) UpdateDisappearingSetting(ctx context.Context, setting database.DisappearingSetting, opts UpdateDisappearingSettingOpts) bool { if setting.Timer == 0 { setting.Type = event.DisappearingTypeNone } @@ -4047,7 +4055,7 @@ func (portal *Portal) UpdateDisappearingSetting(ctx context.Context, setting dat } portal.Disappear.Type = setting.Type portal.Disappear.Timer = setting.Timer - if save { + if opts.Save { err := portal.Save(ctx) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal to database after updating disappearing setting") @@ -4057,21 +4065,21 @@ func (portal *Portal) UpdateDisappearingSetting(ctx context.Context, setting dat return true } - portal.sendRoomMeta(ctx, sender, ts, event.StateBeeperDisappearingTimer, "", setting.ToEventContent()) - - content := DisappearingMessageNotice(setting.Timer, implicit) - if sender == nil { - sender = portal.Bridge.Bot + if opts.Sender == nil { + opts.Sender = portal.Bridge.Bot } - _, err := sender.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{ + portal.sendRoomMeta(ctx, opts.Sender, opts.Timestamp, event.StateBeeperDisappearingTimer, "", setting.ToEventContent()) + + content := DisappearingMessageNotice(setting.Timer, opts.Implicit) + _, err := opts.Sender.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{ Parsed: content, - }, &MatrixSendExtra{Timestamp: ts}) + }, &MatrixSendExtra{Timestamp: opts.Timestamp}) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to send disappearing messages notice") } else { zerolog.Ctx(ctx).Debug(). Dur("new_timer", portal.Disappear.Timer). - Bool("implicit", implicit). + Bool("implicit", opts.Implicit). Msg("Sent disappearing messages notice") } return true @@ -4162,7 +4170,13 @@ func (portal *Portal) UpdateInfo(ctx context.Context, info *ChatInfo, source *Us changed = portal.updateAvatar(ctx, info.Avatar, sender, ts) || changed } if info.Disappear != nil { - changed = portal.UpdateDisappearingSetting(ctx, *info.Disappear, sender, ts, false, false) || changed + changed = portal.UpdateDisappearingSetting(ctx, *info.Disappear, UpdateDisappearingSettingOpts{ + Sender: sender, + Timestamp: ts, + Implicit: false, + Save: false, + SendNotice: true, + }) || changed } if info.ParentID != nil { changed = portal.updateParent(ctx, *info.ParentID, source) || changed From 8e703410f48ca9c94b2288c6608a4d8f5c39ff3c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 25 Aug 2025 17:21:55 +0300 Subject: [PATCH 257/581] bridgev2/portal: always set timestamp for disappearing message timer update --- bridgev2/portal.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 0aae674d..e523b7bd 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4068,6 +4068,9 @@ func (portal *Portal) UpdateDisappearingSetting(ctx context.Context, setting dat if opts.Sender == nil { opts.Sender = portal.Bridge.Bot } + if opts.Timestamp.IsZero() { + opts.Timestamp = time.Now() + } portal.sendRoomMeta(ctx, opts.Sender, opts.Timestamp, event.StateBeeperDisappearingTimer, "", setting.ToEventContent()) content := DisappearingMessageNotice(setting.Timer, opts.Implicit) From f860b0e2386ae669fb2b9829e46dc9d874c8c599 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 25 Aug 2025 17:23:25 +0300 Subject: [PATCH 258/581] bridgev2/portal: fix send notice option when updating disappearing message timer --- bridgev2/portal.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index e523b7bd..f0d2d0a1 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4073,6 +4073,9 @@ func (portal *Portal) UpdateDisappearingSetting(ctx context.Context, setting dat } portal.sendRoomMeta(ctx, opts.Sender, opts.Timestamp, event.StateBeeperDisappearingTimer, "", setting.ToEventContent()) + if !opts.SendNotice { + return true + } content := DisappearingMessageNotice(setting.Timer, opts.Implicit) _, err := opts.Sender.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{ Parsed: content, From a6bbe978bd5520b3722518a54ff2c60d7588beeb Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 25 Aug 2025 17:31:12 +0300 Subject: [PATCH 259/581] bridgev2/networkinterface: add interface for handling disappearing timer changes from Matrix --- bridgev2/matrix/connector.go | 1 + bridgev2/networkinterface.go | 9 +++++++++ bridgev2/portal.go | 15 ++++++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 19eb399b..c5ee40fe 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -147,6 +147,7 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) { br.EventProcessor.On(event.StateRoomAvatar, br.handleRoomEvent) br.EventProcessor.On(event.StateTopic, br.handleRoomEvent) br.EventProcessor.On(event.StateTombstone, br.handleRoomEvent) + br.EventProcessor.On(event.StateBeeperDisappearingTimer, br.handleRoomEvent) br.EventProcessor.On(event.EphemeralEventReceipt, br.handleEphemeralEvent) br.EventProcessor.On(event.EphemeralEventTyping, br.handleEphemeralEvent) br.Bot = br.AS.BotIntent() diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index d792ed0d..dcbcbad5 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -683,6 +683,14 @@ type RoomTopicHandlingNetworkAPI interface { HandleMatrixRoomTopic(ctx context.Context, msg *MatrixRoomTopic) (bool, error) } +type DisappearTimerChangingNetworkAPI interface { + NetworkAPI + // HandleMatrixDisappearingTimer is called when the disappearing timer of a portal room is changed. + // This method should update the Disappear field of the Portal with the new timer and return true + // if the change was successful. If the change is not successful, then the field should not be updated. + HandleMatrixDisappearingTimer(ctx context.Context, msg *MatrixDisappearingTimer) (bool, error) +} + type ResolveIdentifierResponse struct { // Ghost is the ghost of the user that the identifier resolves to. // This field should be set whenever possible. However, it is not required, @@ -1270,6 +1278,7 @@ type MatrixRoomMeta[ContentType any] struct { type MatrixRoomName = MatrixRoomMeta[*event.RoomNameEventContent] type MatrixRoomAvatar = MatrixRoomMeta[*event.RoomAvatarEventContent] type MatrixRoomTopic = MatrixRoomMeta[*event.TopicEventContent] +type MatrixDisappearingTimer = MatrixRoomMeta[*event.BeeperDisappearingTimer] type MatrixReadReceipt struct { Portal *Portal diff --git a/bridgev2/portal.go b/bridgev2/portal.go index f0d2d0a1..24365df9 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -663,6 +663,8 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, RoomTopicHandlingNetworkAPI.HandleMatrixRoomTopic) case event.StateRoomAvatar: return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, RoomAvatarHandlingNetworkAPI.HandleMatrixRoomAvatar) + case event.StateBeeperDisappearingTimer: + return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, DisappearTimerChangingNetworkAPI.HandleMatrixDisappearingTimer) case event.StateEncryption: // TODO? return EventHandlingResultIgnored @@ -1477,6 +1479,15 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( portal.sendSuccessStatus(ctx, evt, 0, "") return EventHandlingResultIgnored } + case *event.BeeperDisappearingTimer: + if typedContent.Type == event.DisappearingTypeNone || typedContent.Timer.Duration <= 0 { + typedContent.Type = event.DisappearingTypeNone + typedContent.Timer.Duration = 0 + } + if typedContent.Type == portal.Disappear.Type && typedContent.Timer.Duration == portal.Disappear.Timer { + portal.sendSuccessStatus(ctx, evt, 0, "") + return EventHandlingResultIgnored + } } var prevContent ContentType if evt.Unsigned.PrevContent != nil { @@ -1500,7 +1511,9 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( return EventHandlingResultFailed.WithMSSError(err) } if changed { - portal.UpdateBridgeInfo(ctx) + if evt.Type != event.StateBeeperDisappearingTimer { + portal.UpdateBridgeInfo(ctx) + } err = portal.Save(ctx) if err != nil { log.Err(err).Msg("Failed to save portal after updating room metadata") From 4f7c7dafdc6af65fcada0c881c669405e374cf3f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 25 Aug 2025 17:42:20 +0300 Subject: [PATCH 260/581] bridgev2/matrix: fix encryption error notice not being redacted after retry success --- bridgev2/matrix/matrix.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bridgev2/matrix/matrix.go b/bridgev2/matrix/matrix.go index 49c377db..64165941 100644 --- a/bridgev2/matrix/matrix.go +++ b/bridgev2/matrix/matrix.go @@ -87,17 +87,18 @@ func (br *Connector) handleEncryptedEvent(ctx context.Context, evt *event.Event) decryptionStart := time.Now() decrypted, err := br.Crypto.Decrypt(ctx, evt) decryptionRetryCount := 0 + var errorEventID id.EventID if errors.Is(err, NoSessionFound) { decryptionRetryCount = 1 log.Debug(). Int("wait_seconds", int(initialSessionWaitTimeout.Seconds())). Msg("Couldn't find session, waiting for keys to arrive...") - go br.sendCryptoStatusError(ctx, evt, err, nil, 0, false) + go br.sendCryptoStatusError(ctx, evt, err, &errorEventID, 0, false) if br.Crypto.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, initialSessionWaitTimeout) { log.Debug().Msg("Got keys after waiting, trying to decrypt event again") decrypted, err = br.Crypto.Decrypt(ctx, evt) } else { - go br.waitLongerForSession(ctx, evt, decryptionStart) + go br.waitLongerForSession(ctx, evt, decryptionStart, &errorEventID) return } } @@ -106,10 +107,10 @@ func (br *Connector) handleEncryptedEvent(ctx context.Context, evt *event.Event) go br.sendCryptoStatusError(ctx, evt, err, nil, decryptionRetryCount, true) return } - br.postDecrypt(ctx, evt, decrypted, decryptionRetryCount, nil, time.Since(decryptionStart)) + br.postDecrypt(ctx, evt, decrypted, decryptionRetryCount, &errorEventID, time.Since(decryptionStart)) } -func (br *Connector) waitLongerForSession(ctx context.Context, evt *event.Event, decryptionStart time.Time) { +func (br *Connector) waitLongerForSession(ctx context.Context, evt *event.Event, decryptionStart time.Time, errorEventID *id.EventID) { log := zerolog.Ctx(ctx) content := evt.Content.AsEncrypted() log.Debug(). @@ -117,7 +118,6 @@ func (br *Connector) waitLongerForSession(ctx context.Context, evt *event.Event, Msg("Couldn't find session, requesting keys and waiting longer...") go br.Crypto.RequestSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID) - var errorEventID *id.EventID go br.sendCryptoStatusError(ctx, evt, fmt.Errorf("%w. The bridge will retry for %d seconds", errNoDecryptionKeys, int(extendedSessionWaitTimeout.Seconds())), errorEventID, 1, false) if !br.Crypto.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, extendedSessionWaitTimeout) { From bca8b0528c976aca0e1df4d284918dc60c1c01a4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 25 Aug 2025 18:27:49 +0300 Subject: [PATCH 261/581] sqlstatestore: fix GetPowerLevels returning non-nil even if power levels weren't found --- sqlstatestore/statestore.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sqlstatestore/statestore.go b/sqlstatestore/statestore.go index 0ed4b698..c4126802 100644 --- a/sqlstatestore/statestore.go +++ b/sqlstatestore/statestore.go @@ -370,7 +370,7 @@ func (store *SQLStateStore) SetEncryptionEvent(ctx context.Context, roomID id.Ro func (store *SQLStateStore) GetEncryptionEvent(ctx context.Context, roomID id.RoomID) (*event.EncryptionEventContent, error) { var data []byte err := store. - QueryRow(ctx, "SELECT encryption FROM mx_room_state WHERE room_id=$1", roomID). + QueryRow(ctx, "SELECT encryption FROM mx_room_state WHERE room_id=$1 AND encryption IS NOT NULL", roomID). Scan(&data) if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -406,7 +406,7 @@ func (store *SQLStateStore) SetPowerLevels(ctx context.Context, roomID id.RoomID func (store *SQLStateStore) GetPowerLevels(ctx context.Context, roomID id.RoomID) (levels *event.PowerLevelsEventContent, err error) { levels = &event.PowerLevelsEventContent{} err = store. - QueryRow(ctx, "SELECT power_levels, create_event FROM mx_room_state WHERE room_id=$1", roomID). + QueryRow(ctx, "SELECT power_levels, create_event FROM mx_room_state WHERE room_id=$1 AND power_levels IS NOT NULL", roomID). Scan(&dbutil.JSON{Data: &levels}, &dbutil.JSON{Data: &levels.CreateEvent}) if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -458,7 +458,7 @@ func (store *SQLStateStore) SetCreate(ctx context.Context, evt *event.Event) err func (store *SQLStateStore) GetCreate(ctx context.Context, roomID id.RoomID) (evt *event.Event, err error) { err = store. - QueryRow(ctx, "SELECT create_event FROM mx_room_state WHERE room_id=$1", roomID). + QueryRow(ctx, "SELECT create_event FROM mx_room_state WHERE room_id=$1 AND create_event IS NOT NULL", roomID). Scan(&dbutil.JSON{Data: &evt}) if errors.Is(err, sql.ErrNoRows) { return nil, nil From c3a422347ce3da25637a6f1239e676db530df01d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 25 Aug 2025 18:36:03 +0300 Subject: [PATCH 262/581] bridgev2/portal: validate capabilities when updating disappearing timer --- bridgev2/errors.go | 2 ++ bridgev2/portal.go | 7 +++++++ event/capabilities.go | 7 +++++++ 3 files changed, 16 insertions(+) diff --git a/bridgev2/errors.go b/bridgev2/errors.go index c023dcdf..026a95f4 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -67,6 +67,8 @@ var ( ErrPowerLevelsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group power levels")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) ErrRemoteEchoTimeout = WrapErrorInStatus(errors.New("remote echo timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) ErrRemoteAckTimeout = WrapErrorInStatus(errors.New("remote ack timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) + + ErrDisappearingTimerUnsupported error = WrapErrorInStatus(errors.New("invalid disappearing timer")).WithIsCertain(true) ) // Common login interface errors diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 24365df9..5e0a9137 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1488,6 +1488,10 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( portal.sendSuccessStatus(ctx, evt, 0, "") return EventHandlingResultIgnored } + if !sender.Client.GetCapabilities(ctx, portal).DisappearingTimer.Supports(typedContent) { + portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent()) + return EventHandlingResultFailed.WithMSSError(ErrDisappearingTimerUnsupported) + } } var prevContent ContentType if evt.Unsigned.PrevContent != nil { @@ -1508,6 +1512,9 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( }) if err != nil { log.Err(err).Msg("Failed to handle Matrix room metadata") + if evt.Type == event.StateBeeperDisappearingTimer { + portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent()) + } return EventHandlingResultFailed.WithMSSError(err) } if changed { diff --git a/event/capabilities.go b/event/capabilities.go index f44d6600..20f87bce 100644 --- a/event/capabilities.go +++ b/event/capabilities.go @@ -76,6 +76,13 @@ type DisappearingTimerCapability struct { OmitEmptyTimer bool `json:"omit_empty_timer,omitempty"` } +func (dtc *DisappearingTimerCapability) Supports(content *BeeperDisappearingTimer) bool { + if dtc == nil || content.Type == DisappearingTypeNone { + return true + } + return slices.Contains(dtc.Types, content.Type) && slices.Contains(dtc.Timers, content.Timer) +} + type CapabilityMsgType = MessageType // Message types which are used for event capability signaling, but aren't real values for the msgtype field. From 63b654187d40ed2538ad16e48e3b16fe2280df63 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 25 Aug 2025 19:03:07 +0300 Subject: [PATCH 263/581] event: marshal zero disappearing timers as empty object --- event/state.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/event/state.go b/event/state.go index 66b06b14..8711f857 100644 --- a/event/state.go +++ b/event/state.go @@ -8,6 +8,7 @@ package event import ( "encoding/base64" + "encoding/json" "slices" "go.mau.fi/util/jsontime" @@ -223,6 +224,15 @@ type BeeperDisappearingTimer struct { Timer jsontime.Milliseconds `json:"timer"` } +type marshalableBeeperDisappearingTimer BeeperDisappearingTimer + +func (bdt *BeeperDisappearingTimer) MarshalJSON() ([]byte, error) { + if bdt == nil || bdt.Type == DisappearingTypeNone { + return []byte("{}"), nil + } + return json.Marshal((*marshalableBeeperDisappearingTimer)(bdt)) +} + type SpaceChildEventContent struct { Via []string `json:"via,omitempty"` Order string `json:"order,omitempty"` From e9d4eeb33266ec3cc7ae9dfe0d854dd2bef8ae7c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 26 Aug 2025 15:56:27 +0300 Subject: [PATCH 264/581] bridgev2/status: add avatar_keys to remote profile --- bridgev2/status/bridgestate.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bridgev2/status/bridgestate.go b/bridgev2/status/bridgestate.go index 01a235a0..671303e0 100644 --- a/bridgev2/status/bridgestate.go +++ b/bridgev2/status/bridgestate.go @@ -22,6 +22,7 @@ import ( "go.mau.fi/util/ptr" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/attachment" "maunium.net/go/mautrix/id" ) @@ -87,6 +88,9 @@ type RemoteProfile struct { Username string `json:"username,omitempty"` Name string `json:"name,omitempty"` Avatar id.ContentURIString `json:"avatar,omitempty"` + + // Only used for backups of local bridge states + AvatarKeys *attachment.EncryptedFile `json:"avatar_keys,omitempty"` } func coalesce[T ~string](a, b T) T { @@ -102,6 +106,9 @@ func (rp *RemoteProfile) Merge(other RemoteProfile) RemoteProfile { other.Username = coalesce(rp.Username, other.Username) other.Name = coalesce(rp.Name, other.Name) other.Avatar = coalesce(rp.Avatar, other.Avatar) + if rp.AvatarKeys != nil { + other.AvatarKeys = rp.AvatarKeys + } return other } From 7b3a60742eb0f0b7e7a3a003578fa91cf4a787ad Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 26 Aug 2025 15:56:38 +0300 Subject: [PATCH 265/581] event: allow omitting timers from disappearing timer capability --- event/capabilities.d.ts | 3 ++- event/capabilities.go | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/event/capabilities.d.ts b/event/capabilities.d.ts index 7f1dce05..27164a5f 100644 --- a/event/capabilities.d.ts +++ b/event/capabilities.d.ts @@ -117,7 +117,8 @@ export enum DisappearingType { export interface DisappearingTimerCapability { types: DisappearingType[] - timers: milliseconds[] + /** Allowed timer values. If omitted, any timer is allowed. */ + timers?: milliseconds[] /** * Whether clients should omit the empty disappearing_timer object in messages that they don't want to disappear * diff --git a/event/capabilities.go b/event/capabilities.go index 20f87bce..ebedb6a2 100644 --- a/event/capabilities.go +++ b/event/capabilities.go @@ -71,7 +71,7 @@ type FileFeatureMap map[CapabilityMsgType]*FileFeatures type DisappearingTimerCapability struct { Types []DisappearingType `json:"types"` - Timers []jsontime.Milliseconds `json:"timers"` + Timers []jsontime.Milliseconds `json:"timers,omitempty"` OmitEmptyTimer bool `json:"omit_empty_timer,omitempty"` } @@ -80,7 +80,7 @@ func (dtc *DisappearingTimerCapability) Supports(content *BeeperDisappearingTime if dtc == nil || content.Type == DisappearingTypeNone { return true } - return slices.Contains(dtc.Types, content.Type) && slices.Contains(dtc.Timers, content.Timer) + return slices.Contains(dtc.Types, content.Type) && (dtc.Timers == nil || slices.Contains(dtc.Timers, content.Timer)) } type CapabilityMsgType = MessageType From 0345a5356de17d1006b462cd633d6d25be82a1c1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 26 Aug 2025 17:07:16 +0300 Subject: [PATCH 266/581] bridgev2/database: don't set disappearing timer content to nil --- bridgev2/database/disappear.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/database/disappear.go b/bridgev2/database/disappear.go index e830cb14..537d0552 100644 --- a/bridgev2/database/disappear.go +++ b/bridgev2/database/disappear.go @@ -39,7 +39,7 @@ type DisappearingSetting struct { func (ds DisappearingSetting) ToEventContent() *event.BeeperDisappearingTimer { if ds.Type == event.DisappearingTypeNone || ds.Timer == 0 { - return nil + return &event.BeeperDisappearingTimer{} } return &event.BeeperDisappearingTimer{ Type: ds.Type, From ba16c30a8cd11d91086f6efa77014c97a4546761 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 27 Aug 2025 00:45:33 +0200 Subject: [PATCH 267/581] federation/eventauth: add v3-v12 event auth rules (#401) --- federation/eventauth/eventauth.go | 838 ++++++++++++++++++ federation/eventauth/eventauth_test.go | 85 ++ .../eventauth/testroom-v12-success.jsonl | 17 + federation/pdu/pdu.go | 5 + federation/signutil/verify.go | 41 + 5 files changed, 986 insertions(+) create mode 100644 federation/eventauth/eventauth.go create mode 100644 federation/eventauth/eventauth_test.go create mode 100644 federation/eventauth/testroom-v12-success.jsonl diff --git a/federation/eventauth/eventauth.go b/federation/eventauth/eventauth.go new file mode 100644 index 00000000..d4a50969 --- /dev/null +++ b/federation/eventauth/eventauth.go @@ -0,0 +1,838 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package eventauth + +import ( + "encoding/json" + "encoding/json/jsontext" + "errors" + "fmt" + "slices" + "strconv" + "strings" + + "github.com/tidwall/gjson" + "go.mau.fi/util/exgjson" + "go.mau.fi/util/exstrings" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/federation/pdu" + "maunium.net/go/mautrix/federation/signutil" + "maunium.net/go/mautrix/id" +) + +type AuthFailError struct { + Index string + Message string + Wrapped error +} + +func (afe AuthFailError) Error() string { + if afe.Message != "" { + return fmt.Sprintf("fail %s: %s", afe.Index, afe.Message) + } else if afe.Wrapped != nil { + return fmt.Sprintf("fail %s: %s", afe.Index, afe.Wrapped.Error()) + } + return fmt.Sprintf("fail %s", afe.Index) +} + +func (afe AuthFailError) Unwrap() error { + return afe.Wrapped +} + +var mFederatePath = exgjson.Path("m.federate") + +var ( + ErrCreateHasPrevEvents = AuthFailError{Index: "1.1", Message: "m.room.create event has prev_events"} + ErrCreateHasRoomID = AuthFailError{Index: "1.2", Message: "m.room.create event has room_id set"} + ErrRoomIDDoesntMatchSender = AuthFailError{Index: "1.2", Message: "room ID server doesn't match sender server"} + ErrUnknownRoomVersion = AuthFailError{Index: "1.3", Wrapped: id.ErrUnknownRoomVersion} + ErrInvalidAdditionalCreators = AuthFailError{Index: "1.4", Message: "m.room.create event has invalid additional_creators"} + ErrMissingCreator = AuthFailError{Index: "1.4", Message: "m.room.create event is missing creator field"} + + ErrInvalidRoomIDLength = AuthFailError{Index: "2", Message: "room ID length is invalid"} + ErrFailedToGetCreateEvent = AuthFailError{Index: "2", Message: "failed to get m.room.create event"} + ErrCreateEventNotFound = AuthFailError{Index: "2", Message: "m.room.create event not found using room ID as event ID"} + ErrRejectedCreateEvent = AuthFailError{Index: "2", Message: "m.room.create event was rejected"} + + ErrFailedToGetAuthEvents = AuthFailError{Index: "3", Message: "failed to get auth events"} + ErrFailedToParsePowerLevels = AuthFailError{Index: "?", Message: "failed to parse power levels"} + ErrDuplicateAuthEvent = AuthFailError{Index: "3.1", Message: "duplicate type/state key pair in auth events"} + ErrNonStateAuthEvent = AuthFailError{Index: "3.2", Message: "non-state event in auth events"} + ErrMissingAuthEvent = AuthFailError{Index: "3.2", Message: "missing auth event"} + ErrUnexpectedAuthEvent = AuthFailError{Index: "3.2", Message: "unexpected type/state key pair in auth events"} + ErrNoCreateEvent = AuthFailError{Index: "3.2", Message: "no m.room.create event found in auth events"} + ErrRejectedAuthEvent = AuthFailError{Index: "3.3", Message: "auth event was rejected"} + ErrMismatchingRoomIDInAuthEvent = AuthFailError{Index: "3.4", Message: "auth event room ID does not match event room ID"} + + ErrFederationDisabled = AuthFailError{Index: "4", Message: "federation is disabled for this room"} + + ErrMemberNotState = AuthFailError{Index: "5.1", Message: "m.room.member event is not a state event"} + ErrNotSignedByAuthoriser = AuthFailError{Index: "5.2", Message: "m.room.member event is not signed by server of join_authorised_via_users_server"} + ErrCantJoinOtherUser = AuthFailError{Index: "5.3.2", Message: "can't send join event with different state key"} + ErrCantJoinBanned = AuthFailError{Index: "5.3.3", Message: "user is banned from the room"} + ErrAuthoriserCantInvite = AuthFailError{Index: "5.3.5.2", Message: "authoriser doesn't have sufficient power level to invite"} + ErrCantJoinWithoutInvite = AuthFailError{Index: "5.3.7", Message: "can't join invite-only room without invite"} + ErrInvalidJoinRule = AuthFailError{Index: "5.3.7", Message: "invalid join rule in room"} + ErrThirdPartyInviteBanned = AuthFailError{Index: "5.4.1.1", Message: "third party invite target user is banned"} + ErrThirdPartyInviteMissingFields = AuthFailError{Index: "5.4.1.3", Message: "third party invite is missing mxid or token fields"} + ErrThirdPartyInviteMXIDMismatch = AuthFailError{Index: "5.4.1.4", Message: "mxid in signed third party invite doesn't match event state key"} + ErrThirdPartyInviteNotFound = AuthFailError{Index: "5.4.1.5", Message: "matching m.room.third_party_invite event not found in auth events"} + ErrThirdPartyInviteSenderMismatch = AuthFailError{Index: "5.4.1.6", Message: "sender of third party invite doesn't match sender of member event"} + ErrThirdPartyInviteNotSigned = AuthFailError{Index: "5.4.1.8", Message: "no valid signatures found for third party invite"} + ErrInviterNotInRoom = AuthFailError{Index: "5.4.2", Message: "inviter's membership is not join"} + ErrInviteTargetAlreadyInRoom = AuthFailError{Index: "5.4.3", Message: "invite target user is already in the room"} + ErrInviteTargetBanned = AuthFailError{Index: "5.4.3", Message: "invite target user is banned"} + ErrInsufficientPermissionForInvite = AuthFailError{Index: "5.4.5", Message: "inviter does not have sufficient permission to send invites"} + ErrCantLeaveWithoutBeingInRoom = AuthFailError{Index: "5.5.1", Message: "can't leave room without being in it"} + ErrCantKickWithoutBeingInRoom = AuthFailError{Index: "5.5.2", Message: "can't kick another user without being in the room"} + ErrInsufficientPermissionForUnban = AuthFailError{Index: "5.5.3", Message: "sender does not have sufficient permission to unban users"} + ErrInsufficientPermissionForKick = AuthFailError{Index: "5.5.5", Message: "sender does not have sufficient permission to kick the user"} + ErrCantBanWithoutBeingInRoom = AuthFailError{Index: "5.6.1", Message: "can't ban another user without being in the room"} + ErrInsufficientPermissionForBan = AuthFailError{Index: "5.6.3", Message: "sender does not have sufficient permission to ban the user"} + ErrNotKnockableRoom = AuthFailError{Index: "5.7.1", Message: "join rule doesn't allow knocking"} + ErrCantKnockOtherUser = AuthFailError{Index: "5.7.1", Message: "can't send knock event with different state key"} + ErrCantKnockWhileInRoom = AuthFailError{Index: "5.7.2", Message: "can't knock while joined, invited or banned"} + ErrUnknownMembership = AuthFailError{Index: "5.8", Message: "unknown membership in m.room.member event"} + + ErrNotInRoom = AuthFailError{Index: "6", Message: "sender is not a member of the room"} + + ErrInsufficientPowerForThirdPartyInvite = AuthFailError{Index: "7.1", Message: "sender does not have sufficient power level to send third party invite"} + + ErrInsufficientPowerLevel = AuthFailError{Index: "8", Message: "sender does not have sufficient power level to send event"} + + ErrMismatchingPrivateStateKey = AuthFailError{Index: "9", Message: "state keys starting with @ must match sender user ID"} + + ErrTopLevelPLNotInteger = AuthFailError{Index: "10.1", Message: "invalid type for top-level power level field"} + ErrPLNotInteger = AuthFailError{Index: "10.2", Message: "invalid type for power level"} + ErrInvalidUserIDInPL = AuthFailError{Index: "10.3", Message: "invalid user ID in power levels"} + ErrUserPLNotInteger = AuthFailError{Index: "10.3", Message: "invalid type for user power level"} + ErrCreatorInPowerLevels = AuthFailError{Index: "10.4", Message: "room creators must not be specified in power levels"} + ErrInvalidPowerChange = AuthFailError{Index: "10.x", Message: "illegal power level change"} +) + +func isRejected(evt *pdu.PDU) bool { + return evt.InternalMeta.Rejected +} + +type GetEventsFunc = func(ids []id.EventID) ([]*pdu.PDU, error) + +func Authorize(roomVersion id.RoomVersion, evt *pdu.PDU, getEvents GetEventsFunc, getKey pdu.GetKeyFunc) error { + if evt.Type == event.StateCreate.Type { + // 1. If type is m.room.create: + return authorizeCreate(roomVersion, evt) + } + var createEvt *pdu.PDU + if roomVersion.RoomIDIsCreateEventID() { + // 2. If the event’s room_id is not an event ID for an accepted (not rejected) m.room.create event, + // with the sigil ! instead of $, reject. + if len(evt.RoomID) != 44 { + return fmt.Errorf("%w (%d)", ErrInvalidRoomIDLength, len(evt.RoomID)) + } else if createEvts, err := getEvents([]id.EventID{id.EventID("$" + evt.RoomID[1:])}); err != nil { + return fmt.Errorf("%w: %w", ErrFailedToGetCreateEvent, err) + } else if len(createEvts) != 1 { + return fmt.Errorf("%w (%s)", ErrCreateEventNotFound, evt.RoomID) + } else if isRejected(createEvts[0]) { + return ErrRejectedCreateEvent + } else { + createEvt = createEvts[0] + } + } + authEvents, err := getEvents(evt.AuthEvents) + if err != nil { + return fmt.Errorf("%w: %w", ErrFailedToGetAuthEvents, err) + } + expectedAuthEvents := evt.AuthEventSelection(roomVersion) + deduplicator := make(map[pdu.StateKey]id.EventID, len(expectedAuthEvents)) + // 3. Considering the event’s auth_events: + for i, ae := range authEvents { + authEvtID := evt.AuthEvents[i] + if ae == nil { + return fmt.Errorf("%w (%s)", ErrMissingAuthEvent, authEvtID) + } else if ae.StateKey == nil { + // This approximately falls under rule 3.2. + return fmt.Errorf("%w (%s)", ErrNonStateAuthEvent, authEvtID) + } + key := pdu.StateKey{Type: ae.Type, StateKey: *ae.StateKey} + if prevEvtID, alreadyFound := deduplicator[key]; alreadyFound { + // 3.1. If there are duplicate entries for a given type and state_key pair, reject. + return fmt.Errorf("%w for %s/%s: found %s and %s", ErrDuplicateAuthEvent, ae.Type, *ae.StateKey, prevEvtID, authEvtID) + } else if !expectedAuthEvents.Has(key) { + // 3.2. If there are entries whose type and state_key don’t match those specified by + // the auth events selection algorithm described in the server specification, reject. + return fmt.Errorf("%w: found %s with key %s/%s", ErrUnexpectedAuthEvent, authEvtID, ae.Type, *ae.StateKey) + } else if isRejected(ae) { + // 3.3. If there are entries which were themselves rejected under the checks performed on receipt of a PDU, reject. + return fmt.Errorf("%w (%s)", ErrRejectedAuthEvent, authEvtID) + } else if ae.RoomID != evt.RoomID { + // 3.4. If any event in auth_events has a room_id which does not match that of the event being authorised, reject. + return fmt.Errorf("%w (%s)", ErrMismatchingRoomIDInAuthEvent, authEvtID) + } else { + deduplicator[key] = authEvtID + } + if ae.Type == event.StateCreate.Type { + if createEvt == nil { + createEvt = ae + } else { + // Duplicates are prevented by deduplicator, AuthEventSelection also won't allow a create event at all for v12+ + panic(fmt.Errorf("impossible case: multiple create events found in auth events")) + } + } + } + if createEvt == nil { + // This comes either from auth_events or room_id depending on the room version. + // The checks above make sure it's from the right source. + return ErrNoCreateEvent + } + if federateVal := gjson.GetBytes(createEvt.Content, mFederatePath); federateVal.Type == gjson.False && createEvt.Sender.Homeserver() != evt.Sender.Homeserver() { + // 4. If the content of the m.room.create event in the room state has the property m.federate set to false, + // and the sender domain of the event does not match the sender domain of the create event, reject. + return ErrFederationDisabled + } + if evt.Type == event.StateMember.Type { + // 5. If type is m.room.member: + return authorizeMember(roomVersion, evt, createEvt, authEvents, getKey) + } + senderMembership := event.Membership(findEventAndReadString(authEvents, event.StateMember.Type, evt.Sender.String(), "membership", "leave")) + if senderMembership != event.MembershipJoin { + // 6. If the sender’s current membership state is not join, reject. + return ErrNotInRoom + } + powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) + if err != nil { + return err + } + senderPL := powerLevels.GetUserLevel(evt.Sender) + if evt.Type == event.StateThirdPartyInvite.Type { + // 7.1. Allow if and only if sender’s current power level is greater than or equal to the invite level. + if senderPL >= powerLevels.Invite() { + return nil + } + return ErrInsufficientPowerForThirdPartyInvite + } + typeClass := event.MessageEventType + if evt.StateKey != nil { + typeClass = event.StateEventType + } + evtLevel := powerLevels.GetEventLevel(event.Type{Type: evt.Type, Class: typeClass}) + if evtLevel > senderPL { + // 8. If the event type’s required power level is greater than the sender’s power level, reject. + return fmt.Errorf("%w (%d > %d)", ErrInsufficientPowerLevel, evtLevel, senderPL) + } + + if evt.StateKey != nil && strings.HasPrefix(*evt.StateKey, "@") && *evt.StateKey != evt.Sender.String() { + // 9. If the event has a state_key that starts with an @ and does not match the sender, reject. + return ErrMismatchingPrivateStateKey + } + + if evt.Type == event.StatePowerLevels.Type { + // 10. If type is m.room.power_levels: + return authorizePowerLevels(roomVersion, evt, createEvt, authEvents) + } + + // 11. Otherwise, allow. + return nil +} + +var ErrUserIDNotAString = errors.New("not a string") +var ErrUserIDNotValid = errors.New("not a valid user ID") + +func isValidUserID(roomVersion id.RoomVersion, userID gjson.Result) error { + if userID.Type != gjson.String { + return ErrUserIDNotAString + } + // In a future room version, user IDs will have stricter validation + _, _, err := id.UserID(userID.Str).Parse() + if err != nil { + return ErrUserIDNotValid + } + return nil +} + +func authorizeCreate(roomVersion id.RoomVersion, evt *pdu.PDU) error { + if len(evt.PrevEvents) > 0 { + // 1.1. If it has any prev_events, reject. + return ErrCreateHasPrevEvents + } + if roomVersion.RoomIDIsCreateEventID() { + if evt.RoomID != "" { + // 1.2. If the event has a room_id, reject. + return ErrCreateHasRoomID + } + } else { + _, _, server := id.ParseCommonIdentifier(evt.RoomID) + if server == "" || server != evt.Sender.Homeserver() { + // 1.2. (v11 and below) If the domain of the room_id does not match the domain of the sender, reject. + return ErrRoomIDDoesntMatchSender + } + } + if !roomVersion.IsKnown() { + // 1.3. If content.room_version is present and is not a recognised version, reject. + return fmt.Errorf("%w %s", ErrUnknownRoomVersion, roomVersion) + } + if roomVersion.PrivilegedRoomCreators() { + additionalCreators := gjson.GetBytes(evt.Content, "additional_creators") + if additionalCreators.Exists() { + if !additionalCreators.IsArray() { + return fmt.Errorf("%w: not an array", ErrInvalidAdditionalCreators) + } + for i, item := range additionalCreators.Array() { + // 1.4. If additional_creators is present in content and is not an array of strings + // where each string passes the same user ID validation applied to sender, reject. + if err := isValidUserID(roomVersion, item); err != nil { + return fmt.Errorf("%w: item #%d %w", ErrInvalidAdditionalCreators, i+1, err) + } + } + } + } + if roomVersion.CreatorInContent() { + // 1.4. (v10 and below) If content has no creator property, reject. + if !gjson.GetBytes(evt.Content, "creator").Exists() { + return ErrMissingCreator + } + } + // 1.5. Otherwise, allow. + return nil +} + +func authorizeMember(roomVersion id.RoomVersion, evt, createEvt *pdu.PDU, authEvents []*pdu.PDU, getKey pdu.GetKeyFunc) error { + membership := event.Membership(gjson.GetBytes(evt.Content, "membership").Str) + if evt.StateKey == nil { + // 5.1. If there is no state_key property, or no membership property in content, reject. + return ErrMemberNotState + } + authorizedVia := id.UserID(gjson.GetBytes(evt.Content, "authorized_via_users_server").Str) + if authorizedVia != "" { + homeserver := authorizedVia.Homeserver() + err := evt.VerifySignature(roomVersion, homeserver, getKey) + if err != nil { + // 5.2. If content has a join_authorised_via_users_server key: + // 5.2.1. If the event is not validly signed by the homeserver of the user ID denoted by the key, reject. + return fmt.Errorf("%w: %w", ErrNotSignedByAuthoriser, err) + } + } + targetPrevMembership := event.Membership(findEventAndReadString(authEvents, event.StateMember.Type, *evt.StateKey, "membership", "leave")) + senderMembership := event.Membership(findEventAndReadString(authEvents, event.StateMember.Type, evt.Sender.String(), "membership", "leave")) + switch membership { + case event.MembershipJoin: + createEvtID, err := createEvt.GetEventID(roomVersion) + if err != nil { + return fmt.Errorf("failed to get create event ID: %w", err) + } + creator := createEvt.Sender.String() + if roomVersion.CreatorInContent() { + creator = gjson.GetBytes(evt.Content, "creator").Str + } + if len(evt.PrevEvents) == 1 && + len(evt.AuthEvents) <= 1 && + evt.PrevEvents[0] == createEvtID && + *evt.StateKey == creator { + // 5.3.1. If the only previous event is an m.room.create and the state_key is the sender of the m.room.create, allow. + return nil + } + // Spec wart: this would make more sense before the check above. + // Now you can set anyone as the sender of the first join. + if evt.Sender.String() != *evt.StateKey { + // 5.3.2. If the sender does not match state_key, reject. + return ErrCantJoinOtherUser + } + + if senderMembership == event.MembershipBan { + // 5.3.3. If the sender is banned, reject. + return ErrCantJoinBanned + } + + joinRule := event.JoinRule(findEventAndReadString(authEvents, event.StateJoinRules.Type, "", "join_rule", "invite")) + switch joinRule { + case event.JoinRuleKnock: + if !roomVersion.Knocks() { + return ErrInvalidJoinRule + } + fallthrough + case event.JoinRuleInvite: + // 5.3.4. If the join_rule is invite or knock then allow if membership state is invite or join. + if targetPrevMembership == event.MembershipJoin || targetPrevMembership == event.MembershipInvite { + return nil + } + return ErrCantJoinWithoutInvite + case event.JoinRuleKnockRestricted: + if !roomVersion.KnockRestricted() { + return ErrInvalidJoinRule + } + fallthrough + case event.JoinRuleRestricted: + if joinRule == event.JoinRuleRestricted && !roomVersion.RestrictedJoins() { + return ErrInvalidJoinRule + } + if targetPrevMembership == event.MembershipJoin || targetPrevMembership == event.MembershipInvite { + // 5.3.5.1. If membership state is join or invite, allow. + return nil + } + powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) + if err != nil { + return err + } + if powerLevels.GetUserLevel(authorizedVia) < powerLevels.Invite() { + // 5.3.5.2. If the join_authorised_via_users_server key in content is not a user with sufficient permission to invite other users, reject. + return ErrAuthoriserCantInvite + } + // 5.3.5.3. Otherwise, allow. + return nil + case event.JoinRulePublic: + // 5.3.6. If the join_rule is public, allow. + return nil + default: + // 5.3.7. Otherwise, reject. + return ErrInvalidJoinRule + } + case event.MembershipInvite: + tpiVal := gjson.GetBytes(evt.Content, "third_party_invite") + if tpiVal.Exists() { + if targetPrevMembership == event.MembershipBan { + return ErrThirdPartyInviteBanned + } + signed := tpiVal.Get("signed") + mxid := signed.Get("mxid").Str + token := signed.Get("token").Str + if mxid == "" || token == "" { + // 5.4.1.2. If content.third_party_invite does not have a signed property, reject. + // 5.4.1.3. If signed does not have mxid and token properties, reject. + return ErrThirdPartyInviteMissingFields + } + if mxid != *evt.StateKey { + // 5.4.1.4. If mxid does not match state_key, reject. + return ErrThirdPartyInviteMXIDMismatch + } + tpiEvt := findEvent(authEvents, event.StateThirdPartyInvite.Type, token) + if tpiEvt == nil { + // 5.4.1.5. If there is no m.room.third_party_invite event in the current room state with state_key matching token, reject. + return ErrThirdPartyInviteNotFound + } + if tpiEvt.Sender != evt.Sender { + // 5.4.1.6. If sender does not match sender of the m.room.third_party_invite, reject. + return ErrThirdPartyInviteSenderMismatch + } + var keys []id.Ed25519 + const ed25519Base64Len = 43 + oldPubKey := gjson.GetBytes(evt.Content, "public_key.token") + if oldPubKey.Type == gjson.String && len(oldPubKey.Str) == ed25519Base64Len { + keys = append(keys, id.Ed25519(oldPubKey.Str)) + } + gjson.GetBytes(evt.Content, "public_keys").ForEach(func(key, value gjson.Result) bool { + if key.Type != gjson.Number { + return false + } + if value.Type == gjson.String && len(value.Str) == ed25519Base64Len { + keys = append(keys, id.Ed25519(value.Str)) + } + return true + }) + rawSigned := jsontext.Value(exstrings.UnsafeBytes(signed.Str)) + var validated bool + for _, key := range keys { + if signutil.VerifyJSONAny(key, rawSigned) == nil { + validated = true + } + } + if validated { + // 4.4.1.7. If any signature in signed matches any public key in the m.room.third_party_invite event, allow. + return nil + } + // 4.4.1.8. Otherwise, reject. + return ErrThirdPartyInviteNotSigned + } + if senderMembership != event.MembershipJoin { + // 5.4.2. If the sender’s current membership state is not join, reject. + return ErrInviterNotInRoom + } + // 5.4.3. If target user’s current membership state is join or ban, reject. + if targetPrevMembership == event.MembershipJoin { + return ErrInviteTargetAlreadyInRoom + } else if targetPrevMembership == event.MembershipBan { + return ErrInviteTargetBanned + } + powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) + if err != nil { + return err + } + if powerLevels.GetUserLevel(evt.Sender) >= powerLevels.Invite() { + // 5.4.4. If the sender’s power level is greater than or equal to the invite level, allow. + return nil + } + // 5.4.5. Otherwise, reject. + return ErrInsufficientPermissionForInvite + case event.MembershipLeave: + if evt.Sender.String() == *evt.StateKey { + // 5.5.1. If the sender matches state_key, allow if and only if that user’s current membership state is invite, join, or knock. + if senderMembership == event.MembershipInvite || + senderMembership == event.MembershipJoin || + (senderMembership == event.MembershipKnock && roomVersion.Knocks()) { + return nil + } + return ErrCantLeaveWithoutBeingInRoom + } + if senderMembership != event.MembershipLeave { + // 5.5.2. If the sender’s current membership state is not join, reject. + return ErrCantKickWithoutBeingInRoom + } + powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) + if err != nil { + return err + } + senderLevel := powerLevels.GetUserLevel(evt.Sender) + if targetPrevMembership == event.MembershipBan && senderLevel < powerLevels.Ban() { + // 5.5.3. If the target user’s current membership state is ban, and the sender’s power level is less than the ban level, reject. + return ErrInsufficientPermissionForUnban + } + if senderLevel >= powerLevels.Kick() && powerLevels.GetUserLevel(id.UserID(*evt.StateKey)) < senderLevel { + // 5.5.4. If the sender’s power level is greater than or equal to the kick level, and the target user’s power level is less than the sender’s power level, allow. + return nil + } + // TODO separate errors for < kick and < target user level? + // 5.5.5. Otherwise, reject. + return ErrInsufficientPermissionForKick + case event.MembershipBan: + if senderMembership != event.MembershipLeave { + // 5.6.1. If the sender’s current membership state is not join, reject. + return ErrCantBanWithoutBeingInRoom + } + powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) + if err != nil { + return err + } + senderLevel := powerLevels.GetUserLevel(evt.Sender) + if senderLevel >= powerLevels.Ban() && powerLevels.GetUserLevel(id.UserID(*evt.StateKey)) < senderLevel { + // 5.6.2. If the sender’s power level is greater than or equal to the ban level, and the target user’s power level is less than the sender’s power level, allow. + return nil + } + // 5.6.3. Otherwise, reject. + return ErrInsufficientPermissionForBan + case event.MembershipKnock: + joinRule := event.JoinRule(findEventAndReadString(authEvents, event.StateJoinRules.Type, "", "join_rule", "invite")) + validKnockRule := roomVersion.Knocks() && joinRule == event.JoinRuleKnock + validKnockRestrictedRule := roomVersion.KnockRestricted() && joinRule == event.JoinRuleKnockRestricted + if !validKnockRule && !validKnockRestrictedRule { + // 5.7.1. If the join_rule is anything other than knock or knock_restricted, reject. + return ErrNotKnockableRoom + } + if evt.Sender.String() != *evt.StateKey { + // 5.7.2. If the sender does not match state_key, reject. + return ErrCantKnockOtherUser + } + if senderMembership != event.MembershipBan && senderMembership != event.MembershipInvite && senderMembership != event.MembershipJoin { + // 5.7.3. If the sender’s current membership is not ban, invite, or join, allow. + return nil + } + // 5.7.4. Otherwise, reject. + return ErrCantKnockWhileInRoom + default: + // 5.8. Otherwise, the membership is unknown. Reject. + return ErrUnknownMembership + } +} + +func authorizePowerLevels(roomVersion id.RoomVersion, evt, createEvt *pdu.PDU, authEvents []*pdu.PDU) error { + if roomVersion.ValidatePowerLevelInts() { + for _, key := range []string{"users_default", "events_default", "state_default", "ban", "redact", "kick", "invite"} { + res := gjson.GetBytes(evt.Content, key) + if !res.Exists() { + continue + } + if parseIntWithVersion(roomVersion, res) == nil { + // 10.1. If any of the properties users_default, events_default, state_default, ban, redact, kick, or invite in content are present and not an integer, reject. + return fmt.Errorf("%w %s", ErrTopLevelPLNotInteger, key) + } + } + for _, key := range []string{"events", "notifications"} { + obj := gjson.GetBytes(evt.Content, key) + if !obj.Exists() { + continue + } + // 10.2. If either of the properties events or notifications in content are present and not an object [...], reject. + if !obj.IsObject() { + return fmt.Errorf("%w %s", ErrTopLevelPLNotInteger, key) + } + var err error + // 10.2. [...] are not an object with values that are integers, reject. + obj.ForEach(func(innerKey, value gjson.Result) bool { + if parseIntWithVersion(roomVersion, value) == nil { + err = fmt.Errorf("%w %s.%s", ErrPLNotInteger, key, innerKey.Str) + return false + } + return true + }) + if err != nil { + return err + } + } + } + var creators []id.UserID + if roomVersion.PrivilegedRoomCreators() { + creators = append(creators, createEvt.Sender) + gjson.GetBytes(createEvt.Content, "additional_creators").ForEach(func(key, value gjson.Result) bool { + creators = append(creators, id.UserID(value.Str)) + return true + }) + } + users := gjson.GetBytes(evt.Content, "users") + if users.Exists() { + if !users.IsObject() { + // 10.3. If the users property in content is not an object [...], reject. + return fmt.Errorf("%w users", ErrTopLevelPLNotInteger) + } + var err error + users.ForEach(func(key, value gjson.Result) bool { + if validatorErr := isValidUserID(roomVersion, key); validatorErr != nil { + // 10.3. [...] is not an object with keys that are valid user IDs [...], reject. + err = fmt.Errorf("%w: %q %w", ErrInvalidUserIDInPL, key.Str, validatorErr) + return false + } + if parseIntWithVersion(roomVersion, value) == nil { + // 10.3. [...] is not an object [...] with values that are integers, reject. + err = fmt.Errorf("%w %q", ErrUserPLNotInteger, key.Str) + return false + } + // creators is only filled if the room version has privileged room creators + if slices.Contains(creators, id.UserID(key.Str)) { + // 10.4. If the users property in content contains the sender of the m.room.create event or any of + // the additional_creators array (if present) from the content of the m.room.create event, reject. + err = fmt.Errorf("%w: %q", ErrCreatorInPowerLevels, key.Str) + return false + } + return true + }) + if err != nil { + return err + } + } + oldPL := findEvent(authEvents, event.StatePowerLevels.Type, "") + if oldPL == nil { + // 10.5. If there is no previous m.room.power_levels event in the room, allow. + return nil + } + if slices.Contains(creators, evt.Sender) { + // Skip remaining checks for creators + return nil + } + senderPLPtr := parsePythonInt(gjson.GetBytes(oldPL.Content, exgjson.Path("users", evt.Sender.String()))) + if senderPLPtr == nil { + senderPLPtr = parsePythonInt(gjson.GetBytes(oldPL.Content, "users_default")) + if senderPLPtr == nil { + senderPLPtr = ptr.Ptr(0) + } + } + for _, key := range []string{"users_default", "events_default", "state_default", "ban", "redact", "kick", "invite"} { + oldVal := gjson.GetBytes(oldPL.Content, key) + newVal := gjson.GetBytes(evt.Content, key) + if err := allowPowerChange(roomVersion, *senderPLPtr, key, oldVal, newVal); err != nil { + return err + } + } + if err := allowPowerChangeMap( + roomVersion, *senderPLPtr, "events", "", + gjson.GetBytes(oldPL.Content, "events"), + gjson.GetBytes(evt.Content, "events"), + ); err != nil { + return err + } + if err := allowPowerChangeMap( + roomVersion, *senderPLPtr, "notifications", "", + gjson.GetBytes(oldPL.Content, "notifications"), + gjson.GetBytes(evt.Content, "notifications"), + ); err != nil { + return err + } + if err := allowPowerChangeMap( + roomVersion, *senderPLPtr, "users", evt.Sender.String(), + gjson.GetBytes(oldPL.Content, "users"), + gjson.GetBytes(evt.Content, "users"), + ); err != nil { + return err + } + return nil +} + +func allowPowerChangeMap(roomVersion id.RoomVersion, maxVal int, path, ownID string, old, new gjson.Result) (err error) { + old.ForEach(func(key, value gjson.Result) bool { + newVal := new.Get(exgjson.Path(key.Str)) + err = allowPowerChange(roomVersion, maxVal, path+"."+key.Str, value, newVal) + if err == nil && ownID != "" && key.Str != ownID { + val := parseIntWithVersion(roomVersion, value) + if *val >= maxVal { + err = fmt.Errorf("%w: can't change users.%s from %s to %s with sender level %d", ErrInvalidPowerChange, key.Str, stringifyForError(value), stringifyForError(newVal), maxVal) + } + } + return err == nil + }) + if err != nil { + return + } + new.ForEach(func(key, value gjson.Result) bool { + err = allowPowerChange(roomVersion, maxVal, path+"."+key.Str, old.Get(exgjson.Path(key.Path(key.Str))), value) + return err == nil + }) + return +} + +func allowPowerChange(roomVersion id.RoomVersion, maxVal int, path string, old, new gjson.Result) error { + oldVal := parseIntWithVersion(roomVersion, old) + newVal := parseIntWithVersion(roomVersion, new) + if oldVal == nil { + if newVal == nil || *newVal <= maxVal { + return nil + } + } else if newVal == nil { + if *oldVal <= maxVal { + return nil + } + } else if *oldVal == *newVal || (*oldVal <= maxVal && *newVal <= maxVal) { + return nil + } + return fmt.Errorf("%w can't change %s from %s to %s with sender level %d", ErrInvalidPowerChange, path, stringifyForError(old), stringifyForError(new), maxVal) +} + +func stringifyForError(val gjson.Result) string { + if !val.Exists() { + return "null" + } + return val.Raw +} + +func findEvent(events []*pdu.PDU, evtType, stateKey string) *pdu.PDU { + for _, evt := range events { + if evt.Type == evtType && *evt.StateKey == stateKey { + return evt + } + } + return nil +} + +func findEventAndReadData[T any](events []*pdu.PDU, evtType, stateKey string, reader func(evt *pdu.PDU) T) T { + return reader(findEvent(events, evtType, stateKey)) +} + +func findEventAndReadString(events []*pdu.PDU, evtType, stateKey, fieldPath, defVal string) string { + return findEventAndReadData(events, evtType, stateKey, func(evt *pdu.PDU) string { + if evt == nil { + return defVal + } + res := gjson.GetBytes(evt.Content, fieldPath) + if res.Type != gjson.String { + return defVal + } + return res.Str + }) +} + +func getPowerLevels(roomVersion id.RoomVersion, authEvents []*pdu.PDU, createEvt *pdu.PDU) (*event.PowerLevelsEventContent, error) { + var err error + powerLevels := findEventAndReadData(authEvents, event.StatePowerLevels.Type, "", func(evt *pdu.PDU) (out event.PowerLevelsEventContent) { + if evt == nil { + return + } + content := evt.Content + if !roomVersion.ValidatePowerLevelInts() { + safeParsePowerLevels(content, &out) + } else { + err = json.Unmarshal(content, &out) + } + return + }) + if err != nil { + // This should never happen thanks to safeParsePowerLevels for v1-9 and strict validation in v10+ + return nil, fmt.Errorf("%w: %w", ErrFailedToParsePowerLevels, err) + } + if roomVersion.PrivilegedRoomCreators() { + powerLevels.CreateEvent, err = createEvt.ToClientEvent(roomVersion) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrFailedToParsePowerLevels, err) + } + err = powerLevels.CreateEvent.Content.ParseRaw(powerLevels.CreateEvent.Type) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrFailedToParsePowerLevels, err) + } + } else { + powerLevels.Users = map[id.UserID]int{ + createEvt.Sender: (1 << 53) - 1, + } + } + return &powerLevels, nil +} + +func parseIntWithVersion(roomVersion id.RoomVersion, val gjson.Result) *int { + if roomVersion.ValidatePowerLevelInts() { + if val.Type != gjson.Number { + return nil + } + return ptr.Ptr(int(val.Int())) + } + return parsePythonInt(val) +} + +func parsePythonInt(val gjson.Result) *int { + switch val.Type { + case gjson.True: + return ptr.Ptr(1) + case gjson.False: + return ptr.Ptr(0) + case gjson.Number: + return ptr.Ptr(int(val.Int())) + case gjson.String: + // strconv.Atoi accepts signs as well as leading zeroes, so we just need to trim spaces beforehand + num, err := strconv.Atoi(strings.TrimSpace(val.Str)) + if err != nil { + return nil + } + return &num + default: + // Python int() doesn't accept nulls, arrays or dicts + return nil + } +} + +func safeParsePowerLevels(content jsontext.Value, into *event.PowerLevelsEventContent) { + *into = event.PowerLevelsEventContent{ + Users: make(map[id.UserID]int), + UsersDefault: ptr.Val(parsePythonInt(gjson.GetBytes(content, "users_default"))), + Events: make(map[string]int), + EventsDefault: ptr.Val(parsePythonInt(gjson.GetBytes(content, "events_default"))), + Notifications: nil, // irrelevant for event auth + StateDefaultPtr: parsePythonInt(gjson.GetBytes(content, "state_default")), + InvitePtr: parsePythonInt(gjson.GetBytes(content, "invite")), + KickPtr: parsePythonInt(gjson.GetBytes(content, "kick")), + BanPtr: parsePythonInt(gjson.GetBytes(content, "ban")), + RedactPtr: parsePythonInt(gjson.GetBytes(content, "redact")), + } + gjson.GetBytes(content, "events").ForEach(func(key, value gjson.Result) bool { + if key.Type != gjson.String { + return false + } + val := parsePythonInt(value) + if val != nil { + into.Events[key.Str] = *val + } + return true + }) + gjson.GetBytes(content, "users").ForEach(func(key, value gjson.Result) bool { + if key.Type != gjson.String { + return false + } + val := parsePythonInt(value) + if val == nil { + return false + } + userID := id.UserID(key.Str) + if _, _, err := userID.Parse(); err != nil { + return false + } + into.Users[userID] = *val + return true + }) +} diff --git a/federation/eventauth/eventauth_test.go b/federation/eventauth/eventauth_test.go new file mode 100644 index 00000000..e3c5cd76 --- /dev/null +++ b/federation/eventauth/eventauth_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package eventauth_test + +import ( + "embed" + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "io" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "go.mau.fi/util/exerrors" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix/federation/eventauth" + "maunium.net/go/mautrix/federation/pdu" + "maunium.net/go/mautrix/id" +) + +//go:embed *.jsonl +var data embed.FS + +type eventMap map[id.EventID]*pdu.PDU + +func (em eventMap) Get(ids []id.EventID) ([]*pdu.PDU, error) { + output := make([]*pdu.PDU, len(ids)) + for i, evtID := range ids { + output[i] = em[evtID] + } + return output, nil +} + +func GetKey(serverName string, keyID id.KeyID, validUntilTS time.Time) (id.SigningKey, time.Time, error) { + return "", time.Time{}, nil +} + +func TestAuthorize(t *testing.T) { + files := exerrors.Must(data.ReadDir(".")) + for _, file := range files { + t.Run(file.Name(), func(t *testing.T) { + decoder := jsontext.NewDecoder(exerrors.Must(data.Open(file.Name()))) + events := make(eventMap) + var roomVersion *id.RoomVersion + for i := 1; ; i++ { + var evt *pdu.PDU + err := json.UnmarshalDecode(decoder, &evt) + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + if roomVersion == nil { + require.Equal(t, evt.Type, "m.room.create") + roomVersion = ptr.Ptr(id.RoomVersion(gjson.GetBytes(evt.Content, "room_version").Str)) + } + expectedEventID := gjson.GetBytes(evt.Unsigned, "event_id").Str + evtID, err := evt.GetEventID(*roomVersion) + require.NoError(t, err) + require.Equalf(t, id.EventID(expectedEventID), evtID, "Event ID mismatch for event #%d", i) + + // TODO allow redacted events + assert.True(t, evt.VerifyContentHash(), i) + + events[evtID] = evt + err = eventauth.Authorize(*roomVersion, evt, events.Get, GetKey) + if err != nil { + evt.InternalMeta.Rejected = true + } + // TODO allow testing intentionally rejected events + assert.NoErrorf(t, err, "Failed to authorize event #%d / %s of type %s", i, evtID, evt.Type) + } + }) + } + +} diff --git a/federation/eventauth/testroom-v12-success.jsonl b/federation/eventauth/testroom-v12-success.jsonl new file mode 100644 index 00000000..1f0b5357 --- /dev/null +++ b/federation/eventauth/testroom-v12-success.jsonl @@ -0,0 +1,17 @@ +{"auth_events":[],"content":{"room_version":"12"},"depth":1,"hashes":{"sha256":"qJYytb+EqWPiiZ0ogDODcLeA8XYw/2hVTaLHihcVBZQ"},"origin_server_ts":1756071567186,"prev_events":[],"sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"/9pp+2tkLo6XcZ3opqLeIpa3D96fh3QLpR2PQrZ6Z6j7wyRAvBrcgCpAeMtuyDCzW8Wh1QFEPG4FSsGvVaEFBg"}},"state_key":"","type":"m.room.create","unsigned":{"age_ts":1756071567186,"event_id":"$lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54"}} +{"auth_events":[],"content":{"avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","displayname":"tulir","membership":"join"},"depth":2,"hashes":{"sha256":"MXmgq0e4J9CdIP0IVKVvueFhOb+ndlsXpeyI+6l/2FI"},"origin_server_ts":1756071567259,"prev_events":["$lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"xMgRzyRg9VM9XCKpfFJA+MrYoI68b8PIddKpMTcxz/fDzmGSHEy6Ta2b59VxiX3NoJe2CigkDZ3+jVsQoZYIBA"}},"state_key":"@tulir:maunium.net","type":"m.room.member","unsigned":{"age_ts":1756071567259,"event_id":"$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"}} +{"auth_events":["$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":150},"events_default":0,"historical":100,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:envs.net":9001},"users_default":0},"depth":3,"hashes":{"sha256":"/JzQNBNqJ/i8vwj6xESDaD5EDdOqB4l/LmKlvAVl5jY"},"origin_server_ts":1756071567319,"prev_events":["$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"W3N3X/enja+lumXw3uz66/wT9oczoxrmHbAD5/RF069cX4wkCtqtDd61VWPkSGmKxdV1jurgbCqSX6+Q9/t3AA"}},"state_key":"","type":"m.room.power_levels","unsigned":{"age_ts":1756071567319,"event_id":"$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"}} +{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"join_rule":"invite"},"depth":4,"hashes":{"sha256":"GBu5AySj75ZXlOLd65mB03KueFKOHNgvtg2o/LUnLyI"},"origin_server_ts":1756071567320,"prev_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"XqWEnFREo2PhRnaebGjNzdHdtD691BtCQKkLnpKd8P3lVDewDt8OkCbDSk/Uzh9rDtzwWEsbsIoKSYuOm+G6CA"}},"state_key":"","type":"m.room.join_rules","unsigned":{"age_ts":1756071567320,"event_id":"$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M"}} +{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"history_visibility":"shared"},"depth":5,"hashes":{"sha256":"niDi5vG2akQm0f5pm0aoCYXqmWjXRfmP1ulr/ZEPm/k"},"origin_server_ts":1756071567320,"prev_events":["$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"PTIrNke/fc9+ObKAl/K0PGZfmpe8dwREyoA5rXffOXWdRHSaBifn9UIiJUqd68Bzvrv4RcADTR/ci7lUquFBBw"}},"state_key":"","type":"m.room.history_visibility","unsigned":{"age_ts":1756071567320,"event_id":"$Wmy3G9yxl9ArVg5ZsdeIDPxBsNAdgseuvHoqHTZ2vug"}} +{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"guest_access":"can_join"},"depth":6,"hashes":{"sha256":"sZ9QqsId4oarFF724esTohXuRxDNnaXPl+QmTDG60dw"},"origin_server_ts":1756071567321,"prev_events":["$Wmy3G9yxl9ArVg5ZsdeIDPxBsNAdgseuvHoqHTZ2vug"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"Eh2P9/hl38wfZx2AQbeS5VCD4wldXPfeP2sQsJsLtfmdwFV74jrlGVBaKIkaYcXY4eA08iDp8HW5jqttZqKKDg"}},"state_key":"","type":"m.room.guest_access","unsigned":{"age_ts":1756071567321,"event_id":"$hYVRH7F4P5mB5IqvBDDU5aXY7pYGG0ApstrryiVPKmQ"}} +{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"name":"event auth test v12"},"depth":7,"hashes":{"sha256":"tjwPo38yR+23Was6SbxLvPMhNx44DaXLhF3rKgngepU"},"origin_server_ts":1756071567321,"prev_events":["$hYVRH7F4P5mB5IqvBDDU5aXY7pYGG0ApstrryiVPKmQ"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"q1rk0c5m8TJYE9tePsMaLeaigatNNbvaLRom0X8KiZY0EH+itujfA+/UnksvmPmMmThfAXWlFLx5u8tcuSVyCQ"}},"state_key":"","type":"m.room.name","unsigned":{"age_ts":1756071567321,"event_id":"$fFDwIavLTEIfcnggWuryB6JwfS-L2KT6vP1ap3P6ctE"}} +{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU","$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M"],"content":{"avatar_url":"mxc://envs.net/000cf1510b7c61018f9c72ca4cc63668370782c81725865933316030464","displayname":"tulir[e]","membership":"invite"},"depth":8,"hashes":{"sha256":"r5EBUZN/4LbVcMYwuffDcVV9G4OMHzAQuNbnjigL+OE"},"origin_server_ts":1756071567548,"prev_events":["$fFDwIavLTEIfcnggWuryB6JwfS-L2KT6vP1ap3P6ctE"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"envs.net":{"ed25519:wuJyKT":"svB+uW4Tsj8/I+SYbLl+LPPjBlqxGNXE4wGyAxlP7vfyJtFf7Kn/19jx65wT9ebeCq5sTGlEDV4Fabwma9LhDA"},"maunium.net":{"ed25519:a_xxeS":"LBYMcdJVSNsLd6SmOgx5oOU/0xOeCl03o4g83VwJfHWlRuTT5l9+qlpNED28wY07uxoU9MgLgXXICJ0EezMBCg"}},"state_key":"@tulir:envs.net","type":"m.room.member","unsigned":{"age_ts":1756071567548,"event_id":"$qYZqSKiKMCNjzH6Trhr6nBSvbfuwr8Sh2bC4USSAxok","invite_room_state":[{"content":{"join_rule":"invite"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.join_rules"},{"content":{"name":"event auth test v12"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.name"},{"auth_events":[],"content":{"room_version":"12"},"depth":1,"hashes":{"sha256":"qJYytb+EqWPiiZ0ogDODcLeA8XYw/2hVTaLHihcVBZQ"},"origin_server_ts":1756071567186,"prev_events":[],"sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"/9pp+2tkLo6XcZ3opqLeIpa3D96fh3QLpR2PQrZ6Z6j7wyRAvBrcgCpAeMtuyDCzW8Wh1QFEPG4FSsGvVaEFBg"}},"state_key":"","type":"m.room.create","unsigned":{"age_ts":1756071567186}},{"content":{"avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","displayname":"tulir","membership":"join"},"sender":"@tulir:maunium.net","state_key":"@tulir:maunium.net","type":"m.room.member"}]}} +{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":9,"hashes":{"sha256":"23rgMf7EGJcYt3Aj0qAFnmBWCxuU9Uk+ReidqtIJDKQ"},"origin_server_ts":1756071575986,"prev_events":["$qYZqSKiKMCNjzH6Trhr6nBSvbfuwr8Sh2bC4USSAxok"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"p+Fm/uWO8VXJdCYvN/dVb8HF8W3t1sssNCBiOWbzAeuS3QqYjoMKHyixLuN1mOdnCyATv7SsHHmA4+cELRGdAA"}},"type":"m.room.message","unsigned":{"age_ts":1756071576002,"event_id":"$eZDCydRWSRnR5od0c7ahz2qSZQDHbl5g5PITT0OMC3E"}} +{"auth_events":["$qYZqSKiKMCNjzH6Trhr6nBSvbfuwr8Sh2bC4USSAxok","$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M"],"content":{"avatar_url":"mxc://envs.net/000cf1510b7c61018f9c72ca4cc63668370782c81725865933316030464","displayname":"tulir[e]","membership":"join"},"depth":10,"hashes":{"sha256":"2kJPx2UsysNzTH8QGYHUKTO/05yetxKRlI0nKFeGbts"},"origin_server_ts":1756071578631,"prev_events":["$eZDCydRWSRnR5od0c7ahz2qSZQDHbl5g5PITT0OMC3E"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"Wuzxkh8nEEX6mdJzph6Bt5ku+odFkEg2RIpFAAirOqxgcrwRaz42PsJni3YbfzH1qneF+iWQ/neA+up6jLXFBw"}},"state_key":"@tulir:envs.net","type":"m.room.member","unsigned":{"age":6,"event_id":"$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","replaces_state":"$qYZqSKiKMCNjzH6Trhr6nBSvbfuwr8Sh2bC4USSAxok"}} +{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M","$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"],"content":{"avatar_url":"mxc://matrix.org/BDYVQFSLvZHMaKHDGiRkvhVg","displayname":"tulir[m]","membership":"invite"},"depth":11,"hashes":{"sha256":"dRE11R2hBfFalQ5tIJdyaElUIiSE5aCKMddjek4wR3c"},"origin_server_ts":1756071591449,"prev_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"/Mi4kX40fbR+V3DCJJGI/9L3Uuf8y5Un8LHlCQv1T0O5gnFZGQ3qN6rRNaZ1Kdh3QJBU6H4NTfnd+SVj3wt3CQ"},"matrix.org":{"ed25519:a_RXGa":"ZeLm/oxP3/Cds/uCL2FaZpgjUp0vTDBlGG6YVFNl76yIVlyIKKQKR6BSVw2u5KC5Mu9M1f+0lDmLGQujR5NkBg"}},"state_key":"@tulir:matrix.org","type":"m.room.member","unsigned":{"event_id":"$g4eBtA9EFNGLkHOofvQ4U87GNt4W8NmfmNRyR0wOUO4","invite_room_state":[{"content":{"join_rule":"invite"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.join_rules"},{"content":{"name":"event auth test v12"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.name"},{"auth_events":[],"content":{"room_version":"12"},"depth":1,"hashes":{"sha256":"qJYytb+EqWPiiZ0ogDODcLeA8XYw/2hVTaLHihcVBZQ"},"origin_server_ts":1756071567186,"prev_events":[],"sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"/9pp+2tkLo6XcZ3opqLeIpa3D96fh3QLpR2PQrZ6Z6j7wyRAvBrcgCpAeMtuyDCzW8Wh1QFEPG4FSsGvVaEFBg"}},"state_key":"","type":"m.room.create","unsigned":{"age":11553}},{"content":{"avatar_url":"mxc://envs.net/000cf1510b7c61018f9c72ca4cc63668370782c81725865933316030464","displayname":"tulir[e]","membership":"join"},"sender":"@tulir:envs.net","state_key":"@tulir:envs.net","type":"m.room.member"}]}} +{"auth_events":["$g4eBtA9EFNGLkHOofvQ4U87GNt4W8NmfmNRyR0wOUO4","$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M","$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"],"content":{"avatar_url":"mxc://matrix.org/BDYVQFSLvZHMaKHDGiRkvhVg","displayname":"tulir[m]","membership":"join"},"depth":12,"hashes":{"sha256":"hR/fRIyFkxKnA1XNxIB+NKC0VR0vHs82EDgydhmmZXU"},"origin_server_ts":1756071609205,"prev_events":["$g4eBtA9EFNGLkHOofvQ4U87GNt4W8NmfmNRyR0wOUO4"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:matrix.org","signatures":{"matrix.org":{"ed25519:a_RXGa":"keWbZHm+LPW22XWxb14Att4Ae4GVc6XAKAnxFRr3hxhrgEhsnMcxUx7fjqlA1dk3As6kjLKdekcyCef+AQCXCA"}},"state_key":"@tulir:matrix.org","type":"m.room.member","unsigned":{"age":19,"event_id":"$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw","replaces_state":"$g4eBtA9EFNGLkHOofvQ4U87GNt4W8NmfmNRyR0wOUO4"}} +{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":150},"events_default":0,"historical":100,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:envs.net":9001,"@tulir:matrix.org":9000},"users_default":0},"depth":13,"hashes":{"sha256":"30Wuw3xIbA8+eXQBa4nFDKcyHtMbKPBYhLW1zft9/fE"},"origin_server_ts":1756071643928,"prev_events":["$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"x6Y4uViq4nK8LVPqtMLdCuvNET2bnjxYTgiKuEe1JYfwB4jPBnPuqvrt1O9oaanMpcRWbnuiZjckq4bUlRZ7Cw"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg","replaces_state":"$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"}} +{"auth_events":["$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg","$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw"],"content":{"name":"event auth test v12!"},"depth":14,"hashes":{"sha256":"WT0gz7KYXvbdNruRavqIi9Hhul3rxCdZ+YY9yMGN+Fw"},"origin_server_ts":1756071656988,"prev_events":["$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:matrix.org","signatures":{"matrix.org":{"ed25519:a_RXGa":"bSplmqtXVhO2Z3hJ8JMQ/u7G2Wmg6yt7SwhYXObRQJfthekddJN152ME4YJIwy7YD8WFq7EkyB/NMyQoliYyCg"}},"state_key":"","type":"m.room.name","unsigned":{"event_id":"$p4xvOczrhzQMtRW3-Tf86LYUb5aqpGFIgjwHBuxWIcI","replaces_state":"$fFDwIavLTEIfcnggWuryB6JwfS-L2KT6vP1ap3P6ctE"}} +{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":9001},"events_default":0,"historical":12345,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:envs.net":9001,"@tulir:matrix.org":9000},"users_default":0},"depth":15,"hashes":{"sha256":"FnGzbcXc8YOiB1TY33QunGA17Axoyuu3sdVOj5Z408o"},"origin_server_ts":1756071804931,"prev_events":["$p4xvOczrhzQMtRW3-Tf86LYUb5aqpGFIgjwHBuxWIcI"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"uyTUsPR+CzCtlevzB5+sNXvmfbPSp6u7RZC4E4TLVsj45+pjmMRswAvuHP9PT2+Tkl6Hu8ZPigsXgbKZtR35Aw"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw","replaces_state":"$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg"}} +{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":100},"events_default":0,"historical":12345,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:envs.net":9001,"@tulir:matrix.org":9000},"users_default":0},"depth":16,"hashes":{"sha256":"KcivsiLesdnUnKX23Akk3OJEJFGRSY0g4H+p7XIThnw"},"origin_server_ts":1756071812688,"prev_events":["$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"cAK8dO2AVZklY9te5aVKbF1jR/eB5rzeNOXfYPjBLf+aSAS4Z6R2aMKW6hJB9PqRS4S+UZc24DTrjUjnvMzeBA"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU","replaces_state":"$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw"}} +{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU"],"content":{"body":"meow #2","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":17,"hashes":{"sha256":"SgH9fOXGdbdqpRfYmoz1t29+gX8Ze4ThSoj6klZs3Og"},"origin_server_ts":1756247476706,"prev_events":["$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"SMYK7zP3SaQOKhzZUKUBVCKwffYqi3PFAlPM34kRJtmfGU3KZXNBT0zi+veXDMmxkMunqhF2RTHBD6joa0kBAQ"}},"type":"m.room.message","unsigned":{"event_id":"$KFHLO0-ENYOGQXogp84C-ISSu1xtKUzIMaZ6LiBcR_w"}} diff --git a/federation/pdu/pdu.go b/federation/pdu/pdu.go index b5210550..2dbdefc1 100644 --- a/federation/pdu/pdu.go +++ b/federation/pdu/pdu.go @@ -53,6 +53,10 @@ var ( _ AnyPDU = (*RoomV1PDU)(nil) ) +type InternalMeta struct { + Rejected bool `json:"rejected,omitempty"` +} + type PDU struct { AuthEvents []id.EventID `json:"auth_events"` Content jsontext.Value `json:"content"` @@ -67,6 +71,7 @@ type PDU struct { StateKey *string `json:"state_key,omitzero"` Type string `json:"type"` Unsigned jsontext.Value `json:"unsigned,omitzero"` + InternalMeta InternalMeta `json:"-"` Unknown jsontext.Value `json:",unknown"` diff --git a/federation/signutil/verify.go b/federation/signutil/verify.go index 8fe55b2f..ea0e7886 100644 --- a/federation/signutil/verify.go +++ b/federation/signutil/verify.go @@ -48,6 +48,47 @@ func VerifyJSON(serverName string, keyID id.KeyID, key id.SigningKey, data any) return VerifyJSONRaw(key, sigVal.Str, message) } +func VerifyJSONAny(key id.SigningKey, data any) error { + var err error + message, ok := data.(json.RawMessage) + if !ok { + message, err = json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal data: %w", err) + } + } + sigs := gjson.GetBytes(message, "signatures") + if !sigs.IsObject() { + return ErrSignatureNotFound + } + message, err = sjson.DeleteBytes(message, "signatures") + if err != nil { + return fmt.Errorf("failed to delete signatures: %w", err) + } + message, err = sjson.DeleteBytes(message, "unsigned") + if err != nil { + return fmt.Errorf("failed to delete unsigned: %w", err) + } + var validated bool + sigs.ForEach(func(_, value gjson.Result) bool { + if !value.IsObject() { + return true + } + value.ForEach(func(_, value gjson.Result) bool { + if value.Type != gjson.String { + return true + } + validated = VerifyJSONRaw(key, value.Str, message) == nil + return !validated + }) + return !validated + }) + if !validated { + return ErrInvalidSignature + } + return nil +} + func VerifyJSONRaw(key id.SigningKey, sig string, message json.RawMessage) error { sigBytes, err := base64.RawStdEncoding.DecodeString(sig) if err != nil { From f131ae5aa4b25f6f3ada0db00aa3df89b84d91d6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 27 Aug 2025 12:24:15 +0300 Subject: [PATCH 268/581] federation/pdu: add cached event ID to internal metadata --- federation/pdu/pdu.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/federation/pdu/pdu.go b/federation/pdu/pdu.go index 2dbdefc1..218dd78a 100644 --- a/federation/pdu/pdu.go +++ b/federation/pdu/pdu.go @@ -54,7 +54,8 @@ var ( ) type InternalMeta struct { - Rejected bool `json:"rejected,omitempty"` + EventID id.EventID `json:"event_id,omitempty"` + Rejected bool `json:"rejected,omitempty"` } type PDU struct { From 9f693702b06b0fb2abfba280ad5307694d30c089 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 27 Aug 2025 12:25:08 +0300 Subject: [PATCH 269/581] federation/pdu: add extra field to internal metadata --- federation/pdu/pdu.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/federation/pdu/pdu.go b/federation/pdu/pdu.go index 218dd78a..cecee5b9 100644 --- a/federation/pdu/pdu.go +++ b/federation/pdu/pdu.go @@ -54,8 +54,9 @@ var ( ) type InternalMeta struct { - EventID id.EventID `json:"event_id,omitempty"` - Rejected bool `json:"rejected,omitempty"` + EventID id.EventID `json:"event_id,omitempty"` + Rejected bool `json:"rejected,omitempty"` + Extra map[string]any `json:",unknown"` } type PDU struct { From febca20dd780913e580110c46102595c34b95d7a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 27 Aug 2025 17:11:50 +0300 Subject: [PATCH 270/581] bridgev2/status: use _file pattern for avatar instead of splitting url and keys --- bridgev2/status/bridgestate.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/bridgev2/status/bridgestate.go b/bridgev2/status/bridgestate.go index 671303e0..3bc5a59b 100644 --- a/bridgev2/status/bridgestate.go +++ b/bridgev2/status/bridgestate.go @@ -22,7 +22,7 @@ import ( "go.mau.fi/util/ptr" "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -89,8 +89,7 @@ type RemoteProfile struct { Name string `json:"name,omitempty"` Avatar id.ContentURIString `json:"avatar,omitempty"` - // Only used for backups of local bridge states - AvatarKeys *attachment.EncryptedFile `json:"avatar_keys,omitempty"` + AvatarFile *event.EncryptedFileInfo `json:"avatar_file,omitempty"` } func coalesce[T ~string](a, b T) T { @@ -106,14 +105,14 @@ func (rp *RemoteProfile) Merge(other RemoteProfile) RemoteProfile { other.Username = coalesce(rp.Username, other.Username) other.Name = coalesce(rp.Name, other.Name) other.Avatar = coalesce(rp.Avatar, other.Avatar) - if rp.AvatarKeys != nil { - other.AvatarKeys = rp.AvatarKeys + if rp.AvatarFile != nil { + other.AvatarFile = rp.AvatarFile } return other } func (rp *RemoteProfile) IsEmpty() bool { - return rp == nil || (rp.Phone == "" && rp.Email == "" && rp.Username == "" && rp.Name == "" && rp.Avatar == "") + return rp == nil || (rp.Phone == "" && rp.Email == "" && rp.Username == "" && rp.Name == "" && rp.Avatar == "" && rp.AvatarFile == nil) } type BridgeState struct { From 359afbea2bba3a016ff54e50a381c69ed4d91b92 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 28 Aug 2025 02:19:15 +0300 Subject: [PATCH 271/581] bridgev2/matrix: remove provisioning API prefix option Reverse proxy configuration should be used instead when adding prefixes to the path. Changing the path entirely is not recommended even with reverse proxies. Fixes #403 --- bridgev2/bridgeconfig/config.go | 1 - bridgev2/bridgeconfig/legacymigrate.go | 2 -- bridgev2/bridgeconfig/upgrade.go | 1 - bridgev2/matrix/mxmain/example-config.yaml | 2 -- 4 files changed, 6 deletions(-) diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index 9bdee5fe..13ec738c 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -99,7 +99,6 @@ type AnalyticsConfig struct { } type ProvisioningConfig struct { - Prefix string `yaml:"prefix"` SharedSecret string `yaml:"shared_secret"` DebugEndpoints bool `yaml:"debug_endpoints"` EnableSessionTransfers bool `yaml:"enable_session_transfers"` diff --git a/bridgev2/bridgeconfig/legacymigrate.go b/bridgev2/bridgeconfig/legacymigrate.go index fb2a86d6..954a37c3 100644 --- a/bridgev2/bridgeconfig/legacymigrate.go +++ b/bridgev2/bridgeconfig/legacymigrate.go @@ -133,9 +133,7 @@ func doMigrateLegacy(helper up.Helper, python bool) { CopyToOtherLocation(helper, up.Bool, []string{"bridge", "sync_direct_chat_list"}, []string{"matrix", "sync_direct_chat_list"}) CopyToOtherLocation(helper, up.Bool, []string{"bridge", "federate_rooms"}, []string{"matrix", "federate_rooms"}) - CopyToOtherLocation(helper, up.Str, []string{"bridge", "provisioning", "prefix"}, []string{"provisioning", "prefix"}) CopyToOtherLocation(helper, up.Str, []string{"bridge", "provisioning", "shared_secret"}, []string{"provisioning", "shared_secret"}) - CopyToOtherLocation(helper, up.Str, []string{"appservice", "provisioning", "prefix"}, []string{"provisioning", "prefix"}) CopyToOtherLocation(helper, up.Str, []string{"appservice", "provisioning", "shared_secret"}, []string{"provisioning", "shared_secret"}) CopyToOtherLocation(helper, up.Bool, []string{"bridge", "provisioning", "debug_endpoints"}, []string{"provisioning", "debug_endpoints"}) diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index b69a1fdb..f41f77d8 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -103,7 +103,6 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.Str|up.Null, "analytics", "url") helper.Copy(up.Str|up.Null, "analytics", "user_id") - helper.Copy(up.Str, "provisioning", "prefix") if secret, ok := helper.Get(up.Str, "provisioning", "shared_secret"); !ok || secret == "generate" { sharedSecret := random.String(64) helper.Set(up.Str, sharedSecret, "provisioning", "shared_secret") diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 48e0d528..5da1407d 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -247,8 +247,6 @@ analytics: # Settings for provisioning API provisioning: - # Prefix for the provisioning API paths. - prefix: /_matrix/provision # Shared secret for authentication. If set to "generate" or null, a random secret will be generated, # or if set to "disable", the provisioning API will be disabled. shared_secret: generate From 3048d2edab7a78fc575e523deac4271d257ff889 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 28 Aug 2025 02:20:41 +0300 Subject: [PATCH 272/581] bridgev2/provisioning: add minimum length for shared secret --- bridgev2/matrix/mxmain/example-config.yaml | 2 +- bridgev2/matrix/provisioning.go | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 5da1407d..488f0b4c 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -248,7 +248,7 @@ analytics: # Settings for provisioning API provisioning: # Shared secret for authentication. If set to "generate" or null, a random secret will be generated, - # or if set to "disable", the provisioning API will be disabled. + # or if set to "disable", the provisioning API will be disabled. Must be at least 16 characters. shared_secret: generate # Whether to allow provisioning API requests to be authed using Matrix access tokens. # This follows the same rules as double puppeting to determine which server to contact to check the token, diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index df3e1bdf..2f202f4e 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -210,12 +210,20 @@ func (prov *ProvisioningAPI) checkFederatedMatrixAuth(ctx context.Context, userI } } +func disabledAuth(w http.ResponseWriter, r *http.Request) { + mautrix.MForbidden.WithMessage("Provisioning API is disabled").Write(w) +} + func (prov *ProvisioningAPI) DebugAuthMiddleware(h http.Handler) http.Handler { + secret := prov.br.Config.Provisioning.SharedSecret + if len(secret) < 16 { + return http.HandlerFunc(disabledAuth) + } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if auth == "" { mautrix.MMissingToken.WithMessage("Missing auth token").Write(w) - } else if !exstrings.ConstantTimeEqual(auth, prov.br.Config.Provisioning.SharedSecret) { + } else if !exstrings.ConstantTimeEqual(auth, secret) { mautrix.MUnknownToken.WithMessage("Invalid auth token").Write(w) } else { h.ServeHTTP(w, r) @@ -224,6 +232,10 @@ func (prov *ProvisioningAPI) DebugAuthMiddleware(h http.Handler) http.Handler { } func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { + secret := prov.br.Config.Provisioning.SharedSecret + if len(secret) < 16 { + return http.HandlerFunc(disabledAuth) + } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if auth == "" && prov.GetAuthFromRequest != nil { @@ -237,7 +249,7 @@ func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { if userID == "" && prov.GetUserIDFromRequest != nil { userID = prov.GetUserIDFromRequest(r) } - if !exstrings.ConstantTimeEqual(auth, prov.br.Config.Provisioning.SharedSecret) { + if !exstrings.ConstantTimeEqual(auth, secret) { var err error if strings.HasPrefix(auth, "openid:") { err = prov.checkFederatedMatrixAuth(r.Context(), userID, strings.TrimPrefix(auth, "openid:")) From 19f3b2179cb8e00806193a1e54b8afcd30ea7dbe Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 29 Aug 2025 11:07:16 +0300 Subject: [PATCH 273/581] pre-commit: ban `log.Str(x.String())` --- .pre-commit-config.yaml | 1 + appservice/appservice.go | 2 +- appservice/http.go | 2 +- bridgev2/matrix/crypto.go | 10 +++++----- client.go | 2 +- crypto/cross_sign_store.go | 6 +++--- crypto/decryptolm.go | 2 +- crypto/devicelist.go | 6 +++--- crypto/encryptmegolm.go | 4 ++-- crypto/machine.go | 4 ++-- crypto/verificationhelper/sas.go | 2 +- example/main.go | 2 +- 12 files changed, 22 insertions(+), 21 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 81701203..0b9785ae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,3 +27,4 @@ repos: - id: prevent-literal-http-methods - id: zerolog-ban-global-log - id: zerolog-ban-msgf + - id: zerolog-use-stringer diff --git a/appservice/appservice.go b/appservice/appservice.go index b0af02cd..33b53d7d 100644 --- a/appservice/appservice.go +++ b/appservice/appservice.go @@ -360,7 +360,7 @@ func (as *AppService) NewMautrixClient(userID id.UserID) *mautrix.Client { AccessToken: as.Registration.AppToken, UserAgent: as.UserAgent, StateStore: as.StateStore, - Log: as.Log.With().Str("as_user_id", userID.String()).Logger(), + Log: as.Log.With().Stringer("as_user_id", userID).Logger(), Client: as.HTTPClient, DefaultHTTPRetries: as.DefaultHTTPRetries, SpecVersions: as.SpecVersions, diff --git a/appservice/http.go b/appservice/http.go index 862de7fd..27ce6288 100644 --- a/appservice/http.go +++ b/appservice/http.go @@ -201,7 +201,7 @@ func (as *AppService) handleEvents(ctx context.Context, evts []*event.Event, def } err := evt.Content.ParseRaw(evt.Type) if errors.Is(err, event.ErrUnsupportedContentType) { - log.Debug().Str("event_id", evt.ID.String()).Msg("Not parsing content of unsupported event") + log.Debug().Stringer("event_id", evt.ID).Msg("Not parsing content of unsupported event") } else if err != nil { log.Warn().Err(err). Str("event_id", evt.ID.String()). diff --git a/bridgev2/matrix/crypto.go b/bridgev2/matrix/crypto.go index 47226625..2325ddfa 100644 --- a/bridgev2/matrix/crypto.go +++ b/bridgev2/matrix/crypto.go @@ -157,12 +157,12 @@ func (helper *CryptoHelper) resyncEncryptionInfo(ctx context.Context) { var evt event.EncryptionEventContent err = helper.client.StateEvent(ctx, roomID, event.StateEncryption, "", &evt) if err != nil { - log.Err(err).Str("room_id", roomID.String()).Msg("Failed to get encryption event") + log.Err(err).Stringer("room_id", roomID).Msg("Failed to get encryption event") _, err = helper.store.DB.Exec(ctx, ` UPDATE mx_room_state SET encryption=NULL WHERE room_id=$1 AND encryption='{"resync":true}' `, roomID) if err != nil { - log.Err(err).Str("room_id", roomID.String()).Msg("Failed to unmark room for resync after failed sync") + log.Err(err).Stringer("room_id", roomID).Msg("Failed to unmark room for resync after failed sync") } } else { maxAge := evt.RotationPeriodMillis @@ -185,9 +185,9 @@ func (helper *CryptoHelper) resyncEncryptionInfo(ctx context.Context) { WHERE room_id=$3 AND max_age IS NULL AND max_messages IS NULL `, maxAge, maxMessages, roomID) if err != nil { - log.Err(err).Str("room_id", roomID.String()).Msg("Failed to update megolm session table") + log.Err(err).Stringer("room_id", roomID).Msg("Failed to update megolm session table") } else { - log.Debug().Str("room_id", roomID.String()).Msg("Updated megolm session table") + log.Debug().Stringer("room_id", roomID).Msg("Updated megolm session table") } } } @@ -233,7 +233,7 @@ func (helper *CryptoHelper) loginBot(ctx context.Context) (*mautrix.Client, bool if err != nil { return nil, false, fmt.Errorf("failed to find existing device ID: %w", err) } else if len(deviceID) > 0 { - helper.log.Debug().Str("device_id", deviceID.String()).Msg("Found existing device ID for bot in database") + helper.log.Debug().Stringer("device_id", deviceID).Msg("Found existing device ID for bot in database") } // Create a new client instance with the default AS settings (including as_token), // the Login call will then override the access token in the client. diff --git a/client.go b/client.go index 78f83b85..45230c1e 100644 --- a/client.go +++ b/client.go @@ -1785,7 +1785,7 @@ func (cli *Client) UploadAsync(ctx context.Context, req ReqUploadMedia) (*RespCr go func() { _, err = cli.UploadMedia(ctx, req) if err != nil { - cli.Log.Error().Str("mxc", req.MXC.String()).Err(err).Msg("Async upload of media failed") + cli.Log.Error().Stringer("mxc", req.MXC).Err(err).Msg("Async upload of media failed") } }() return resp, nil diff --git a/crypto/cross_sign_store.go b/crypto/cross_sign_store.go index b583bada..d30b7e32 100644 --- a/crypto/cross_sign_store.go +++ b/crypto/cross_sign_store.go @@ -20,7 +20,7 @@ import ( func (mach *OlmMachine) storeCrossSigningKeys(ctx context.Context, crossSigningKeys map[id.UserID]mautrix.CrossSigningKeys, deviceKeys map[id.UserID]map[id.DeviceID]mautrix.DeviceKeys) { log := mach.machOrContextLog(ctx) for userID, userKeys := range crossSigningKeys { - log := log.With().Str("user_id", userID.String()).Logger() + log := log.With().Stringer("user_id", userID).Logger() currentKeys, err := mach.CryptoStore.GetCrossSigningKeys(ctx, userID) if err != nil { log.Error().Err(err). @@ -28,7 +28,7 @@ func (mach *OlmMachine) storeCrossSigningKeys(ctx context.Context, crossSigningK } if currentKeys != nil { for curKeyUsage, curKey := range currentKeys { - log := log.With().Str("old_key", curKey.Key.String()).Str("old_key_usage", string(curKeyUsage)).Logger() + log := log.With().Stringer("old_key", curKey.Key).Str("old_key_usage", string(curKeyUsage)).Logger() // got a new key with the same usage as an existing key for _, newKeyUsage := range userKeys.Usage { if newKeyUsage == curKeyUsage { @@ -49,7 +49,7 @@ func (mach *OlmMachine) storeCrossSigningKeys(ctx context.Context, crossSigningK } for _, key := range userKeys.Keys { - log := log.With().Str("key", key.String()).Array("usages", exzerolog.ArrayOfStrs(userKeys.Usage)).Logger() + log := log.With().Stringer("key", key).Array("usages", exzerolog.ArrayOfStrs(userKeys.Usage)).Logger() for _, usage := range userKeys.Usage { log.Trace().Str("usage", string(usage)).Msg("Storing cross-signing key") if err = mach.CryptoStore.PutCrossSigningKey(ctx, userID, usage, key); err != nil { diff --git a/crypto/decryptolm.go b/crypto/decryptolm.go index b737e4e1..b961a7b4 100644 --- a/crypto/decryptolm.go +++ b/crypto/decryptolm.go @@ -340,7 +340,7 @@ func (mach *OlmMachine) unwedgeDevice(log zerolog.Logger, sender id.UserID, send return } - log.Debug().Str("device_id", deviceIdentity.DeviceID.String()).Msg("Creating new Olm session") + log.Debug().Stringer("device_id", deviceIdentity.DeviceID).Msg("Creating new Olm session") mach.devicesToUnwedgeLock.Lock() mach.devicesToUnwedge[senderKey] = true mach.devicesToUnwedgeLock.Unlock() diff --git a/crypto/devicelist.go b/crypto/devicelist.go index a2116ed5..61a22522 100644 --- a/crypto/devicelist.go +++ b/crypto/devicelist.go @@ -206,7 +206,7 @@ func (mach *OlmMachine) FetchKeys(ctx context.Context, users []id.UserID, includ log.Trace().Int("user_count", len(resp.DeviceKeys)).Msg("Query key result received") data = make(map[id.UserID]map[id.DeviceID]*id.Device) for userID, devices := range resp.DeviceKeys { - log := log.With().Str("user_id", userID.String()).Logger() + log := log.With().Stringer("user_id", userID).Logger() delete(req.DeviceKeys, userID) newDevices := make(map[id.DeviceID]*id.Device) @@ -222,7 +222,7 @@ func (mach *OlmMachine) FetchKeys(ctx context.Context, users []id.UserID, includ Msg("Updating devices in store") changed := false for deviceID, deviceKeys := range devices { - log := log.With().Str("device_id", deviceID.String()).Logger() + log := log.With().Stringer("device_id", deviceID).Logger() existing, ok := existingDevices[deviceID] if !ok { // New device @@ -270,7 +270,7 @@ func (mach *OlmMachine) FetchKeys(ctx context.Context, users []id.UserID, includ } } for userID := range req.DeviceKeys { - log.Warn().Str("user_id", userID.String()).Msg("Didn't get any keys for user") + log.Warn().Stringer("user_id", userID).Msg("Didn't get any keys for user") } mach.storeCrossSigningKeys(ctx, resp.MasterKeys, resp.DeviceKeys) diff --git a/crypto/encryptmegolm.go b/crypto/encryptmegolm.go index cd211af5..b3d19618 100644 --- a/crypto/encryptmegolm.go +++ b/crypto/encryptmegolm.go @@ -233,7 +233,7 @@ func (mach *OlmMachine) ShareGroupSession(ctx context.Context, roomID id.RoomID, var fetchKeysForUsers []id.UserID for _, userID := range users { - log := log.With().Str("target_user_id", userID.String()).Logger() + log := log.With().Stringer("target_user_id", userID).Logger() devices, err := mach.CryptoStore.GetDevices(ctx, userID) if err != nil { log.Err(err).Msg("Failed to get devices of user") @@ -305,7 +305,7 @@ func (mach *OlmMachine) ShareGroupSession(ctx context.Context, roomID id.RoomID, toDeviceWithheld.Messages[userID] = withheld } - log := log.With().Str("target_user_id", userID.String()).Logger() + log := log.With().Stringer("target_user_id", userID).Logger() log.Trace().Msg("Trying to find olm session to encrypt megolm session for user (post-fetch retry)") mach.findOlmSessionsForUser(ctx, session, userID, devices, output, withheld, nil) log.Debug(). diff --git a/crypto/machine.go b/crypto/machine.go index cac91bf8..e791e70d 100644 --- a/crypto/machine.go +++ b/crypto/machine.go @@ -361,7 +361,7 @@ func (mach *OlmMachine) HandleMemberEvent(ctx context.Context, evt *event.Event) Msg("Got membership state change, invalidating group session in room") err := mach.CryptoStore.RemoveOutboundGroupSession(ctx, evt.RoomID) if err != nil { - mach.Log.Warn().Str("room_id", evt.RoomID.String()).Msg("Failed to invalidate outbound group session") + mach.Log.Warn().Stringer("room_id", evt.RoomID).Msg("Failed to invalidate outbound group session") } } @@ -581,7 +581,7 @@ func (mach *OlmMachine) createGroupSession(ctx context.Context, senderKey id.Sen } err = mach.CryptoStore.PutGroupSession(ctx, igs) if err != nil { - log.Err(err).Str("session_id", sessionID.String()).Msg("Failed to store new inbound group session") + log.Err(err).Stringer("session_id", sessionID).Msg("Failed to store new inbound group session") return fmt.Errorf("failed to store new inbound group session: %w", err) } mach.MarkSessionReceived(ctx, roomID, sessionID, igs.Internal.FirstKnownIndex()) diff --git a/crypto/verificationhelper/sas.go b/crypto/verificationhelper/sas.go index 1313a613..e6392c79 100644 --- a/crypto/verificationhelper/sas.go +++ b/crypto/verificationhelper/sas.go @@ -695,7 +695,7 @@ func (vh *VerificationHelper) onVerificationMAC(ctx context.Context, txn Verific // Verify the MAC for each key var theirDevice *id.Device for keyID, mac := range macEvt.MAC { - log.Info().Str("key_id", keyID.String()).Msg("Received MAC for key") + log.Info().Stringer("key_id", keyID).Msg("Received MAC for key") alg, kID := keyID.Parse() if alg != id.KeyAlgorithmEd25519 { diff --git a/example/main.go b/example/main.go index d8006d46..2bf4bef3 100644 --- a/example/main.go +++ b/example/main.go @@ -143,7 +143,7 @@ func main() { if err != nil { log.Error().Err(err).Msg("Failed to send event") } else { - log.Info().Str("event_id", resp.EventID.String()).Msg("Event sent") + log.Info().Stringer("event_id", resp.EventID).Msg("Event sent") } } cancelSync() From c18d2e2565c89512f9dc49f786bbb131636f789e Mon Sep 17 00:00:00 2001 From: Ping Chen Date: Fri, 29 Aug 2025 17:20:11 +0900 Subject: [PATCH 274/581] bridgev2/matrixinterface: add GetEvent interface for linkedin reply (#406) Co-authored-by: Tulir Asokan --- bridgev2/matrix/intent.go | 20 ++++++++++++++++++++ bridgev2/matrixinterface.go | 2 ++ 2 files changed, 22 insertions(+) diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index 7d78b5a2..2c68a692 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -674,3 +674,23 @@ func (as *ASIntent) MuteRoom(ctx context.Context, roomID id.RoomID, until time.T }) } } + +func (as *ASIntent) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*event.Event, error) { + evt, err := as.Matrix.Client.GetEvent(ctx, roomID, eventID) + if err != nil { + return nil, err + } + err = evt.Content.ParseRaw(evt.Type) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("room_id", roomID).Stringer("event_id", eventID).Msg("failed to parse event content") + } + + if evt.Type == event.EventEncrypted { + if as.Connector.Config.Encryption.DeleteKeys.RatchetOnDecrypt { + return nil, errors.New("can't decrypt the event") + } + return as.Matrix.Crypto.Decrypt(ctx, evt) + } + + return evt, nil +} diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index b30e274a..6fa5360c 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -176,6 +176,8 @@ type MatrixAPI interface { TagRoom(ctx context.Context, roomID id.RoomID, tag event.RoomTag, isTagged bool) error MuteRoom(ctx context.Context, roomID id.RoomID, until time.Time) error + + GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*event.Event, error) } type StreamOrderReadingMatrixAPI interface { From 8f464b5b76efeb9dea12fd460a71c38b09b5b1c3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 29 Aug 2025 16:33:59 +0300 Subject: [PATCH 275/581] bridgev2: move shared SNC code to provisionutil --- bridgev2/commands/startchat.go | 107 +++++------------ bridgev2/matrix/provisioning.go | 122 ++------------------ bridgev2/provisionutil/listcontacts.go | 95 +++++++++++++++ bridgev2/provisionutil/resolveidentifier.go | 85 ++++++++++++++ 4 files changed, 218 insertions(+), 191 deletions(-) create mode 100644 bridgev2/provisionutil/listcontacts.go create mode 100644 bridgev2/provisionutil/resolveidentifier.go diff --git a/bridgev2/commands/startchat.go b/bridgev2/commands/startchat.go index 719d3dd5..da246f50 100644 --- a/bridgev2/commands/startchat.go +++ b/bridgev2/commands/startchat.go @@ -1,4 +1,4 @@ -// Copyright (c) 2024 Tulir Asokan +// Copyright (c) 2025 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -7,15 +7,13 @@ package commands import ( - "context" "fmt" "html" "strings" - "time" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/bridgev2/provisionutil" ) var CommandResolveIdentifier = &FullHandler{ @@ -57,24 +55,13 @@ func getClientForStartingChat[T bridgev2.IdentifierResolvingNetworkAPI](ce *Even return login, api, remainingArgs } -func formatResolveIdentifierResult(ctx context.Context, resp *bridgev2.ResolveIdentifierResponse) string { - var targetName string - var targetMXID id.UserID - if resp.Ghost != nil { - if resp.UserInfo != nil { - resp.Ghost.UpdateInfo(ctx, resp.UserInfo) - } - targetName = resp.Ghost.Name - targetMXID = resp.Ghost.Intent.GetMXID() - } else if resp.UserInfo != nil && resp.UserInfo.Name != nil { - targetName = *resp.UserInfo.Name - } - if targetMXID != "" { - return fmt.Sprintf("`%s` / [%s](%s)", resp.UserID, targetName, targetMXID.URI().MatrixToURL()) - } else if targetName != "" { - return fmt.Sprintf("`%s` / %s", resp.UserID, targetName) +func formatResolveIdentifierResult(resp *provisionutil.RespResolveIdentifier) string { + if resp.MXID != "" { + return fmt.Sprintf("`%s` / [%s](%s)", resp.ID, resp.Name, resp.MXID.URI().MatrixToURL()) + } else if resp.Name != "" { + return fmt.Sprintf("`%s` / %s", resp.ID, resp.Name) } else { - return fmt.Sprintf("`%s`", resp.UserID) + return fmt.Sprintf("`%s`", resp.ID) } } @@ -89,57 +76,24 @@ func fnResolveIdentifier(ce *Event) { } createChat := ce.Command == "start-chat" || ce.Command == "pm" identifier := strings.Join(identifierParts, " ") - resp, err := api.ResolveIdentifier(ce.Ctx, identifier, createChat) + resp, err := provisionutil.ResolveIdentifier(ce.Ctx, login, identifier, createChat) if err != nil { - ce.Log.Err(err).Msg("Failed to resolve identifier") ce.Reply("Failed to resolve identifier: %v", err) return } else if resp == nil { ce.ReplyAdvanced(fmt.Sprintf("Identifier %s not found", html.EscapeString(identifier)), false, true) return } - formattedName := formatResolveIdentifierResult(ce.Ctx, resp) + formattedName := formatResolveIdentifierResult(resp) if createChat { - if resp.Chat == nil { - ce.Reply("Interface error: network connector did not return chat for create chat request") - return + name := resp.Portal.Name + if name == "" { + name = resp.Portal.MXID.String() } - portal := resp.Chat.Portal - if portal == nil { - portal, err = ce.Bridge.GetPortalByKey(ce.Ctx, resp.Chat.PortalKey) - if err != nil { - ce.Log.Err(err).Msg("Failed to get portal") - ce.Reply("Failed to get portal: %v", err) - return - } - } - if resp.Chat.PortalInfo == nil { - resp.Chat.PortalInfo, err = api.GetChatInfo(ce.Ctx, portal) - if err != nil { - ce.Log.Err(err).Msg("Failed to get portal info") - ce.Reply("Failed to get portal info: %v", err) - return - } - } - if portal.MXID != "" { - name := portal.Name - if name == "" { - name = portal.MXID.String() - } - portal.UpdateInfo(ce.Ctx, resp.Chat.PortalInfo, login, nil, time.Time{}) - ce.Reply("You already have a direct chat with %s at [%s](%s)", formattedName, name, portal.MXID.URI().MatrixToURL()) + if !resp.JustCreated { + ce.Reply("You already have a direct chat with %s at [%s](%s)", formattedName, name, resp.Portal.MXID.URI().MatrixToURL()) } else { - err = portal.CreateMatrixRoom(ce.Ctx, login, resp.Chat.PortalInfo) - if err != nil { - ce.Log.Err(err).Msg("Failed to create room") - ce.Reply("Failed to create room: %v", err) - return - } - name := portal.Name - if name == "" { - name = portal.MXID.String() - } - ce.Reply("Created chat with %s: [%s](%s)", formattedName, name, portal.MXID.URI().MatrixToURL()) + ce.Reply("Created chat with %s: [%s](%s)", formattedName, name, resp.Portal.MXID.URI().MatrixToURL()) } } else { ce.Reply("Found %s", formattedName) @@ -163,34 +117,25 @@ func fnSearch(ce *Event) { ce.Reply("Usage: `$cmdprefix search `") return } - _, api, queryParts := getClientForStartingChat[bridgev2.UserSearchingNetworkAPI](ce, "searching users") + login, api, queryParts := getClientForStartingChat[bridgev2.UserSearchingNetworkAPI](ce, "searching users") if api == nil { return } - results, err := api.SearchUsers(ce.Ctx, strings.Join(queryParts, " ")) + resp, err := provisionutil.SearchUsers(ce.Ctx, login, strings.Join(queryParts, " ")) if err != nil { - ce.Log.Err(err).Msg("Failed to search for users") ce.Reply("Failed to search for users: %v", err) return } - resultsString := make([]string, len(results)) - for i, res := range results { - formattedName := formatResolveIdentifierResult(ce.Ctx, res) + resultsString := make([]string, len(resp.Results)) + for i, res := range resp.Results { + formattedName := formatResolveIdentifierResult(res) resultsString[i] = fmt.Sprintf("* %s", formattedName) - if res.Chat != nil { - if res.Chat.Portal == nil { - res.Chat.Portal, err = ce.Bridge.GetExistingPortalByKey(ce.Ctx, res.Chat.PortalKey) - if err != nil { - ce.Log.Err(err).Object("portal_key", res.Chat.PortalKey).Msg("Failed to get DM portal") - } - } - if res.Chat.Portal != nil && res.Chat.Portal.MXID != "" { - portalName := res.Chat.Portal.Name - if portalName == "" { - portalName = res.Chat.Portal.MXID.String() - } - resultsString[i] = fmt.Sprintf("%s - DM portal: [%s](%s)", resultsString[i], portalName, res.Chat.Portal.MXID.URI().MatrixToURL()) + if res.Portal != nil && res.Portal.MXID != "" { + portalName := res.Portal.Name + if portalName == "" { + portalName = res.Portal.MXID.String() } + resultsString[i] = fmt.Sprintf("%s - DM portal: [%s](%s)", resultsString[i], portalName, res.Portal.MXID.URI().MatrixToURL()) } } ce.Reply("Search results:\n\n%s", strings.Join(resultsString, "\n")) diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 2f202f4e..02ad6abd 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -30,6 +30,7 @@ import ( "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/provisionutil" "maunium.net/go/mautrix/bridgev2/status" "maunium.net/go/mautrix/federation" "maunium.net/go/mautrix/id" @@ -608,101 +609,18 @@ func (prov *ProvisioningAPI) doResolveIdentifier(w http.ResponseWriter, r *http. if login == nil { return } - api, ok := login.Client.(bridgev2.IdentifierResolvingNetworkAPI) - if !ok { - mautrix.MUnrecognized.WithMessage("This bridge does not support resolving identifiers").Write(w) - return - } - resp, err := api.ResolveIdentifier(r.Context(), r.PathValue("identifier"), createChat) + resp, err := provisionutil.ResolveIdentifier(r.Context(), login, r.PathValue("identifier"), createChat) if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to resolve identifier") RespondWithError(w, err, "Internal error resolving identifier") - return } else if resp == nil { mautrix.MNotFound.WithMessage("Identifier not found").Write(w) - return - } - apiResp := &RespResolveIdentifier{ - ID: resp.UserID, - } - status := http.StatusOK - if resp.Ghost != nil { - if resp.UserInfo != nil { - resp.Ghost.UpdateInfo(r.Context(), resp.UserInfo) - } - apiResp.Name = resp.Ghost.Name - apiResp.AvatarURL = resp.Ghost.AvatarMXC - apiResp.Identifiers = resp.Ghost.Identifiers - apiResp.MXID = resp.Ghost.Intent.GetMXID() - } else if resp.UserInfo != nil && resp.UserInfo.Name != nil { - apiResp.Name = *resp.UserInfo.Name - } - if resp.Chat != nil { - if resp.Chat.Portal == nil { - resp.Chat.Portal, err = prov.br.Bridge.GetPortalByKey(r.Context(), resp.Chat.PortalKey) - if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to get portal") - mautrix.MUnknown.WithMessage("Failed to get portal").Write(w) - return - } - } - if createChat && resp.Chat.Portal.MXID == "" { + } else { + status := http.StatusOK + if resp.JustCreated { status = http.StatusCreated - err = resp.Chat.Portal.CreateMatrixRoom(r.Context(), login, resp.Chat.PortalInfo) - if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to create portal room") - mautrix.MUnknown.WithMessage("Failed to create portal room").Write(w) - return - } } - apiResp.DMRoomID = resp.Chat.Portal.MXID + exhttp.WriteJSONResponse(w, status, resp) } - exhttp.WriteJSONResponse(w, status, apiResp) -} - -type RespGetContactList struct { - Contacts []*RespResolveIdentifier `json:"contacts"` -} - -func (prov *ProvisioningAPI) processResolveIdentifiers(ctx context.Context, resp []*bridgev2.ResolveIdentifierResponse) (apiResp []*RespResolveIdentifier) { - apiResp = make([]*RespResolveIdentifier, len(resp)) - for i, contact := range resp { - apiContact := &RespResolveIdentifier{ - ID: contact.UserID, - } - apiResp[i] = apiContact - if contact.UserInfo != nil { - if contact.UserInfo.Name != nil { - apiContact.Name = *contact.UserInfo.Name - } - if contact.UserInfo.Identifiers != nil { - apiContact.Identifiers = contact.UserInfo.Identifiers - } - } - if contact.Ghost != nil { - if contact.Ghost.Name != "" { - apiContact.Name = contact.Ghost.Name - } - if len(contact.Ghost.Identifiers) >= len(apiContact.Identifiers) { - apiContact.Identifiers = contact.Ghost.Identifiers - } - apiContact.AvatarURL = contact.Ghost.AvatarMXC - apiContact.MXID = contact.Ghost.Intent.GetMXID() - } - if contact.Chat != nil { - if contact.Chat.Portal == nil { - var err error - contact.Chat.Portal, err = prov.br.Bridge.GetPortalByKey(ctx, contact.Chat.PortalKey) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal") - } - } - if contact.Chat.Portal != nil { - apiContact.DMRoomID = contact.Chat.Portal.MXID - } - } - } - return } func (prov *ProvisioningAPI) GetContactList(w http.ResponseWriter, r *http.Request) { @@ -710,20 +628,12 @@ func (prov *ProvisioningAPI) GetContactList(w http.ResponseWriter, r *http.Reque if login == nil { return } - api, ok := login.Client.(bridgev2.ContactListingNetworkAPI) - if !ok { - mautrix.MUnrecognized.WithMessage("This bridge does not support listing contacts").Write(w) - return - } - resp, err := api.GetContactList(r.Context()) + resp, err := provisionutil.GetContactList(r.Context(), login) if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to get contact list") - RespondWithError(w, err, "Internal error fetching contact list") + RespondWithError(w, err, "Internal error getting contact list") return } - exhttp.WriteJSONResponse(w, http.StatusOK, &RespGetContactList{ - Contacts: prov.processResolveIdentifiers(r.Context(), resp), - }) + exhttp.WriteJSONResponse(w, http.StatusOK, resp) } type ReqSearchUsers struct { @@ -746,20 +656,12 @@ func (prov *ProvisioningAPI) PostSearchUsers(w http.ResponseWriter, r *http.Requ if login == nil { return } - api, ok := login.Client.(bridgev2.UserSearchingNetworkAPI) - if !ok { - mautrix.MUnrecognized.WithMessage("This bridge does not support searching for users").Write(w) - return - } - resp, err := api.SearchUsers(r.Context(), req.Query) + resp, err := provisionutil.SearchUsers(r.Context(), login, req.Query) if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to get contact list") - RespondWithError(w, err, "Internal error fetching contact list") + RespondWithError(w, err, "Internal error searching users") return } - exhttp.WriteJSONResponse(w, http.StatusOK, &RespSearchUsers{ - Results: prov.processResolveIdentifiers(r.Context(), resp), - }) + exhttp.WriteJSONResponse(w, http.StatusOK, resp) } func (prov *ProvisioningAPI) GetResolveIdentifier(w http.ResponseWriter, r *http.Request) { diff --git a/bridgev2/provisionutil/listcontacts.go b/bridgev2/provisionutil/listcontacts.go new file mode 100644 index 00000000..d2cf5e90 --- /dev/null +++ b/bridgev2/provisionutil/listcontacts.go @@ -0,0 +1,95 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package provisionutil + +import ( + "context" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" +) + +type RespGetContactList struct { + Contacts []*RespResolveIdentifier `json:"contacts"` +} + +type RespSearchUsers struct { + Results []*RespResolveIdentifier `json:"results"` +} + +func GetContactList(ctx context.Context, login *bridgev2.UserLogin) (*RespGetContactList, error) { + api, ok := login.Client.(bridgev2.ContactListingNetworkAPI) + if !ok { + return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support listing contacts")) + } + resp, err := api.GetContactList(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get contact list") + return nil, err + } + return &RespGetContactList{ + Contacts: processResolveIdentifiers(ctx, login.Bridge, resp), + }, nil +} + +func SearchUsers(ctx context.Context, login *bridgev2.UserLogin, query string) (*RespSearchUsers, error) { + api, ok := login.Client.(bridgev2.UserSearchingNetworkAPI) + if !ok { + return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support searching for users")) + } + resp, err := api.SearchUsers(ctx, query) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get contact list") + return nil, err + } + return &RespSearchUsers{ + Results: processResolveIdentifiers(ctx, login.Bridge, resp), + }, nil +} + +func processResolveIdentifiers(ctx context.Context, br *bridgev2.Bridge, resp []*bridgev2.ResolveIdentifierResponse) (apiResp []*RespResolveIdentifier) { + apiResp = make([]*RespResolveIdentifier, len(resp)) + for i, contact := range resp { + apiContact := &RespResolveIdentifier{ + ID: contact.UserID, + } + apiResp[i] = apiContact + if contact.UserInfo != nil { + if contact.UserInfo.Name != nil { + apiContact.Name = *contact.UserInfo.Name + } + if contact.UserInfo.Identifiers != nil { + apiContact.Identifiers = contact.UserInfo.Identifiers + } + } + if contact.Ghost != nil { + if contact.Ghost.Name != "" { + apiContact.Name = contact.Ghost.Name + } + if len(contact.Ghost.Identifiers) >= len(apiContact.Identifiers) { + apiContact.Identifiers = contact.Ghost.Identifiers + } + apiContact.AvatarURL = contact.Ghost.AvatarMXC + apiContact.MXID = contact.Ghost.Intent.GetMXID() + } + if contact.Chat != nil { + if contact.Chat.Portal == nil { + var err error + contact.Chat.Portal, err = br.GetPortalByKey(ctx, contact.Chat.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal") + } + } + if contact.Chat.Portal != nil { + apiContact.DMRoomID = contact.Chat.Portal.MXID + } + } + } + return +} diff --git a/bridgev2/provisionutil/resolveidentifier.go b/bridgev2/provisionutil/resolveidentifier.go new file mode 100644 index 00000000..23813620 --- /dev/null +++ b/bridgev2/provisionutil/resolveidentifier.go @@ -0,0 +1,85 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package provisionutil + +import ( + "context" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/id" +) + +type RespResolveIdentifier struct { + ID networkid.UserID `json:"id"` + Name string `json:"name,omitempty"` + AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` + Identifiers []string `json:"identifiers,omitempty"` + MXID id.UserID `json:"mxid,omitempty"` + DMRoomID id.RoomID `json:"dm_room_mxid,omitempty"` + + Portal *bridgev2.Portal `json:"-"` + Ghost *bridgev2.Ghost `json:"-"` + JustCreated bool `json:"-"` +} + +func ResolveIdentifier( + ctx context.Context, + login *bridgev2.UserLogin, + identifier string, + createChat bool, +) (*RespResolveIdentifier, error) { + api, ok := login.Client.(bridgev2.IdentifierResolvingNetworkAPI) + if !ok { + return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support resolving identifiers")) + } + resp, err := api.ResolveIdentifier(ctx, identifier, createChat) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to resolve identifier") + return nil, err + } else if resp == nil { + return nil, nil + } + apiResp := &RespResolveIdentifier{ + ID: resp.UserID, + Ghost: resp.Ghost, + } + if resp.Ghost != nil { + if resp.UserInfo != nil { + resp.Ghost.UpdateInfo(ctx, resp.UserInfo) + } + apiResp.Name = resp.Ghost.Name + apiResp.AvatarURL = resp.Ghost.AvatarMXC + apiResp.Identifiers = resp.Ghost.Identifiers + apiResp.MXID = resp.Ghost.Intent.GetMXID() + } else if resp.UserInfo != nil && resp.UserInfo.Name != nil { + apiResp.Name = *resp.UserInfo.Name + } + if resp.Chat != nil { + if resp.Chat.Portal == nil { + resp.Chat.Portal, err = login.Bridge.GetPortalByKey(ctx, resp.Chat.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal") + return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to get portal")) + } + } + if createChat && resp.Chat.Portal.MXID == "" { + apiResp.JustCreated = true + err = resp.Chat.Portal.CreateMatrixRoom(ctx, login, resp.Chat.PortalInfo) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to create portal room") + return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to create portal room")) + } + } + apiResp.Portal = resp.Chat.Portal + apiResp.DMRoomID = resp.Chat.Portal.MXID + } + return apiResp, nil +} From f9e3e8a30f2fd2794f015fd775ae5cc4845d8b0c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 29 Aug 2025 18:30:47 +0300 Subject: [PATCH 276/581] bridgev2/provisionutil: allow passing mxids to ResolveIdentifier Closes #398 --- bridgev2/provisionutil/resolveidentifier.go | 53 ++++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/bridgev2/provisionutil/resolveidentifier.go b/bridgev2/provisionutil/resolveidentifier.go index 23813620..5387347c 100644 --- a/bridgev2/provisionutil/resolveidentifier.go +++ b/bridgev2/provisionutil/resolveidentifier.go @@ -8,6 +8,7 @@ package provisionutil import ( "context" + "errors" "github.com/rs/zerolog" @@ -30,6 +31,8 @@ type RespResolveIdentifier struct { JustCreated bool `json:"-"` } +var ErrNoPortalKey = errors.New("network API didn't return portal key for createChat request") + func ResolveIdentifier( ctx context.Context, login *bridgev2.UserLogin, @@ -40,12 +43,44 @@ func ResolveIdentifier( if !ok { return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support resolving identifiers")) } - resp, err := api.ResolveIdentifier(ctx, identifier, createChat) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to resolve identifier") - return nil, err - } else if resp == nil { - return nil, nil + var resp *bridgev2.ResolveIdentifierResponse + parsedUserID, ok := login.Bridge.Matrix.ParseGhostMXID(id.UserID(identifier)) + validator, vOK := login.Bridge.Network.(bridgev2.IdentifierValidatingNetwork) + if ok && (!vOK || validator.ValidateUserID(parsedUserID)) { + ghost, err := login.Bridge.GetGhostByID(ctx, parsedUserID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost by ID") + return nil, err + } + resp = &bridgev2.ResolveIdentifierResponse{ + Ghost: ghost, + UserID: parsedUserID, + } + gdcAPI, ok := api.(bridgev2.GhostDMCreatingNetworkAPI) + if ok && createChat { + resp.Chat, err = gdcAPI.CreateChatWithGhost(ctx, ghost) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to create chat") + return nil, err + } + } else if createChat || ghost.Name == "" { + zerolog.Ctx(ctx).Debug(). + Bool("create_chat", createChat). + Bool("has_name", ghost.Name != ""). + Msg("Falling back to resolving identifier") + resp = nil + identifier = string(parsedUserID) + } + } + if resp == nil { + var err error + resp, err = api.ResolveIdentifier(ctx, identifier, createChat) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to resolve identifier") + return nil, err + } else if resp == nil { + return nil, nil + } } apiResp := &RespResolveIdentifier{ ID: resp.UserID, @@ -63,7 +98,11 @@ func ResolveIdentifier( apiResp.Name = *resp.UserInfo.Name } if resp.Chat != nil { + if resp.Chat.PortalKey.IsEmpty() { + return nil, ErrNoPortalKey + } if resp.Chat.Portal == nil { + var err error resp.Chat.Portal, err = login.Bridge.GetPortalByKey(ctx, resp.Chat.PortalKey) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal") @@ -72,7 +111,7 @@ func ResolveIdentifier( } if createChat && resp.Chat.Portal.MXID == "" { apiResp.JustCreated = true - err = resp.Chat.Portal.CreateMatrixRoom(ctx, login, resp.Chat.PortalInfo) + err := resp.Chat.Portal.CreateMatrixRoom(ctx, login, resp.Chat.PortalInfo) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to create portal room") return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to create portal room")) From 050fbbd466a8791f16b3a98db433dbb168af996f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 29 Aug 2025 18:31:09 +0300 Subject: [PATCH 277/581] bridgev2/status: change RemoteID to a UserLoginID --- bridgev2/status/bridgestate.go | 9 +++++---- bridgev2/userlogin.go | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/bridgev2/status/bridgestate.go b/bridgev2/status/bridgestate.go index 3bc5a59b..430d4c7c 100644 --- a/bridgev2/status/bridgestate.go +++ b/bridgev2/status/bridgestate.go @@ -22,6 +22,7 @@ import ( "go.mau.fi/util/ptr" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -126,10 +127,10 @@ type BridgeState struct { UserAction BridgeStateUserAction `json:"user_action,omitempty"` - UserID id.UserID `json:"user_id,omitempty"` - RemoteID string `json:"remote_id,omitempty"` - RemoteName string `json:"remote_name,omitempty"` - RemoteProfile *RemoteProfile `json:"remote_profile,omitempty"` + UserID id.UserID `json:"user_id,omitempty"` + RemoteID networkid.UserLoginID `json:"remote_id,omitempty"` + RemoteName string `json:"remote_name,omitempty"` + RemoteProfile *RemoteProfile `json:"remote_profile,omitempty"` Reason string `json:"reason,omitempty"` Info map[string]interface{} `json:"info,omitempty"` diff --git a/bridgev2/userlogin.go b/bridgev2/userlogin.go index 203dc122..b5fcfcd0 100644 --- a/bridgev2/userlogin.go +++ b/bridgev2/userlogin.go @@ -501,7 +501,7 @@ var _ status.BridgeStateFiller = (*UserLogin)(nil) func (ul *UserLogin) FillBridgeState(state status.BridgeState) status.BridgeState { state.UserID = ul.UserMXID - state.RemoteID = string(ul.ID) + state.RemoteID = ul.ID state.RemoteName = ul.RemoteName state.RemoteProfile = &ul.RemoteProfile filler, ok := ul.Client.(status.BridgeStateFiller) From 1d6bea5fe3ff73b79192f8eb2d40a7d859fb995d Mon Sep 17 00:00:00 2001 From: fmseals <115927730+fmseals@users.noreply.github.com> Date: Fri, 29 Aug 2025 16:34:06 +0000 Subject: [PATCH 278/581] client: fix v3/delete_devices method (#393) --- client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client.go b/client.go index 45230c1e..43fc3783 100644 --- a/client.go +++ b/client.go @@ -2471,7 +2471,7 @@ func (cli *Client) DeleteDevice(ctx context.Context, deviceID id.DeviceID, req * func (cli *Client) DeleteDevices(ctx context.Context, req *ReqDeleteDevices) error { urlPath := cli.BuildClientURL("v3", "delete_devices") - _, err := cli.MakeRequest(ctx, http.MethodDelete, urlPath, req, nil) + _, err := cli.MakeRequest(ctx, http.MethodPost, urlPath, req, nil) return err } From cd927c27963f740ae92594d773221a7a6308afd7 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 30 Aug 2025 00:06:20 +0300 Subject: [PATCH 279/581] event: add types for MSC4332 --- event/botcommand.go | 45 +++++++++++++++++++++++++++++++++++++++++++++ event/content.go | 1 + event/message.go | 2 ++ event/state.go | 25 ++++++++++++++++++++++++- event/type.go | 4 +++- 5 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 event/botcommand.go diff --git a/event/botcommand.go b/event/botcommand.go new file mode 100644 index 00000000..a052ebd4 --- /dev/null +++ b/event/botcommand.go @@ -0,0 +1,45 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +type BotCommandsEventContent struct { + Sigil string `json:"sigil,omitempty"` + Commands []*BotCommand `json:"commands,omitempty"` +} + +type BotCommand struct { + Syntax string `json:"syntax"` + Aliases []string `json:"fi.mau.aliases,omitempty"` // Not in MSC (yet) + Arguments []*BotCommandArgument `json:"arguments,omitempty"` + Description *ExtensibleTextContainer `json:"description,omitempty"` +} + +type BotArgumentType string + +const ( + BotArgumentTypeString BotArgumentType = "string" + BotArgumentTypeEnum BotArgumentType = "enum" + BotArgumentTypeInteger BotArgumentType = "integer" + BotArgumentTypeBoolean BotArgumentType = "boolean" + BotArgumentTypeUserID BotArgumentType = "user_id" + BotArgumentTypeRoomID BotArgumentType = "room_id" + BotArgumentTypeRoomAlias BotArgumentType = "room_alias" + BotArgumentTypeEventID BotArgumentType = "event_id" +) + +type BotCommandArgument struct { + Type BotArgumentType `json:"type"` + DefaultValue any `json:"fi.mau.default_value,omitempty"` // Not in MSC (yet) + Description *ExtensibleTextContainer `json:"description,omitempty"` + Enum []string `json:"enum,omitempty"` + Variadic bool `json:"variadic,omitempty"` +} + +type BotCommandInput struct { + Syntax string `json:"syntax"` + Arguments map[string]any `json:"arguments,omitempty"` +} diff --git a/event/content.go b/event/content.go index 5924ffe3..5e093273 100644 --- a/event/content.go +++ b/event/content.go @@ -50,6 +50,7 @@ var TypeMap = map[Type]reflect.Type{ StateElementFunctionalMembers: reflect.TypeOf(ElementFunctionalMembersContent{}), StateBeeperRoomFeatures: reflect.TypeOf(RoomFeatures{}), StateBeeperDisappearingTimer: reflect.TypeOf(BeeperDisappearingTimer{}), + StateBotCommands: reflect.TypeOf(BotCommandsEventContent{}), EventMessage: reflect.TypeOf(MessageEventContent{}), EventSticker: reflect.TypeOf(MessageEventContent{}), diff --git a/event/message.go b/event/message.go index cc7c8261..b397623f 100644 --- a/event/message.go +++ b/event/message.go @@ -142,6 +142,8 @@ type MessageEventContent struct { MSC1767Audio *MSC1767Audio `json:"org.matrix.msc1767.audio,omitempty"` MSC3245Voice *MSC3245Voice `json:"org.matrix.msc3245.voice,omitempty"` + + MSC4332BotCommand *BotCommandInput `json:"org.matrix.msc4332.command,omitempty"` } func (content *MessageEventContent) GetCapMsgType() CapabilityMsgType { diff --git a/event/state.go b/event/state.go index 8711f857..ba7c608d 100644 --- a/event/state.go +++ b/event/state.go @@ -56,10 +56,33 @@ type TopicEventContent struct { // m.room.topic state event as described in [MSC3765]. // // [MSC3765]: https://github.com/matrix-org/matrix-spec-proposals/pull/3765 -type ExtensibleTopic struct { +type ExtensibleTopic = ExtensibleTextContainer + +type ExtensibleTextContainer struct { Text []ExtensibleText `json:"m.text"` } +func MakeExtensibleText(text string) *ExtensibleTextContainer { + return &ExtensibleTextContainer{ + Text: []ExtensibleText{{ + Body: text, + MimeType: "text/plain", + }}, + } +} + +func MakeExtensibleFormattedText(plaintext, html string) *ExtensibleTextContainer { + return &ExtensibleTextContainer{ + Text: []ExtensibleText{{ + Body: plaintext, + MimeType: "text/plain", + }, { + Body: html, + MimeType: "text/html", + }}, + } +} + // ExtensibleText represents the contents of an m.text field. type ExtensibleText struct { MimeType string `json:"mimetype,omitempty"` diff --git a/event/type.go b/event/type.go index 1ab8c517..3f01a067 100644 --- a/event/type.go +++ b/event/type.go @@ -112,7 +112,8 @@ func (et *Type) GuessClass() TypeClass { StatePowerLevels.Type, StateRoomName.Type, StateRoomAvatar.Type, StateServerACL.Type, StateTopic.Type, StatePinnedEvents.Type, StateTombstone.Type, StateEncryption.Type, StateBridge.Type, StateHalfShotBridge.Type, StateSpaceParent.Type, StateSpaceChild.Type, StatePolicyRoom.Type, StatePolicyServer.Type, StatePolicyUser.Type, - StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type, StateBeeperDisappearingTimer.Type: + StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type, StateBeeperDisappearingTimer.Type, + StateBotCommands.Type: return StateEventType case EphemeralEventReceipt.Type, EphemeralEventTyping.Type, EphemeralEventPresence.Type: return EphemeralEventType @@ -204,6 +205,7 @@ var ( StateElementFunctionalMembers = Type{"io.element.functional_members", StateEventType} StateBeeperRoomFeatures = Type{"com.beeper.room_features", StateEventType} StateBeeperDisappearingTimer = Type{"com.beeper.disappearing_timer", StateEventType} + StateBotCommands = Type{"org.matrix.msc4332.commands", StateEventType} ) // Message events From 61a90da14542ac4089cc4f0b7d1f79c48b5b46ec Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 1 Sep 2025 00:45:32 +0300 Subject: [PATCH 280/581] event: use RawMessage instead of map for bot command arguments --- event/botcommand.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/event/botcommand.go b/event/botcommand.go index a052ebd4..2b208656 100644 --- a/event/botcommand.go +++ b/event/botcommand.go @@ -6,6 +6,10 @@ package event +import ( + "encoding/json" +) + type BotCommandsEventContent struct { Sigil string `json:"sigil,omitempty"` Commands []*BotCommand `json:"commands,omitempty"` @@ -40,6 +44,6 @@ type BotCommandArgument struct { } type BotCommandInput struct { - Syntax string `json:"syntax"` - Arguments map[string]any `json:"arguments,omitempty"` + Syntax string `json:"syntax"` + Arguments json.RawMessage `json:"arguments,omitempty"` } From 0627c4227057baeec5040cd0024da179e2b7f982 Mon Sep 17 00:00:00 2001 From: "timedout (aka nexy7574)" Date: Mon, 1 Sep 2025 16:01:05 +0100 Subject: [PATCH 281/581] client: implement MSC4323 (#407) --- client.go | 28 ++++++++++++++++++++++++++++ requests.go | 10 ++++++++++ responses.go | 28 ++++++++++++++++++++++------ versions.go | 1 + 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/client.go b/client.go index 43fc3783..85b27923 100644 --- a/client.go +++ b/client.go @@ -2562,6 +2562,34 @@ func (cli *Client) ReportRoom(ctx context.Context, roomID id.RoomID, reason stri return err } +// UnstableGetSuspendedStatus uses MSC4323 to check if a user is suspended. +func (cli *Client) UnstableGetSuspendedStatus(ctx context.Context, userID id.UserID) (res *RespSuspended, err error) { + urlPath := cli.BuildClientURL("unstable", "uk.timedout.msc4323", "admin", "suspend", userID) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, res) + return +} + +// UnstableGetLockStatus uses MSC4323 to check if a user is locked. +func (cli *Client) UnstableGetLockStatus(ctx context.Context, userID id.UserID) (res *RespLocked, err error) { + urlPath := cli.BuildClientURL("unstable", "uk.timedout.msc4323", "admin", "lock", userID) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, res) + return +} + +// UnstableSetSuspendedStatus uses MSC4323 to set whether a user account is suspended. +func (cli *Client) UnstableSetSuspendedStatus(ctx context.Context, userID id.UserID, suspended bool) (res *RespSuspended, err error) { + urlPath := cli.BuildClientURL("unstable", "uk.timedout.msc4323", "admin", "suspend", userID) + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &ReqSuspend{Suspended: suspended}, res) + return +} + +// UnstableSetLockStatus uses MSC4323 to set whether a user account is locked. +func (cli *Client) UnstableSetLockStatus(ctx context.Context, userID id.UserID, locked bool) (res *RespLocked, err error) { + urlPath := cli.BuildClientURL("unstable", "uk.timedout.msc4323", "admin", "lock", userID) + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &ReqLocked{Locked: locked}, res) + return +} + func (cli *Client) AppservicePing(ctx context.Context, id, txnID string) (resp *RespAppservicePing, err error) { _, err = cli.MakeFullRequest(ctx, FullRequest{ Method: http.MethodPost, diff --git a/requests.go b/requests.go index 9871f044..4b5ce74b 100644 --- a/requests.go +++ b/requests.go @@ -596,3 +596,13 @@ func (rgr *ReqGetRelations) Query() map[string]string { } return query } + +// ReqSuspend is the request body for https://github.com/matrix-org/matrix-spec-proposals/pull/4323 +type ReqSuspend struct { + Suspended bool `json:"suspended"` +} + +// ReqLocked is the request body for https://github.com/matrix-org/matrix-spec-proposals/pull/4323 +type ReqLocked struct { + Locked bool `json:"locked"` +} diff --git a/responses.go b/responses.go index 5b97b293..8ab78373 100644 --- a/responses.go +++ b/responses.go @@ -494,12 +494,13 @@ type RespBeeperBatchSend struct { // RespCapabilities is the JSON response for https://spec.matrix.org/v1.3/client-server-api/#get_matrixclientv3capabilities type RespCapabilities struct { - RoomVersions *CapRoomVersions `json:"m.room_versions,omitempty"` - ChangePassword *CapBooleanTrue `json:"m.change_password,omitempty"` - SetDisplayname *CapBooleanTrue `json:"m.set_displayname,omitempty"` - SetAvatarURL *CapBooleanTrue `json:"m.set_avatar_url,omitempty"` - ThreePIDChanges *CapBooleanTrue `json:"m.3pid_changes,omitempty"` - GetLoginToken *CapBooleanTrue `json:"m.get_login_token,omitempty"` + RoomVersions *CapRoomVersions `json:"m.room_versions,omitempty"` + ChangePassword *CapBooleanTrue `json:"m.change_password,omitempty"` + SetDisplayname *CapBooleanTrue `json:"m.set_displayname,omitempty"` + SetAvatarURL *CapBooleanTrue `json:"m.set_avatar_url,omitempty"` + ThreePIDChanges *CapBooleanTrue `json:"m.3pid_changes,omitempty"` + GetLoginToken *CapBooleanTrue `json:"m.get_login_token,omitempty"` + UnstableAccountModeration *CapUnstableAccountModeration `json:"uk.timedout.msc4323,omitempty"` Custom map[string]interface{} `json:"-"` } @@ -608,6 +609,11 @@ func (vers *CapRoomVersions) IsAvailable(version string) bool { return available } +type CapUnstableAccountModeration struct { + Suspend bool `json:"suspend"` + Lock bool `json:"lock"` +} + type RespPublicRooms struct { Chunk []*PublicRoomInfo `json:"chunk"` NextBatch string `json:"next_batch,omitempty"` @@ -699,3 +705,13 @@ type RespGetRelations struct { PrevBatch string `json:"prev_batch,omitempty"` RecursionDepth int `json:"recursion_depth,omitempty"` } + +// RespSuspended is the response body for https://github.com/matrix-org/matrix-spec-proposals/pull/4323 +type RespSuspended struct { + Suspended bool `json:"suspended"` +} + +// RespLocked is the response body for https://github.com/matrix-org/matrix-spec-proposals/pull/4323 +type RespLocked struct { + Locked bool `json:"locked"` +} diff --git a/versions.go b/versions.go index f87bddda..c3be86cc 100644 --- a/versions.go +++ b/versions.go @@ -66,6 +66,7 @@ var ( FeatureMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms"} FeatureUserRedaction = UnstableFeature{UnstableFlag: "org.matrix.msc4194"} FeatureViewRedactedContent = UnstableFeature{UnstableFlag: "fi.mau.msc2815"} + FeatureAccountModeration = UnstableFeature{UnstableFlag: "uk.timedout.msc4323"} BeeperFeatureHungry = UnstableFeature{UnstableFlag: "com.beeper.hungry"} BeeperFeatureBatchSending = UnstableFeature{UnstableFlag: "com.beeper.batch_sending"} From f8c3a95de7a10ed3a63dffff37adf39c7b21077b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 1 Sep 2025 17:01:20 +0200 Subject: [PATCH 282/581] bridgev2: add support for creating groups (#405) --- bridgev2/commands/startchat.go | 98 +++++++++++++++++++ bridgev2/matrix/provisioning.go | 22 ++++- bridgev2/matrix/provisioning.yaml | 81 +++++++++++++++- bridgev2/matrixinvite.go | 130 +++++++++++--------------- bridgev2/networkid/bridgeid.go | 4 +- bridgev2/networkinterface.go | 70 +++++++++++++- bridgev2/portal.go | 124 ++++++++++++++++++++---- bridgev2/portalinternal.go | 20 +++- bridgev2/provisionutil/creategroup.go | 99 ++++++++++++++++++++ event/capabilities.go | 2 +- 10 files changed, 545 insertions(+), 105 deletions(-) create mode 100644 bridgev2/provisionutil/creategroup.go diff --git a/bridgev2/commands/startchat.go b/bridgev2/commands/startchat.go index da246f50..7b755064 100644 --- a/bridgev2/commands/startchat.go +++ b/bridgev2/commands/startchat.go @@ -7,13 +7,20 @@ package commands import ( + "context" "fmt" "html" + "maps" + "slices" "strings" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/provisionutil" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" ) var CommandResolveIdentifier = &FullHandler{ @@ -100,6 +107,97 @@ func fnResolveIdentifier(ce *Event) { } } +var CommandCreateGroup = &FullHandler{ + Func: fnCreateGroup, + Name: "create-group", + Aliases: []string{"create"}, + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Create a new group chat for the current Matrix room", + Args: "[_group type_]", + }, + RequiresLogin: true, + NetworkAPI: NetworkAPIImplements[bridgev2.GroupCreatingNetworkAPI], +} + +func getState[T any](ctx context.Context, roomID id.RoomID, evtType event.Type, provider bridgev2.MatrixConnectorWithArbitraryRoomState) (content T) { + evt, err := provider.GetStateEvent(ctx, roomID, evtType, "") + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("event_type", evtType).Msg("Failed to get state event for group creation") + } else if evt != nil { + content, _ = evt.Content.Parsed.(T) + } + return +} + +func fnCreateGroup(ce *Event) { + ce.Bridge.Matrix.GetCapabilities() + login, api, remainingArgs := getClientForStartingChat[bridgev2.GroupCreatingNetworkAPI](ce, "creating group") + if api == nil { + return + } + stateProvider, ok := ce.Bridge.Matrix.(bridgev2.MatrixConnectorWithArbitraryRoomState) + if !ok { + ce.Reply("Matrix connector doesn't support fetching room state") + return + } + members, err := ce.Bridge.Matrix.GetMembers(ce.Ctx, ce.RoomID) + if err != nil { + ce.Log.Err(err).Msg("Failed to get room members for group creation") + ce.Reply("Failed to get room members: %v", err) + return + } + caps := ce.Bridge.Network.GetCapabilities() + params := &bridgev2.GroupCreateParams{ + Username: "", + Participants: make([]networkid.UserID, 0, len(members)-2), + Parent: nil, // TODO check space parent event + Name: getState[*event.RoomNameEventContent](ce.Ctx, ce.RoomID, event.StateRoomName, stateProvider), + Avatar: getState[*event.RoomAvatarEventContent](ce.Ctx, ce.RoomID, event.StateRoomAvatar, stateProvider), + Topic: getState[*event.TopicEventContent](ce.Ctx, ce.RoomID, event.StateTopic, stateProvider), + Disappear: getState[*event.BeeperDisappearingTimer](ce.Ctx, ce.RoomID, event.StateBeeperDisappearingTimer, stateProvider), + RoomID: ce.RoomID, + } + for userID, member := range members { + if userID == ce.User.MXID || userID == ce.Bot.GetMXID() || !member.Membership.IsInviteOrJoin() { + continue + } + if parsedUserID, ok := ce.Bridge.Matrix.ParseGhostMXID(userID); ok { + params.Participants = append(params.Participants, parsedUserID) + } else if !ce.Bridge.Config.SplitPortals { + if user, err := ce.Bridge.GetExistingUserByMXID(ce.Ctx, userID); err != nil { + ce.Log.Err(err).Stringer("user_id", userID).Msg("Failed to get user for room member") + } else if user != nil { + // TODO add user logins to participants + //for _, login := range user.GetUserLogins() { + // params.Participants = append(params.Participants, login.GetUserID()) + //} + } + } + } + + if len(caps.Provisioning.GroupCreation) == 0 { + ce.Reply("No group creation types defined in network capabilities") + return + } else if len(remainingArgs) > 0 { + params.Type = remainingArgs[0] + } else if len(caps.Provisioning.GroupCreation) == 1 { + for params.Type = range caps.Provisioning.GroupCreation { + // The loop assigns the variable we want + } + } else { + types := strings.Join(slices.Collect(maps.Keys(caps.Provisioning.GroupCreation)), "`, `") + ce.Reply("Please specify type of group to create: `%s`", types) + return + } + resp, err := provisionutil.CreateGroup(ce.Ctx, login, params) + if err != nil { + ce.Reply("Failed to create group: %v", err) + return + } + ce.Reply("Successfully created group `%s`", resp.ID) +} + var CommandSearch = &FullHandler{ Func: fnSearch, Name: "search", diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 02ad6abd..4e11aa22 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -120,6 +120,7 @@ func (prov *ProvisioningAPI) Init() { tp.Transport.TLSHandshakeTimeout = 10 * time.Second prov.Router = http.NewServeMux() prov.Router.HandleFunc("GET /v3/whoami", prov.GetWhoami) + prov.Router.HandleFunc("GET /v3/capabilities", prov.GetCapabilities) prov.Router.HandleFunc("GET /v3/login/flows", prov.GetLoginFlows) prov.Router.HandleFunc("POST /v3/login/start/{flowID}", prov.PostLoginStart) prov.Router.HandleFunc("POST /v3/login/step/{loginProcessID}/{stepID}/{stepType}", prov.PostLoginStep) @@ -129,7 +130,7 @@ func (prov *ProvisioningAPI) Init() { prov.Router.HandleFunc("POST /v3/search_users", prov.PostSearchUsers) prov.Router.HandleFunc("GET /v3/resolve_identifier/{identifier}", prov.GetResolveIdentifier) prov.Router.HandleFunc("POST /v3/create_dm/{identifier}", prov.PostCreateDM) - prov.Router.HandleFunc("POST /v3/create_group", prov.PostCreateGroup) + prov.Router.HandleFunc("POST /v3/create_group/{type}", prov.PostCreateGroup) if prov.br.Config.Provisioning.EnableSessionTransfers { prov.log.Debug().Msg("Enabling session transfer API") @@ -361,6 +362,10 @@ func (prov *ProvisioningAPI) GetLoginFlows(w http.ResponseWriter, r *http.Reques }) } +func (prov *ProvisioningAPI) GetCapabilities(w http.ResponseWriter, r *http.Request) { + exhttp.WriteJSONResponse(w, http.StatusOK, &prov.net.GetCapabilities().Provisioning) +} + var ErrNilStep = errors.New("bridge returned nil step with no error") func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Request) { @@ -673,11 +678,24 @@ func (prov *ProvisioningAPI) PostCreateDM(w http.ResponseWriter, r *http.Request } func (prov *ProvisioningAPI) PostCreateGroup(w http.ResponseWriter, r *http.Request) { + var req bridgev2.GroupCreateParams + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") + mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) + return + } + req.Type = r.PathValue("type") login := prov.GetLoginForRequest(w, r) if login == nil { return } - mautrix.MUnrecognized.WithMessage("Creating groups is not yet implemented").Write(w) + resp, err := provisionutil.CreateGroup(r.Context(), login, &req) + if err != nil { + RespondWithError(w, err, "Internal error creating group") + return + } + exhttp.WriteJSONResponse(w, http.StatusOK, resp) } type ReqExportCredentials struct { diff --git a/bridgev2/matrix/provisioning.yaml b/bridgev2/matrix/provisioning.yaml index b9879ea5..5bb27272 100644 --- a/bridgev2/matrix/provisioning.yaml +++ b/bridgev2/matrix/provisioning.yaml @@ -361,14 +361,25 @@ paths: $ref: '#/components/responses/InternalError' 501: $ref: '#/components/responses/NotSupported' - /v3/create_group: + /v3/create_group/{type}: post: tags: [ snc ] summary: Create a group chat on the remote network. operationId: createGroup parameters: - $ref: "#/components/parameters/loginID" + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GroupCreateParams' responses: + 200: + description: Identifier resolved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreatedGroup' 401: $ref: '#/components/responses/Unauthorized' 404: @@ -572,6 +583,74 @@ components: description: The Matrix room ID of the direct chat with the user. examples: - '!OKhS0I5q2fCzdnl2qgeozDQw:t2bot.io' + GroupCreateParams: + type: object + description: | + Parameters for creating a group chat. + The /capabilities endpoint response must be checked to see which fields are actually allowed. + properties: + type: + type: string + description: The type of group to create. + examples: + - channel + username: + type: string + description: The public username for the created group. + participants: + type: array + description: The users to add to the group initially. + items: + type: string + parent: + type: object + name: + type: object + description: The `m.room.name` event content for the room. + properties: + name: + type: string + avatar: + type: object + description: The `m.room.avatar` event content for the room. + properties: + url: + type: string + format: mxc + topic: + type: object + description: The `m.room.topic` event content for the room. + properties: + topic: + type: string + disappear: + type: object + description: The `com.beeper.disappearing_timer` event content for the room. + properties: + type: + type: string + timer: + type: number + room_id: + type: string + format: matrix_room_id + description: | + An existing Matrix room ID to bridge to. + The other parameters must be already in sync with the room state when using this parameter. + CreatedGroup: + type: object + description: A successfully created group chat. + required: [id, mxid] + properties: + id: + type: string + description: The internal chat ID of the created group. + mxid: + type: string + format: matrix_room_id + description: The Matrix room ID of the portal. + examples: + - '!OKhS0I5q2fCzdnl2qgeozDQw:t2bot.io' LoginStep: type: object description: A step in a login process. diff --git a/bridgev2/matrixinvite.go b/bridgev2/matrixinvite.go index bfbabd26..2c14cc7f 100644 --- a/bridgev2/matrixinvite.go +++ b/bridgev2/matrixinvite.go @@ -206,72 +206,64 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen return EventHandlingResultFailed } - didSetPortal := portal.setMXIDToExistingRoom(ctx, evt.RoomID) - if didSetPortal { - message := "Private chat portal created" - err = br.givePowerToBot(ctx, evt.RoomID, invitedGhost.Intent) - hasWarning := false - if err != nil { - log.Warn().Err(err).Msg("Failed to give power to bot in new DM") - message += "\n\nWarning: failed to promote bot" - hasWarning = true - } - if resp.DMRedirectedTo != "" && resp.DMRedirectedTo != invitedGhost.ID { - log.Debug(). - Str("dm_redirected_to_id", string(resp.DMRedirectedTo)). - Msg("Created DM was redirected to another user ID") - _, err = invitedGhost.Intent.SendState(ctx, portal.MXID, event.StateMember, invitedGhost.Intent.GetMXID().String(), &event.Content{ - Parsed: &event.MemberEventContent{ - Membership: event.MembershipLeave, - Reason: "Direct chat redirected to another internal user ID", - }, - }, time.Time{}) - if err != nil { - log.Err(err).Msg("Failed to make incorrect ghost leave new DM room") - } - otherUserGhost, err := br.GetGhostByID(ctx, resp.DMRedirectedTo) - if err != nil { - log.Err(err).Msg("Failed to get ghost of real portal other user ID") - } else { - invitedGhost = otherUserGhost - } - } - if resp.PortalInfo != nil { - portal.UpdateInfo(ctx, resp.PortalInfo, sourceLogin, nil, time.Time{}) - } else { - portal.UpdateCapabilities(ctx, sourceLogin, true) - portal.UpdateBridgeInfo(ctx) - } - // TODO this might become unnecessary if UpdateInfo starts taking care of it - _, err = br.Bot.SendState(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.Content{ - Parsed: &event.ElementFunctionalMembersContent{ - ServiceMembers: []id.UserID{br.Bot.GetMXID()}, + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() + portalMXID := portal.MXID + if portalMXID != "" { + sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "You already have a direct chat with me at [%s](%s)", portalMXID, portalMXID.URI(br.Matrix.ServerName()).MatrixToURL()) + rejectInvite(ctx, evt, br.Bot, "") + return EventHandlingResultSuccess + } + err = br.givePowerToBot(ctx, evt.RoomID, invitedGhost.Intent) + if err != nil { + log.Err(err).Msg("Failed to give permissions to bridge bot") + sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to give permissions to bridge bot") + rejectInvite(ctx, evt, br.Bot, "") + return EventHandlingResultSuccess + } + if resp.DMRedirectedTo != "" && resp.DMRedirectedTo != invitedGhost.ID { + log.Debug(). + Str("dm_redirected_to_id", string(resp.DMRedirectedTo)). + Msg("Created DM was redirected to another user ID") + _, err = invitedGhost.Intent.SendState(ctx, portal.MXID, event.StateMember, invitedGhost.Intent.GetMXID().String(), &event.Content{ + Parsed: &event.MemberEventContent{ + Membership: event.MembershipLeave, + Reason: "Direct chat redirected to another internal user ID", }, }, time.Time{}) if err != nil { - log.Warn().Err(err).Msg("Failed to set service members in room") - if !hasWarning { - message += "\n\nWarning: failed to set service members" - hasWarning = true - } + log.Err(err).Msg("Failed to make incorrect ghost leave new DM room") } - mx, ok := br.Matrix.(MatrixConnectorWithPostRoomBridgeHandling) - if ok { - err = mx.HandleNewlyBridgedRoom(ctx, evt.RoomID) - if err != nil { - if hasWarning { - message += fmt.Sprintf(", %s", err.Error()) - } else { - message += fmt.Sprintf("\n\nWarning: %s", err.Error()) - } - } + otherUserGhost, err := br.GetGhostByID(ctx, resp.DMRedirectedTo) + if err != nil { + log.Err(err).Msg("Failed to get ghost of real portal other user ID") + } else { + invitedGhost = otherUserGhost } - sendNotice(ctx, evt, invitedGhost.Intent, message) - } else { - // TODO ensure user is invited even if PortalInfo wasn't provided? - sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "You already have a direct chat with me at [%s](%s)", portal.MXID, portal.MXID.URI(br.Matrix.ServerName()).MatrixToURL()) - rejectInvite(ctx, evt, br.Bot, "") } + err = portal.UpdateMatrixRoomID(ctx, evt.RoomID, UpdateMatrixRoomIDParams{ + // We locked it before checking the mxid + RoomCreateAlreadyLocked: true, + + FailIfMXIDSet: true, + ChatInfo: resp.PortalInfo, + ChatInfoSource: sourceLogin, + }) + if err != nil { + log.Err(err).Msg("Failed to update Matrix room ID for new DM portal") + sendNotice(ctx, evt, invitedGhost.Intent, "Failed to finish configuring portal. The chat may or may not work") + return EventHandlingResultSuccess + } + message := "Private chat portal created" + mx, ok := br.Matrix.(MatrixConnectorWithPostRoomBridgeHandling) + if ok { + err = mx.HandleNewlyBridgedRoom(ctx, evt.RoomID) + if err != nil { + log.Err(err).Msg("Error in connector newly bridged room handler") + message += fmt.Sprintf("\n\nWarning: %s", err.Error()) + } + } + sendNotice(ctx, evt, invitedGhost.Intent, message) return EventHandlingResultSuccess } @@ -294,21 +286,3 @@ func (br *Bridge) givePowerToBot(ctx context.Context, roomID id.RoomID, userWith } return nil } - -func (portal *Portal) setMXIDToExistingRoom(ctx context.Context, roomID id.RoomID) bool { - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - if portal.MXID != "" { - return false - } - portal.MXID = roomID - portal.updateLogger() - portal.Bridge.cacheLock.Lock() - portal.Bridge.portalsByMXID[portal.MXID] = portal - portal.Bridge.cacheLock.Unlock() - err := portal.Save(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after updating mxid") - } - return true -} diff --git a/bridgev2/networkid/bridgeid.go b/bridgev2/networkid/bridgeid.go index 443d3655..e3a6df70 100644 --- a/bridgev2/networkid/bridgeid.go +++ b/bridgev2/networkid/bridgeid.go @@ -47,8 +47,8 @@ type PortalID string // As a special case, Receiver MUST be set if the Bridge.Config.SplitPortals flag is set to true. // The flag is intended for puppeting-only bridges which want multiple logins to create separate portals for each user. type PortalKey struct { - ID PortalID - Receiver UserLoginID + ID PortalID `json:"portal_id"` + Receiver UserLoginID `json:"portal_receiver,omitempty"` } func (pk PortalKey) IsEmpty() bool { diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index dcbcbad5..8293be51 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -350,6 +350,8 @@ type NetworkGeneralCapabilities struct { // to handle asynchronous message responses, this field can be set to enable // automatic timeout errors in case the asynchronous response never arrives. OutgoingMessageTimeouts *OutgoingTimeoutConfig + // Capabilities related to the provisioning API. + Provisioning ProvisioningCapabilities } // NetworkAPI is an interface representing a remote network client for a single user login. @@ -750,9 +752,75 @@ type UserSearchingNetworkAPI interface { SearchUsers(ctx context.Context, query string) ([]*ResolveIdentifierResponse, error) } +type ProvisioningCapabilities struct { + ResolveIdentifier ResolveIdentifierCapabilities `json:"resolve_identifier"` + GroupCreation map[string]GroupTypeCapabilities `json:"group_creation"` +} + +type ResolveIdentifierCapabilities struct { + // Can DMs be created after resolving an identifier? + CreateDM bool `json:"create_dm"` + // Can users be looked up by phone number? + LookupPhone bool `json:"lookup_phone"` + // Can users be looked up by email address? + LookupEmail bool `json:"lookup_email"` + // Can users be looked up by network-specific username? + LookupUsername bool `json:"lookup_username"` + // Can any phone number be contacted without having to validate it via lookup first? + AnyPhone bool `json:"any_phone"` + // Can a contact list be retrieved from the bridge? + ContactList bool `json:"contact_list"` + // Can users be searched by name on the remote network? + Search bool `json:"search"` +} + +type GroupTypeCapabilities struct { + TypeDescription string `json:"type_description"` + + Name GroupFieldCapability `json:"name"` + Username GroupFieldCapability `json:"username"` + Avatar GroupFieldCapability `json:"avatar"` + Topic GroupFieldCapability `json:"topic"` + Disappear GroupFieldCapability `json:"disappear"` + Participants GroupFieldCapability `json:"participants"` + Parent GroupFieldCapability `json:"parent"` +} + +type GroupFieldCapability struct { + // Is setting this field allowed at all in the create request? + // Even if false, the network connector should attempt to set the metadata after group creation, + // as the allowed flag can't be enforced properly when creating a group for an existing Matrix room. + Allowed bool `json:"allowed"` + // Is setting this field mandatory for the creation to succeed? + Required bool `json:"required,omitempty"` + // The minimum/maximum length of the field, if applicable. + // For members, length means the number of members excluding the creator. + MinLength int `json:"min_length,omitempty"` + MaxLength int `json:"max_length,omitempty"` + + // Only for the disappear field: allowed disappearing settings + DisappearSettings *event.DisappearingTimerCapability `json:"settings,omitempty"` +} + +type GroupCreateParams struct { + Type string `json:"type"` + + Username string `json:"username"` + Participants []networkid.UserID `json:"participants"` + Parent *networkid.PortalKey `json:"parent"` + + Name *event.RoomNameEventContent `json:"name"` + Avatar *event.RoomAvatarEventContent `json:"avatar"` + Topic *event.TopicEventContent `json:"topic"` + Disappear *event.BeeperDisappearingTimer `json:"disappear"` + + // An existing room ID to bridge to. If unset, a new room will be created. + RoomID id.RoomID `json:"room_id"` +} + type GroupCreatingNetworkAPI interface { IdentifierResolvingNetworkAPI - CreateGroup(ctx context.Context, name string, users ...networkid.UserID) (*CreateChatResponse, error) + CreateGroup(ctx context.Context, params *GroupCreateParams) (*CreateChatResponse, error) } type MembershipChangeType struct { diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 5e0a9137..85d670d9 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1840,42 +1840,134 @@ func (portal *Portal) handleMatrixTombstone(ctx context.Context, evt *event.Even return EventHandlingResultIgnored } } - - portal.Bridge.cacheLock.Lock() - if _, alreadyExists := portal.Bridge.portalsByMXID[content.ReplacementRoom]; alreadyExists { - log.Warn().Msg("Replacement room is already a portal, ignoring tombstone") - portal.Bridge.cacheLock.Unlock() + err = portal.UpdateMatrixRoomID(ctx, content.ReplacementRoom, UpdateMatrixRoomIDParams{ + DeleteOldRoom: true, + FetchInfoVia: senderUser, + }) + if errors.Is(err, ErrTargetRoomIsPortal) { return EventHandlingResultIgnored + } else if err != nil { + return EventHandlingResultFailed.WithError(err) } - delete(portal.Bridge.portalsByMXID, portal.MXID) - portal.MXID = content.ReplacementRoom + return EventHandlingResultSuccess +} + +var ErrTargetRoomIsPortal = errors.New("target room is already a portal") +var ErrRoomAlreadyExists = errors.New("this portal already has a room") + +type UpdateMatrixRoomIDParams struct { + SyncDBMetadata func() + FailIfMXIDSet bool + OverwriteOldPortal bool + TombstoneOldRoom bool + DeleteOldRoom bool + + RoomCreateAlreadyLocked bool + + FetchInfoVia *User + ChatInfo *ChatInfo + ChatInfoSource *UserLogin +} + +func (portal *Portal) UpdateMatrixRoomID( + ctx context.Context, + newRoomID id.RoomID, + params UpdateMatrixRoomIDParams, +) error { + if !params.RoomCreateAlreadyLocked { + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() + } + oldRoom := portal.MXID + if oldRoom == newRoomID { + return nil + } else if oldRoom != "" && params.FailIfMXIDSet { + return ErrRoomAlreadyExists + } + log := zerolog.Ctx(ctx) + portal.Bridge.cacheLock.Lock() + // Wrap unlock in a sync.OnceFunc because we want to both defer it to catch early returns + // and unlock it before return if nothing goes wrong. + unlockCacheLock := sync.OnceFunc(portal.Bridge.cacheLock.Unlock) + defer unlockCacheLock() + if existingPortal, alreadyExists := portal.Bridge.portalsByMXID[newRoomID]; alreadyExists && !params.OverwriteOldPortal { + log.Warn().Msg("Replacement room is already a portal, ignoring") + return ErrTargetRoomIsPortal + } else if alreadyExists { + log.Debug().Msg("Replacement room is already a portal, overwriting") + existingPortal.MXID = "" + err := existingPortal.Save(ctx) + if err != nil { + return fmt.Errorf("failed to clear mxid of existing portal: %w", err) + } + delete(portal.Bridge.portalsByMXID, portal.MXID) + } + portal.MXID = newRoomID portal.Bridge.portalsByMXID[portal.MXID] = portal portal.NameSet = false portal.AvatarSet = false portal.TopicSet = false portal.InSpace = false portal.CapState = database.CapabilityState{} - portal.Bridge.cacheLock.Unlock() + portal.lastCapUpdate = time.Time{} + if params.SyncDBMetadata != nil { + params.SyncDBMetadata() + } + unlockCacheLock() + portal.updateLogger() - err = portal.Save(ctx) + err := portal.Save(ctx) if err != nil { - log.Err(err).Msg("Failed to save portal after tombstone") - return EventHandlingResultFailed.WithError(err) + log.Err(err).Msg("Failed to save portal in UpdateMatrixRoomID") + return err } log.Info().Msg("Successfully followed tombstone and updated portal MXID") err = portal.Bridge.DB.UserPortal.MarkAllNotInSpace(ctx, portal.PortalKey) if err != nil { - log.Err(err).Msg("Failed to update in_space flag for user portals after tombstone") + log.Err(err).Msg("Failed to update in_space flag for user portals after updating portal MXID") } go portal.addToUserSpaces(ctx) - go portal.updateInfoAfterTombstone(ctx, senderUser) + if params.FetchInfoVia != nil { + go portal.updateInfoAfterTombstone(ctx, params.FetchInfoVia) + } else if params.ChatInfo != nil { + go portal.UpdateInfo(ctx, params.ChatInfo, params.ChatInfoSource, nil, time.Time{}) + } else if params.ChatInfoSource != nil { + portal.UpdateCapabilities(ctx, params.ChatInfoSource, true) + portal.UpdateBridgeInfo(ctx) + } go func() { - err = portal.Bridge.Bot.DeleteRoom(ctx, evt.RoomID, true) + // TODO this might become unnecessary if UpdateInfo starts taking care of it + _, err = portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.Content{ + Parsed: &event.ElementFunctionalMembersContent{ + ServiceMembers: []id.UserID{portal.Bridge.Bot.GetMXID()}, + }, + }, time.Time{}) if err != nil { - log.Err(err).Msg("Failed to clean up Matrix room after following tombstone") + if err != nil { + log.Warn().Err(err).Msg("Failed to set service members in new room") + } } }() - return EventHandlingResultSuccess + if params.TombstoneOldRoom && oldRoom != "" { + _, err = portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateTombstone, "", &event.Content{ + Parsed: &event.TombstoneEventContent{ + Body: "Room has been replaced.", + ReplacementRoom: newRoomID, + }, + }, time.Now()) + if err != nil { + log.Err(err).Msg("Failed to send tombstone event to old room") + } + } + if params.DeleteOldRoom && oldRoom != "" { + go func() { + err = portal.Bridge.Bot.DeleteRoom(ctx, oldRoom, true) + if err != nil { + log.Err(err).Msg("Failed to clean up old Matrix room after updating portal MXID") + } + }() + } + return nil } func (portal *Portal) updateInfoAfterTombstone(ctx context.Context, senderUser *User) { diff --git a/bridgev2/portalinternal.go b/bridgev2/portalinternal.go index e82c481a..0223b4f2 100644 --- a/bridgev2/portalinternal.go +++ b/bridgev2/portalinternal.go @@ -125,6 +125,14 @@ func (portal *PortalInternals) HandleMatrixPowerLevels(ctx context.Context, send return (*Portal)(portal).handleMatrixPowerLevels(ctx, sender, origSender, evt) } +func (portal *PortalInternals) HandleMatrixTombstone(ctx context.Context, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixTombstone(ctx, evt) +} + +func (portal *PortalInternals) UpdateInfoAfterTombstone(ctx context.Context, senderUser *User) { + (*Portal)(portal).updateInfoAfterTombstone(ctx, senderUser) +} + func (portal *PortalInternals) HandleMatrixRedaction(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { return (*Portal)(portal).handleMatrixRedaction(ctx, sender, origSender, evt) } @@ -133,6 +141,10 @@ func (portal *PortalInternals) HandleRemoteEvent(ctx context.Context, source *Us return (*Portal)(portal).handleRemoteEvent(ctx, source, evtType, evt) } +func (portal *PortalInternals) EnsureFunctionalMember(ctx context.Context, ghost *Ghost) { + (*Portal)(portal).ensureFunctionalMember(ctx, ghost) +} + func (portal *PortalInternals) GetIntentAndUserMXIDFor(ctx context.Context, sender EventSender, source *UserLogin, otherLogins []*UserLogin, evtType RemoteEventType) (intent MatrixAPI, extraUserID id.UserID, err error) { return (*Portal)(portal).getIntentAndUserMXIDFor(ctx, sender, source, otherLogins, evtType) } @@ -297,6 +309,10 @@ func (portal *PortalInternals) CreateMatrixRoomInLoop(ctx context.Context, sourc return (*Portal)(portal).createMatrixRoomInLoop(ctx, source, info, backfillBundle) } +func (portal *PortalInternals) AddToUserSpaces(ctx context.Context) { + (*Portal)(portal).addToUserSpaces(ctx) +} + func (portal *PortalInternals) RemoveInPortalCache(ctx context.Context) { (*Portal)(portal).removeInPortalCache(ctx) } @@ -360,7 +376,3 @@ func (portal *PortalInternals) AddToParentSpaceAndSave(ctx context.Context, save func (portal *PortalInternals) ToggleSpace(ctx context.Context, spaceID id.RoomID, canonical, remove bool) error { return (*Portal)(portal).toggleSpace(ctx, spaceID, canonical, remove) } - -func (portal *PortalInternals) SetMXIDToExistingRoom(ctx context.Context, roomID id.RoomID) bool { - return (*Portal)(portal).setMXIDToExistingRoom(ctx, roomID) -} diff --git a/bridgev2/provisionutil/creategroup.go b/bridgev2/provisionutil/creategroup.go new file mode 100644 index 00000000..891f9615 --- /dev/null +++ b/bridgev2/provisionutil/creategroup.go @@ -0,0 +1,99 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package provisionutil + +import ( + "context" + + "github.com/rs/zerolog" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/id" +) + +type RespCreateGroup struct { + ID networkid.PortalID `json:"id"` + MXID id.RoomID `json:"mxid"` + Portal *bridgev2.Portal `json:"-"` +} + +func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev2.GroupCreateParams) (*RespCreateGroup, error) { + api, ok := login.Client.(bridgev2.GroupCreatingNetworkAPI) + if !ok { + return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support creating groups")) + } + caps := login.Bridge.Network.GetCapabilities() + typeSpec, validType := caps.Provisioning.GroupCreation[params.Type] + if !validType { + return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("Unrecognized group type %s", params.Type)) + } + if len(params.Participants) < typeSpec.Participants.MinLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at least %d members", typeSpec.Participants.MinLength)) + } + if (params.Name == nil || params.Name.Name == "") && typeSpec.Name.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name is required")) + } else if nameLen := len(ptr.Val(params.Name).Name); nameLen > 0 && nameLen < typeSpec.Name.MinLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name must be at least %d characters", typeSpec.Name.MinLength)) + } else if nameLen > typeSpec.Name.MaxLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name must be at most %d characters", typeSpec.Name.MaxLength)) + } + if (params.Avatar == nil || params.Avatar.URL == "") && typeSpec.Avatar.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Avatar is required")) + } + if (params.Topic == nil || params.Topic.Topic == "") && typeSpec.Topic.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic is required")) + } else if topicLen := len(ptr.Val(params.Topic).Topic); topicLen > 0 && topicLen < typeSpec.Topic.MinLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic must be at least %d characters", typeSpec.Topic.MinLength)) + } else if topicLen > typeSpec.Topic.MaxLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic must be at most %d characters", typeSpec.Topic.MaxLength)) + } + if (params.Disappear == nil || params.Disappear.Timer.Duration == 0) && typeSpec.Disappear.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Disappearing timer is required")) + } else if !typeSpec.Disappear.DisappearSettings.Supports(params.Disappear) { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Unsupported value for disappearing timer")) + } + if params.Username == "" && typeSpec.Username.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username is required")) + } else if len(params.Username) > 0 && len(params.Username) < typeSpec.Username.MinLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username must be at least %d characters", typeSpec.Username.MinLength)) + } else if len(params.Username) > typeSpec.Username.MaxLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username must be at most %d characters", typeSpec.Username.MaxLength)) + } + if params.Parent == nil && typeSpec.Parent.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Parent is required")) + } + resp, err := api.CreateGroup(ctx, params) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to create group") + return nil, err + } + if resp.PortalKey.IsEmpty() { + return nil, ErrNoPortalKey + } + if resp.Portal == nil { + resp.Portal, err = login.Bridge.GetPortalByKey(ctx, resp.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal") + return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to get portal")) + } + } + if resp.Portal.MXID == "" { + err = resp.Portal.CreateMatrixRoom(ctx, login, resp.PortalInfo) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to create portal room") + return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to create portal room")) + } + } + return &RespCreateGroup{ + ID: resp.Portal.ID, + MXID: resp.Portal.MXID, + Portal: resp.Portal, + }, nil +} diff --git a/event/capabilities.go b/event/capabilities.go index ebedb6a2..94662428 100644 --- a/event/capabilities.go +++ b/event/capabilities.go @@ -77,7 +77,7 @@ type DisappearingTimerCapability struct { } func (dtc *DisappearingTimerCapability) Supports(content *BeeperDisappearingTimer) bool { - if dtc == nil || content.Type == DisappearingTypeNone { + if dtc == nil || content == nil || content.Type == DisappearingTypeNone { return true } return slices.Contains(dtc.Types, content.Type) && (dtc.Timers == nil || slices.Contains(dtc.Timers, content.Timer)) From bcd0a70bdfb1d44b78e89465a2fb3b2de44cfb4e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 2 Sep 2025 00:31:15 +0300 Subject: [PATCH 283/581] appservice/websocket: override read limit --- appservice/websocket.go | 1 + 1 file changed, 1 insertion(+) diff --git a/appservice/websocket.go b/appservice/websocket.go index 18768098..309cc485 100644 --- a/appservice/websocket.go +++ b/appservice/websocket.go @@ -412,6 +412,7 @@ func (as *AppService) StartWebsocket(ctx context.Context, baseURL string, onConn } }) } + ws.SetReadLimit(50 * 1024 * 1024) as.ws = ws as.StopWebsocket = stopFunc as.PrepareWebsocket() From 8f8b26d815b11a00847afdd4e8fe332b2e14f137 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 2 Sep 2025 10:33:49 +0300 Subject: [PATCH 284/581] event: add is_animated flag from MSC4230 --- event/message.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/event/message.go b/event/message.go index b397623f..692382cf 100644 --- a/event/message.go +++ b/event/message.go @@ -301,7 +301,8 @@ type FileInfo struct { Blurhash string AnoaBlurhash string - MauGIF bool + MauGIF bool + IsAnimated bool Width int Height int @@ -318,7 +319,8 @@ type serializableFileInfo struct { Blurhash string `json:"blurhash,omitempty"` AnoaBlurhash string `json:"xyz.amorgan.blurhash,omitempty"` - MauGIF bool `json:"fi.mau.gif,omitempty"` + MauGIF bool `json:"fi.mau.gif,omitempty"` + IsAnimated bool `json:"is_animated,omitempty"` Width json.Number `json:"w,omitempty"` Height json.Number `json:"h,omitempty"` @@ -336,7 +338,8 @@ func (sfi *serializableFileInfo) CopyFrom(fileInfo *FileInfo) *serializableFileI ThumbnailInfo: (&serializableFileInfo{}).CopyFrom(fileInfo.ThumbnailInfo), ThumbnailFile: fileInfo.ThumbnailFile, - MauGIF: fileInfo.MauGIF, + MauGIF: fileInfo.MauGIF, + IsAnimated: fileInfo.IsAnimated, Blurhash: fileInfo.Blurhash, AnoaBlurhash: fileInfo.AnoaBlurhash, @@ -367,6 +370,7 @@ func (sfi *serializableFileInfo) CopyTo(fileInfo *FileInfo) { ThumbnailURL: sfi.ThumbnailURL, ThumbnailFile: sfi.ThumbnailFile, MauGIF: sfi.MauGIF, + IsAnimated: sfi.IsAnimated, Blurhash: sfi.Blurhash, AnoaBlurhash: sfi.AnoaBlurhash, } From 709f48f2b3703a91871b2d7d0f4ac7fb1dc71d5d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 2 Sep 2025 18:24:24 +0300 Subject: [PATCH 285/581] bridgev2/provisioning: remove unused structs --- bridgev2/matrix/provisioning.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 4e11aa22..61aad869 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -600,15 +600,6 @@ func RespondWithError(w http.ResponseWriter, err error, message string) { } } -type RespResolveIdentifier struct { - ID networkid.UserID `json:"id"` - Name string `json:"name,omitempty"` - AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` - Identifiers []string `json:"identifiers,omitempty"` - MXID id.UserID `json:"mxid,omitempty"` - DMRoomID id.RoomID `json:"dm_room_mxid,omitempty"` -} - func (prov *ProvisioningAPI) doResolveIdentifier(w http.ResponseWriter, r *http.Request, createChat bool) { login := prov.GetLoginForRequest(w, r) if login == nil { @@ -645,10 +636,6 @@ type ReqSearchUsers struct { Query string `json:"query"` } -type RespSearchUsers struct { - Results []*RespResolveIdentifier `json:"results"` -} - func (prov *ProvisioningAPI) PostSearchUsers(w http.ResponseWriter, r *http.Request) { var req ReqSearchUsers err := json.NewDecoder(r.Body).Decode(&req) From 30ab68f7f18fe657bb0395ba141e0af284d01d3e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 4 Sep 2025 18:18:53 +0300 Subject: [PATCH 286/581] appservice: maybe fix url template raw path for unix sockets --- appservice/appservice.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appservice/appservice.go b/appservice/appservice.go index 33b53d7d..d7037ef6 100644 --- a/appservice/appservice.go +++ b/appservice/appservice.go @@ -334,7 +334,7 @@ func (as *AppService) SetHomeserverURL(homeserverURL string) error { } else if as.hsURLForClient.Scheme == "" { as.hsURLForClient.Scheme = "https" } - as.hsURLForClient.RawPath = parsedURL.EscapedPath() + as.hsURLForClient.RawPath = as.hsURLForClient.EscapedPath() jar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) as.HTTPClient = &http.Client{Timeout: 180 * time.Second, Jar: jar} From 41bbe4ace4c7a35b942b2761fb221caed8ee3d8a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 9 Sep 2025 16:24:18 +0300 Subject: [PATCH 287/581] bridgev2/portal: add action message metadata to disappearing notices --- bridgev2/portal.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 85d670d9..f3797247 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4191,6 +4191,14 @@ func (portal *Portal) UpdateDisappearingSetting(ctx context.Context, setting dat content := DisappearingMessageNotice(setting.Timer, opts.Implicit) _, err := opts.Sender.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{ Parsed: content, + Raw: map[string]any{ + "com.beeper.action_message": map[string]any{ + "type": "disappearing_timer", + "timer": setting.Timer.Milliseconds(), + "timer_type": setting.Type, + "implicit": opts.Implicit, + }, + }, }, &MatrixSendExtra{Timestamp: opts.Timestamp}) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to send disappearing messages notice") From e295028ffd4409d56b47b58d13606474f6187f51 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 9 Sep 2025 19:10:07 +0300 Subject: [PATCH 288/581] client: stabilize arbitrary profile field support --- client.go | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/client.go b/client.go index 85b27923..71c3fb18 100644 --- a/client.go +++ b/client.go @@ -1088,8 +1088,7 @@ func (cli *Client) GetRoomSummary(ctx context.Context, roomIDOrAlias string, via // GetDisplayName returns the display name of the user with the specified MXID. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseriddisplayname func (cli *Client) GetDisplayName(ctx context.Context, mxid id.UserID) (resp *RespUserDisplayName, err error) { - urlPath := cli.BuildClientURL("v3", "profile", mxid, "displayname") - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + err = cli.GetProfileField(ctx, mxid, "displayname", &resp) return } @@ -1100,41 +1099,38 @@ func (cli *Client) GetOwnDisplayName(ctx context.Context) (resp *RespUserDisplay // SetDisplayName sets the user's profile display name. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3profileuseriddisplayname func (cli *Client) SetDisplayName(ctx context.Context, displayName string) (err error) { - urlPath := cli.BuildClientURL("v3", "profile", cli.UserID, "displayname") - s := struct { - DisplayName string `json:"displayname"` - }{displayName} - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &s, nil) - return + return cli.SetProfileField(ctx, "displayname", displayName) } -// UnstableSetProfileField sets an arbitrary MSC4133 profile field. See https://github.com/matrix-org/matrix-spec-proposals/pull/4133 -func (cli *Client) UnstableSetProfileField(ctx context.Context, key string, value any) (err error) { - urlPath := cli.BuildClientURL("unstable", "uk.tcpip.msc4133", "profile", cli.UserID, key) +// SetProfileField sets an arbitrary profile field. See https://spec.matrix.org/v1.16/client-server-api/#put_matrixclientv3profileuseridkeyname +func (cli *Client) SetProfileField(ctx context.Context, key string, value any) (err error) { + urlPath := cli.BuildClientURL("v3", "profile", cli.UserID, key) _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, map[string]any{ key: value, }, nil) return } -// UnstableDeleteProfileField deletes an arbitrary MSC4133 profile field. See https://github.com/matrix-org/matrix-spec-proposals/pull/4133 -func (cli *Client) UnstableDeleteProfileField(ctx context.Context, key string) (err error) { - urlPath := cli.BuildClientURL("unstable", "uk.tcpip.msc4133", "profile", cli.UserID, key) +// DeleteProfileField deletes an arbitrary profile field. See https://spec.matrix.org/v1.16/client-server-api/#put_matrixclientv3profileuseridkeyname +func (cli *Client) DeleteProfileField(ctx context.Context, key string) (err error) { + urlPath := cli.BuildClientURL("v3", "profile", cli.UserID, key) _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, nil) return } +// GetProfileField gets an arbitrary profile field and parses the response into the given struct. See https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv3profileuseridkeyname +func (cli *Client) GetProfileField(ctx context.Context, userID id.UserID, key string, into any) (err error) { + urlPath := cli.BuildClientURL("v3", "profile", userID, key) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, into) + return +} + // GetAvatarURL gets the avatar URL of the user with the specified MXID. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseridavatar_url func (cli *Client) GetAvatarURL(ctx context.Context, mxid id.UserID) (url id.ContentURI, err error) { - urlPath := cli.BuildClientURL("v3", "profile", mxid, "avatar_url") s := struct { AvatarURL id.ContentURI `json:"avatar_url"` }{} - - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &s) - if err != nil { - return - } + err = cli.GetProfileField(ctx, mxid, "avatar_url", &s) url = s.AvatarURL return } From 22a908d8d63d0c7f3c98bffd9f07e5c245bc1907 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 10 Sep 2025 16:24:43 +0300 Subject: [PATCH 289/581] crypto/decryptolm: add debug logs for failing to decrypt with new session --- crypto/decryptolm.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crypto/decryptolm.go b/crypto/decryptolm.go index b961a7b4..f54210a7 100644 --- a/crypto/decryptolm.go +++ b/crypto/decryptolm.go @@ -17,6 +17,8 @@ import ( "time" "github.com/rs/zerolog" + "go.mau.fi/util/exerrors" + "go.mau.fi/util/ptr" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -180,6 +182,7 @@ func (mach *OlmMachine) tryDecryptOlmCiphertext(ctx context.Context, sender id.U log = log.With().Str("new_olm_session_id", session.ID().String()).Logger() log.Debug(). Hex("ciphertext_hash", ciphertextHash[:]). + Hex("ciphertext_hash_repeat", ptr.Ptr(exerrors.Must(olmMessageHash(ciphertext)))[:]). Str("olm_session_description", session.Describe()). Msg("Created inbound olm session") ctx = log.WithContext(ctx) @@ -189,6 +192,12 @@ func (mach *OlmMachine) tryDecryptOlmCiphertext(ctx context.Context, sender id.U endTimeTrace() if err != nil { go mach.unwedgeDevice(log, sender, senderKey) + log.Debug(). + Hex("ciphertext_hash", ciphertextHash[:]). + Hex("ciphertext_hash_repeat", ptr.Ptr(exerrors.Must(olmMessageHash(ciphertext)))[:]). + Str("ciphertext", ciphertext). + Str("olm_session_description", session.Describe()). + Msg("DEBUG: Failed to decrypt prekey olm message with newly created session") return nil, fmt.Errorf("failed to decrypt olm event with session created from prekey message: %w", err) } From faa1c5ff8d97be5236f8dd4a09ec70cc86c67ed5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 10 Sep 2025 16:46:05 +0300 Subject: [PATCH 290/581] crypto/machine: log when loading olm account --- crypto/machine.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crypto/machine.go b/crypto/machine.go index e791e70d..83ce024d 100644 --- a/crypto/machine.go +++ b/crypto/machine.go @@ -156,6 +156,10 @@ func (mach *OlmMachine) Load(ctx context.Context) (err error) { if mach.account == nil { mach.account = NewOlmAccount() } + zerolog.Ctx(ctx).Debug(). + Str("machine_ptr", fmt.Sprintf("%p", mach)). + Str("account_ptr", fmt.Sprintf("%p", mach.account.Internal)). + Msg("Loaded olm account") return nil } From bdb9e22a4372ed8a262b42238748a8670bacaa52 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 11 Sep 2025 13:22:45 +0300 Subject: [PATCH 291/581] crypto/libolm: clean up pointer management --- crypto/libolm/account.go | 73 +++++++++++++++++---------- crypto/libolm/inboundgroupsession.go | 60 +++++++++++++--------- crypto/libolm/outboundgroupsession.go | 44 ++++++++++------ crypto/libolm/pk.go | 63 +++++++++++++++++------ crypto/libolm/register.go | 12 +++-- crypto/libolm/session.go | 68 ++++++++++++++++--------- 6 files changed, 212 insertions(+), 108 deletions(-) diff --git a/crypto/libolm/account.go b/crypto/libolm/account.go index cddce7ce..a2212ccc 100644 --- a/crypto/libolm/account.go +++ b/crypto/libolm/account.go @@ -8,6 +8,7 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" + "runtime" "unsafe" "github.com/tidwall/gjson" @@ -53,7 +54,7 @@ func AccountFromPickled(pickled, key []byte) (*Account, error) { func NewBlankAccount() *Account { memory := make([]byte, accountSize()) return &Account{ - int: C.olm_account(unsafe.Pointer(&memory[0])), + int: C.olm_account(unsafe.Pointer(unsafe.SliceData(memory))), mem: memory, } } @@ -68,8 +69,9 @@ func NewAccount() (*Account, error) { } ret := C.olm_create_account( (*C.OlmAccount)(a.int), - unsafe.Pointer(&random[0]), + unsafe.Pointer(unsafe.SliceData(random)), C.size_t(len(random))) + runtime.KeepAlive(random) if ret == errorVal() { return nil, a.lastError() } else { @@ -143,9 +145,9 @@ func (a *Account) Pickle(key []byte) ([]byte, error) { pickled := make([]byte, a.pickleLen()) r := C.olm_pickle_account( (*C.OlmAccount)(a.int), - unsafe.Pointer(&key[0]), + unsafe.Pointer(unsafe.SliceData(key)), C.size_t(len(key)), - unsafe.Pointer(&pickled[0]), + unsafe.Pointer(unsafe.SliceData(pickled)), C.size_t(len(pickled))) if r == errorVal() { return nil, a.lastError() @@ -159,9 +161,9 @@ func (a *Account) Unpickle(pickled, key []byte) error { } r := C.olm_unpickle_account( (*C.OlmAccount)(a.int), - unsafe.Pointer(&key[0]), + unsafe.Pointer(unsafe.SliceData(key)), C.size_t(len(key)), - unsafe.Pointer(&pickled[0]), + unsafe.Pointer(unsafe.SliceData(pickled)), C.size_t(len(pickled))) if r == errorVal() { return a.lastError() @@ -221,7 +223,7 @@ func (a *Account) IdentityKeysJSON() ([]byte, error) { identityKeys := make([]byte, a.identityKeysLen()) r := C.olm_account_identity_keys( (*C.OlmAccount)(a.int), - unsafe.Pointer(&identityKeys[0]), + unsafe.Pointer(unsafe.SliceData(identityKeys)), C.size_t(len(identityKeys))) if r == errorVal() { return nil, a.lastError() @@ -250,10 +252,11 @@ func (a *Account) Sign(message []byte) ([]byte, error) { signature := make([]byte, a.signatureLen()) r := C.olm_account_sign( (*C.OlmAccount)(a.int), - unsafe.Pointer(&message[0]), + unsafe.Pointer(unsafe.SliceData(message)), C.size_t(len(message)), - unsafe.Pointer(&signature[0]), + unsafe.Pointer(unsafe.SliceData(signature)), C.size_t(len(signature))) + runtime.KeepAlive(message) if r == errorVal() { panic(a.lastError()) } @@ -277,8 +280,9 @@ func (a *Account) OneTimeKeys() (map[string]id.Curve25519, error) { oneTimeKeysJSON := make([]byte, a.oneTimeKeysLen()) r := C.olm_account_one_time_keys( (*C.OlmAccount)(a.int), - unsafe.Pointer(&oneTimeKeysJSON[0]), - C.size_t(len(oneTimeKeysJSON))) + unsafe.Pointer(unsafe.SliceData(oneTimeKeysJSON)), + C.size_t(len(oneTimeKeysJSON)), + ) if r == errorVal() { return nil, a.lastError() } @@ -312,8 +316,10 @@ func (a *Account) GenOneTimeKeys(num uint) error { r := C.olm_account_generate_one_time_keys( (*C.OlmAccount)(a.int), C.size_t(num), - unsafe.Pointer(&random[0]), - C.size_t(len(random))) + unsafe.Pointer(unsafe.SliceData(random)), + C.size_t(len(random)), + ) + runtime.KeepAlive(random) if r == errorVal() { return a.lastError() } @@ -333,15 +339,21 @@ func (a *Account) NewOutboundSession(theirIdentityKey, theirOneTimeKey id.Curve2 if err != nil { panic(olm.NotEnoughGoRandom) } + theirIdentityKeyCopy := []byte(theirIdentityKey) + theirOneTimeKeyCopy := []byte(theirOneTimeKey) r := C.olm_create_outbound_session( (*C.OlmSession)(s.int), (*C.OlmAccount)(a.int), - unsafe.Pointer(&([]byte(theirIdentityKey)[0])), - C.size_t(len(theirIdentityKey)), - unsafe.Pointer(&([]byte(theirOneTimeKey)[0])), - C.size_t(len(theirOneTimeKey)), - unsafe.Pointer(&random[0]), - C.size_t(len(random))) + unsafe.Pointer(unsafe.SliceData(theirIdentityKeyCopy)), + C.size_t(len(theirIdentityKeyCopy)), + unsafe.Pointer(unsafe.SliceData(theirOneTimeKeyCopy)), + C.size_t(len(theirOneTimeKeyCopy)), + unsafe.Pointer(unsafe.SliceData(random)), + C.size_t(len(random)), + ) + runtime.KeepAlive(random) + runtime.KeepAlive(theirIdentityKeyCopy) + runtime.KeepAlive(theirOneTimeKeyCopy) if r == errorVal() { return nil, s.lastError() } @@ -360,11 +372,14 @@ func (a *Account) NewInboundSession(oneTimeKeyMsg string) (olm.Session, error) { return nil, olm.EmptyInput } s := NewBlankSession() + oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) r := C.olm_create_inbound_session( (*C.OlmSession)(s.int), (*C.OlmAccount)(a.int), - unsafe.Pointer(&([]byte(oneTimeKeyMsg)[0])), - C.size_t(len(oneTimeKeyMsg))) + unsafe.Pointer(unsafe.SliceData(oneTimeKeyMsgCopy)), + C.size_t(len(oneTimeKeyMsgCopy)), + ) + runtime.KeepAlive(oneTimeKeyMsgCopy) if r == errorVal() { return nil, s.lastError() } @@ -382,14 +397,19 @@ func (a *Account) NewInboundSessionFrom(theirIdentityKey *id.Curve25519, oneTime if theirIdentityKey == nil || len(oneTimeKeyMsg) == 0 { return nil, olm.EmptyInput } + theirIdentityKeyCopy := []byte(*theirIdentityKey) + oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) s := NewBlankSession() r := C.olm_create_inbound_session_from( (*C.OlmSession)(s.int), (*C.OlmAccount)(a.int), - unsafe.Pointer(&([]byte(*theirIdentityKey)[0])), - C.size_t(len(*theirIdentityKey)), - unsafe.Pointer(&([]byte(oneTimeKeyMsg)[0])), - C.size_t(len(oneTimeKeyMsg))) + unsafe.Pointer(unsafe.SliceData(theirIdentityKeyCopy)), + C.size_t(len(theirIdentityKeyCopy)), + unsafe.Pointer(unsafe.SliceData(oneTimeKeyMsgCopy)), + C.size_t(len(oneTimeKeyMsgCopy)), + ) + runtime.KeepAlive(theirIdentityKeyCopy) + runtime.KeepAlive(oneTimeKeyMsgCopy) if r == errorVal() { return nil, s.lastError() } @@ -402,7 +422,8 @@ func (a *Account) NewInboundSessionFrom(theirIdentityKey *id.Curve25519, oneTime func (a *Account) RemoveOneTimeKeys(s olm.Session) error { r := C.olm_remove_one_time_keys( (*C.OlmAccount)(a.int), - (*C.OlmSession)(s.(*Session).int)) + (*C.OlmSession)(s.(*Session).int), + ) if r == errorVal() { return a.lastError() } diff --git a/crypto/libolm/inboundgroupsession.go b/crypto/libolm/inboundgroupsession.go index 1e25748d..d7912a7a 100644 --- a/crypto/libolm/inboundgroupsession.go +++ b/crypto/libolm/inboundgroupsession.go @@ -7,6 +7,7 @@ import "C" import ( "bytes" "encoding/base64" + "runtime" "unsafe" "maunium.net/go/mautrix/crypto/olm" @@ -67,8 +68,10 @@ func NewInboundGroupSession(sessionKey []byte) (*InboundGroupSession, error) { s := NewBlankInboundGroupSession() r := C.olm_init_inbound_group_session( (*C.OlmInboundGroupSession)(s.int), - (*C.uint8_t)(&sessionKey[0]), - C.size_t(len(sessionKey))) + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionKey))), + C.size_t(len(sessionKey)), + ) + runtime.KeepAlive(sessionKey) if r == errorVal() { return nil, s.lastError() } @@ -86,8 +89,10 @@ func InboundGroupSessionImport(sessionKey []byte) (*InboundGroupSession, error) s := NewBlankInboundGroupSession() r := C.olm_import_inbound_group_session( (*C.OlmInboundGroupSession)(s.int), - (*C.uint8_t)(&sessionKey[0]), - C.size_t(len(sessionKey))) + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionKey))), + C.size_t(len(sessionKey)), + ) + runtime.KeepAlive(sessionKey) if r == errorVal() { return nil, s.lastError() } @@ -104,7 +109,7 @@ func inboundGroupSessionSize() uint { func NewBlankInboundGroupSession() *InboundGroupSession { memory := make([]byte, inboundGroupSessionSize()) return &InboundGroupSession{ - int: C.olm_inbound_group_session(unsafe.Pointer(&memory[0])), + int: C.olm_inbound_group_session(unsafe.Pointer(unsafe.SliceData(memory))), mem: memory, } } @@ -139,10 +144,12 @@ func (s *InboundGroupSession) Pickle(key []byte) ([]byte, error) { pickled := make([]byte, s.pickleLen()) r := C.olm_pickle_inbound_group_session( (*C.OlmInboundGroupSession)(s.int), - unsafe.Pointer(&key[0]), + unsafe.Pointer(unsafe.SliceData(key)), C.size_t(len(key)), - unsafe.Pointer(&pickled[0]), - C.size_t(len(pickled))) + unsafe.Pointer(unsafe.SliceData(pickled)), + C.size_t(len(pickled)), + ) + runtime.KeepAlive(key) if r == errorVal() { return nil, s.lastError() } @@ -157,10 +164,12 @@ func (s *InboundGroupSession) Unpickle(pickled, key []byte) error { } r := C.olm_unpickle_inbound_group_session( (*C.OlmInboundGroupSession)(s.int), - unsafe.Pointer(&key[0]), + unsafe.Pointer(unsafe.SliceData(key)), C.size_t(len(key)), - unsafe.Pointer(&pickled[0]), - C.size_t(len(pickled))) + unsafe.Pointer(unsafe.SliceData(pickled)), + C.size_t(len(pickled)), + ) + runtime.KeepAlive(key) if r == errorVal() { return s.lastError() } @@ -226,11 +235,13 @@ func (s *InboundGroupSession) decryptMaxPlaintextLen(message []byte) (uint, erro return 0, olm.EmptyInput } // olm_group_decrypt_max_plaintext_length destroys the input, so we have to clone it - message = bytes.Clone(message) + messageCopy := bytes.Clone(message) r := C.olm_group_decrypt_max_plaintext_length( (*C.OlmInboundGroupSession)(s.int), - (*C.uint8_t)(&message[0]), - C.size_t(len(message))) + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(messageCopy))), + C.size_t(len(messageCopy)), + ) + runtime.KeepAlive(messageCopy) if r == errorVal() { return 0, s.lastError() } @@ -254,17 +265,18 @@ func (s *InboundGroupSession) Decrypt(message []byte) ([]byte, uint, error) { if err != nil { return nil, 0, err } - messageCopy := make([]byte, len(message)) - copy(messageCopy, message) + messageCopy := bytes.Clone(message) plaintext := make([]byte, decryptMaxPlaintextLen) var messageIndex uint32 r := C.olm_group_decrypt( (*C.OlmInboundGroupSession)(s.int), - (*C.uint8_t)(&messageCopy[0]), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(messageCopy))), C.size_t(len(messageCopy)), - (*C.uint8_t)(&plaintext[0]), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(plaintext))), C.size_t(len(plaintext)), - (*C.uint32_t)(&messageIndex)) + (*C.uint32_t)(unsafe.Pointer(&messageIndex)), + ) + runtime.KeepAlive(messageCopy) if r == errorVal() { return nil, 0, s.lastError() } @@ -281,8 +293,9 @@ func (s *InboundGroupSession) ID() id.SessionID { sessionID := make([]byte, s.sessionIdLen()) r := C.olm_inbound_group_session_id( (*C.OlmInboundGroupSession)(s.int), - (*C.uint8_t)(&sessionID[0]), - C.size_t(len(sessionID))) + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionID))), + C.size_t(len(sessionID)), + ) if r == errorVal() { panic(s.lastError()) } @@ -318,9 +331,10 @@ func (s *InboundGroupSession) Export(messageIndex uint32) ([]byte, error) { key := make([]byte, s.exportLen()) r := C.olm_export_inbound_group_session( (*C.OlmInboundGroupSession)(s.int), - (*C.uint8_t)(&key[0]), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(key))), C.size_t(len(key)), - C.uint32_t(messageIndex)) + C.uint32_t(messageIndex), + ) if r == errorVal() { return nil, s.lastError() } diff --git a/crypto/libolm/outboundgroupsession.go b/crypto/libolm/outboundgroupsession.go index a21f8d4a..94df66d7 100644 --- a/crypto/libolm/outboundgroupsession.go +++ b/crypto/libolm/outboundgroupsession.go @@ -7,6 +7,7 @@ import "C" import ( "crypto/rand" "encoding/base64" + "runtime" "unsafe" "maunium.net/go/mautrix/crypto/olm" @@ -44,8 +45,10 @@ func NewOutboundGroupSession() (*OutboundGroupSession, error) { } r := C.olm_init_outbound_group_session( (*C.OlmOutboundGroupSession)(s.int), - (*C.uint8_t)(&random[0]), - C.size_t(len(random))) + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(random))), + C.size_t(len(random)), + ) + runtime.KeepAlive(random) if r == errorVal() { return nil, s.lastError() } @@ -62,7 +65,7 @@ func outboundGroupSessionSize() uint { func NewBlankOutboundGroupSession() *OutboundGroupSession { memory := make([]byte, outboundGroupSessionSize()) return &OutboundGroupSession{ - int: C.olm_outbound_group_session(unsafe.Pointer(&memory[0])), + int: C.olm_outbound_group_session(unsafe.Pointer(unsafe.SliceData(memory))), mem: memory, } } @@ -98,10 +101,12 @@ func (s *OutboundGroupSession) Pickle(key []byte) ([]byte, error) { pickled := make([]byte, s.pickleLen()) r := C.olm_pickle_outbound_group_session( (*C.OlmOutboundGroupSession)(s.int), - unsafe.Pointer(&key[0]), + unsafe.Pointer(unsafe.SliceData(key)), C.size_t(len(key)), - unsafe.Pointer(&pickled[0]), - C.size_t(len(pickled))) + unsafe.Pointer(unsafe.SliceData(pickled)), + C.size_t(len(pickled)), + ) + runtime.KeepAlive(key) if r == errorVal() { return nil, s.lastError() } @@ -114,10 +119,13 @@ func (s *OutboundGroupSession) Unpickle(pickled, key []byte) error { } r := C.olm_unpickle_outbound_group_session( (*C.OlmOutboundGroupSession)(s.int), - unsafe.Pointer(&key[0]), + unsafe.Pointer(unsafe.SliceData(key)), C.size_t(len(key)), - unsafe.Pointer(&pickled[0]), - C.size_t(len(pickled))) + unsafe.Pointer(unsafe.SliceData(pickled)), + C.size_t(len(pickled)), + ) + runtime.KeepAlive(pickled) + runtime.KeepAlive(key) if r == errorVal() { return s.lastError() } @@ -192,10 +200,12 @@ func (s *OutboundGroupSession) Encrypt(plaintext []byte) ([]byte, error) { message := make([]byte, s.encryptMsgLen(len(plaintext))) r := C.olm_group_encrypt( (*C.OlmOutboundGroupSession)(s.int), - (*C.uint8_t)(&plaintext[0]), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(plaintext))), C.size_t(len(plaintext)), - (*C.uint8_t)(&message[0]), - C.size_t(len(message))) + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(message))), + C.size_t(len(message)), + ) + runtime.KeepAlive(plaintext) if r == errorVal() { return nil, s.lastError() } @@ -212,8 +222,9 @@ func (s *OutboundGroupSession) ID() id.SessionID { sessionID := make([]byte, s.sessionIdLen()) r := C.olm_outbound_group_session_id( (*C.OlmOutboundGroupSession)(s.int), - (*C.uint8_t)(&sessionID[0]), - C.size_t(len(sessionID))) + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionID))), + C.size_t(len(sessionID)), + ) if r == errorVal() { panic(s.lastError()) } @@ -236,8 +247,9 @@ func (s *OutboundGroupSession) Key() string { sessionKey := make([]byte, s.sessionKeyLen()) r := C.olm_outbound_group_session_key( (*C.OlmOutboundGroupSession)(s.int), - (*C.uint8_t)(&sessionKey[0]), - C.size_t(len(sessionKey))) + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionKey))), + C.size_t(len(sessionKey)), + ) if r == errorVal() { panic(s.lastError()) } diff --git a/crypto/libolm/pk.go b/crypto/libolm/pk.go index db8d35c5..172f4191 100644 --- a/crypto/libolm/pk.go +++ b/crypto/libolm/pk.go @@ -14,6 +14,7 @@ import "C" import ( "crypto/rand" "encoding/json" + "runtime" "unsafe" "github.com/tidwall/sjson" @@ -63,7 +64,7 @@ func pkSigningSignatureLength() uint { func newBlankPKSigning() *PKSigning { memory := make([]byte, pkSigningSize()) return &PKSigning{ - int: C.olm_pk_signing(unsafe.Pointer(&memory[0])), + int: C.olm_pk_signing(unsafe.Pointer(unsafe.SliceData(memory))), mem: memory, } } @@ -73,9 +74,14 @@ func NewPKSigningFromSeed(seed []byte) (*PKSigning, error) { p := newBlankPKSigning() p.clear() pubKey := make([]byte, pkSigningPublicKeyLength()) - if C.olm_pk_signing_key_from_seed((*C.OlmPkSigning)(p.int), - unsafe.Pointer(&pubKey[0]), C.size_t(len(pubKey)), - unsafe.Pointer(&seed[0]), C.size_t(len(seed))) == errorVal() { + r := C.olm_pk_signing_key_from_seed( + (*C.OlmPkSigning)(p.int), + unsafe.Pointer(unsafe.SliceData(pubKey)), + C.size_t(len(pubKey)), + unsafe.Pointer(unsafe.SliceData(seed)), + C.size_t(len(seed)), + ) + if r == errorVal() { return nil, p.lastError() } p.publicKey = id.Ed25519(pubKey) @@ -112,8 +118,15 @@ func (p *PKSigning) clear() { // Sign creates a signature for the given message using this key. func (p *PKSigning) Sign(message []byte) ([]byte, error) { signature := make([]byte, pkSigningSignatureLength()) - if C.olm_pk_sign((*C.OlmPkSigning)(p.int), (*C.uint8_t)(unsafe.Pointer(&message[0])), C.size_t(len(message)), - (*C.uint8_t)(unsafe.Pointer(&signature[0])), C.size_t(len(signature))) == errorVal() { + r := C.olm_pk_sign( + (*C.OlmPkSigning)(p.int), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(message))), + C.size_t(len(message)), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(signature))), + C.size_t(len(signature)), + ) + runtime.KeepAlive(message) + if r == errorVal() { return nil, p.lastError() } return signature, nil @@ -157,15 +170,21 @@ func pkDecryptionPublicKeySize() uint { func NewPkDecryption(privateKey []byte) (*PKDecryption, error) { memory := make([]byte, pkDecryptionSize()) p := &PKDecryption{ - int: C.olm_pk_decryption(unsafe.Pointer(&memory[0])), + int: C.olm_pk_decryption(unsafe.Pointer(unsafe.SliceData(memory))), mem: memory, } p.clear() pubKey := make([]byte, pkDecryptionPublicKeySize()) - if C.olm_pk_key_from_private((*C.OlmPkDecryption)(p.int), - unsafe.Pointer(&pubKey[0]), C.size_t(len(pubKey)), - unsafe.Pointer(&privateKey[0]), C.size_t(len(privateKey))) == errorVal() { + r := C.olm_pk_key_from_private( + (*C.OlmPkDecryption)(p.int), + unsafe.Pointer(unsafe.SliceData(pubKey)), + C.size_t(len(pubKey)), + unsafe.Pointer(unsafe.SliceData(privateKey)), + C.size_t(len(privateKey)), + ) + runtime.KeepAlive(privateKey) + if r == errorVal() { return nil, p.lastError() } p.publicKey = pubKey @@ -178,14 +197,26 @@ func (p *PKDecryption) PublicKey() id.Curve25519 { } func (p *PKDecryption) Decrypt(ephemeralKey []byte, mac []byte, ciphertext []byte) ([]byte, error) { - maxPlaintextLength := uint(C.olm_pk_max_plaintext_length((*C.OlmPkDecryption)(p.int), C.size_t(len(ciphertext)))) + maxPlaintextLength := uint(C.olm_pk_max_plaintext_length( + (*C.OlmPkDecryption)(p.int), + C.size_t(len(ciphertext)), + )) plaintext := make([]byte, maxPlaintextLength) - size := C.olm_pk_decrypt((*C.OlmPkDecryption)(p.int), - unsafe.Pointer(&ephemeralKey[0]), C.size_t(len(ephemeralKey)), - unsafe.Pointer(&mac[0]), C.size_t(len(mac)), - unsafe.Pointer(&ciphertext[0]), C.size_t(len(ciphertext)), - unsafe.Pointer(&plaintext[0]), C.size_t(len(plaintext))) + size := C.olm_pk_decrypt( + (*C.OlmPkDecryption)(p.int), + unsafe.Pointer(unsafe.SliceData(ephemeralKey)), + C.size_t(len(ephemeralKey)), + unsafe.Pointer(unsafe.SliceData(mac)), + C.size_t(len(mac)), + unsafe.Pointer(unsafe.SliceData(ciphertext)), + C.size_t(len(ciphertext)), + unsafe.Pointer(unsafe.SliceData(plaintext)), + C.size_t(len(plaintext)), + ) + runtime.KeepAlive(ephemeralKey) + runtime.KeepAlive(mac) + runtime.KeepAlive(ciphertext) if size == errorVal() { return nil, p.lastError() } diff --git a/crypto/libolm/register.go b/crypto/libolm/register.go index a423a7d0..6aaec61e 100644 --- a/crypto/libolm/register.go +++ b/crypto/libolm/register.go @@ -3,16 +3,20 @@ package libolm // #cgo LDFLAGS: -lolm -lstdc++ // #include import "C" -import "maunium.net/go/mautrix/crypto/olm" +import ( + "unsafe" + + "maunium.net/go/mautrix/crypto/olm" +) var pickleKey = []byte("maunium.net/go/mautrix/crypto/olm") func init() { olm.GetVersion = func() (major, minor, patch uint8) { C.olm_get_library_version( - (*C.uint8_t)(&major), - (*C.uint8_t)(&minor), - (*C.uint8_t)(&patch)) + (*C.uint8_t)(unsafe.Pointer(&major)), + (*C.uint8_t)(unsafe.Pointer(&minor)), + (*C.uint8_t)(unsafe.Pointer(&patch))) return 3, 2, 15 } olm.SetPickleKeyImpl = func(key []byte) { diff --git a/crypto/libolm/session.go b/crypto/libolm/session.go index 4cc22809..810dc7a6 100644 --- a/crypto/libolm/session.go +++ b/crypto/libolm/session.go @@ -23,6 +23,7 @@ import "C" import ( "crypto/rand" "encoding/base64" + "runtime" "unsafe" "maunium.net/go/mautrix/crypto/olm" @@ -68,7 +69,7 @@ func SessionFromPickled(pickled, key []byte) (*Session, error) { func NewBlankSession() *Session { memory := make([]byte, sessionSize()) return &Session{ - int: C.olm_session(unsafe.Pointer(&memory[0])), + int: C.olm_session(unsafe.Pointer(unsafe.SliceData(memory))), mem: memory, } } @@ -128,11 +129,14 @@ func (s *Session) decryptMaxPlaintextLen(message string, msgType id.OlmMsgType) if len(message) == 0 { return 0, olm.EmptyInput } + messageCopy := []byte(message) r := C.olm_decrypt_max_plaintext_length( (*C.OlmSession)(s.int), C.size_t(msgType), - unsafe.Pointer(C.CString(message)), - C.size_t(len(message))) + unsafe.Pointer(unsafe.SliceData((messageCopy))), + C.size_t(len(messageCopy)), + ) + runtime.KeepAlive(messageCopy) if r == errorVal() { return 0, s.lastError() } @@ -148,10 +152,11 @@ func (s *Session) Pickle(key []byte) ([]byte, error) { pickled := make([]byte, s.pickleLen()) r := C.olm_pickle_session( (*C.OlmSession)(s.int), - unsafe.Pointer(&key[0]), + unsafe.Pointer(unsafe.SliceData(key)), C.size_t(len(key)), - unsafe.Pointer(&pickled[0]), + unsafe.Pointer(unsafe.SliceData(pickled)), C.size_t(len(pickled))) + runtime.KeepAlive(key) if r == errorVal() { panic(s.lastError()) } @@ -166,10 +171,12 @@ func (s *Session) Unpickle(pickled, key []byte) error { } r := C.olm_unpickle_session( (*C.OlmSession)(s.int), - unsafe.Pointer(&key[0]), + unsafe.Pointer(unsafe.SliceData(key)), C.size_t(len(key)), - unsafe.Pointer(&pickled[0]), + unsafe.Pointer(unsafe.SliceData(pickled)), C.size_t(len(pickled))) + runtime.KeepAlive(pickled) + runtime.KeepAlive(key) if r == errorVal() { return s.lastError() } @@ -229,8 +236,9 @@ func (s *Session) ID() id.SessionID { sessionID := make([]byte, s.idLen()) r := C.olm_session_id( (*C.OlmSession)(s.int), - unsafe.Pointer(&sessionID[0]), - C.size_t(len(sessionID))) + unsafe.Pointer(unsafe.SliceData(sessionID)), + C.size_t(len(sessionID)), + ) if r == errorVal() { panic(s.lastError()) } @@ -259,10 +267,13 @@ func (s *Session) MatchesInboundSession(oneTimeKeyMsg string) (bool, error) { if len(oneTimeKeyMsg) == 0 { return false, olm.EmptyInput } + oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) r := C.olm_matches_inbound_session( (*C.OlmSession)(s.int), - unsafe.Pointer(&([]byte(oneTimeKeyMsg))[0]), - C.size_t(len(oneTimeKeyMsg))) + unsafe.Pointer(unsafe.SliceData(oneTimeKeyMsgCopy)), + C.size_t(len(oneTimeKeyMsgCopy)), + ) + runtime.KeepAlive(oneTimeKeyMsgCopy) if r == 1 { return true, nil } else if r == 0 { @@ -284,12 +295,17 @@ func (s *Session) MatchesInboundSessionFrom(theirIdentityKey, oneTimeKeyMsg stri if len(theirIdentityKey) == 0 || len(oneTimeKeyMsg) == 0 { return false, olm.EmptyInput } + theirIdentityKeyCopy := []byte(theirIdentityKey) + oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) r := C.olm_matches_inbound_session_from( (*C.OlmSession)(s.int), - unsafe.Pointer(&([]byte(theirIdentityKey))[0]), - C.size_t(len(theirIdentityKey)), - unsafe.Pointer(&([]byte(oneTimeKeyMsg))[0]), - C.size_t(len(oneTimeKeyMsg))) + unsafe.Pointer(unsafe.SliceData(theirIdentityKeyCopy)), + C.size_t(len(theirIdentityKeyCopy)), + unsafe.Pointer(unsafe.SliceData(oneTimeKeyMsgCopy)), + C.size_t(len(oneTimeKeyMsgCopy)), + ) + runtime.KeepAlive(theirIdentityKeyCopy) + runtime.KeepAlive(oneTimeKeyMsgCopy) if r == 1 { return true, nil } else if r == 0 { @@ -331,12 +347,15 @@ func (s *Session) Encrypt(plaintext []byte) (id.OlmMsgType, []byte, error) { message := make([]byte, s.encryptMsgLen(len(plaintext))) r := C.olm_encrypt( (*C.OlmSession)(s.int), - unsafe.Pointer(&plaintext[0]), + unsafe.Pointer(unsafe.SliceData(plaintext)), C.size_t(len(plaintext)), - unsafe.Pointer(&random[0]), + unsafe.Pointer(unsafe.SliceData(random)), C.size_t(len(random)), - unsafe.Pointer(&message[0]), - C.size_t(len(message))) + unsafe.Pointer(unsafe.SliceData(message)), + C.size_t(len(message)), + ) + runtime.KeepAlive(plaintext) + runtime.KeepAlive(random) if r == errorVal() { return 0, nil, s.lastError() } @@ -363,10 +382,12 @@ func (s *Session) Decrypt(message string, msgType id.OlmMsgType) ([]byte, error) r := C.olm_decrypt( (*C.OlmSession)(s.int), C.size_t(msgType), - unsafe.Pointer(&(messageCopy)[0]), + unsafe.Pointer(unsafe.SliceData(messageCopy)), C.size_t(len(messageCopy)), - unsafe.Pointer(&plaintext[0]), - C.size_t(len(plaintext))) + unsafe.Pointer(unsafe.SliceData(plaintext)), + C.size_t(len(plaintext)), + ) + runtime.KeepAlive(messageCopy) if r == errorVal() { return nil, s.lastError() } @@ -383,6 +404,7 @@ func (s *Session) Describe() string { C.meowlm_session_describe( (*C.OlmSession)(s.int), desc, - C.size_t(maxDescribeSize)) + C.size_t(maxDescribeSize), + ) return C.GoString(desc) } From 69869f7cb502a833ca87ee13fcd78303d69f9b4e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 11 Sep 2025 14:12:35 +0300 Subject: [PATCH 292/581] crypto: log active driver --- crypto/goolm/register.go | 2 ++ crypto/libolm/register.go | 2 ++ crypto/machine.go | 2 ++ crypto/olm/account.go | 2 ++ 4 files changed, 8 insertions(+) diff --git a/crypto/goolm/register.go b/crypto/goolm/register.go index 80ed206b..17e7f207 100644 --- a/crypto/goolm/register.go +++ b/crypto/goolm/register.go @@ -16,6 +16,8 @@ import ( ) func init() { + olm.Driver = "goolm" + olm.GetVersion = func() (major, minor, patch uint8) { return 3, 2, 15 } diff --git a/crypto/libolm/register.go b/crypto/libolm/register.go index 6aaec61e..06c07ea8 100644 --- a/crypto/libolm/register.go +++ b/crypto/libolm/register.go @@ -12,6 +12,8 @@ import ( var pickleKey = []byte("maunium.net/go/mautrix/crypto/olm") func init() { + olm.Driver = "libolm" + olm.GetVersion = func() (major, minor, patch uint8) { C.olm_get_library_version( (*C.uint8_t)(unsafe.Pointer(&major)), diff --git a/crypto/machine.go b/crypto/machine.go index 83ce024d..da3ebe67 100644 --- a/crypto/machine.go +++ b/crypto/machine.go @@ -19,6 +19,7 @@ import ( "go.mau.fi/util/exzerolog" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/olm" "maunium.net/go/mautrix/crypto/ssss" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -159,6 +160,7 @@ func (mach *OlmMachine) Load(ctx context.Context) (err error) { zerolog.Ctx(ctx).Debug(). Str("machine_ptr", fmt.Sprintf("%p", mach)). Str("account_ptr", fmt.Sprintf("%p", mach.account.Internal)). + Str("olm_driver", olm.Driver). Msg("Loaded olm account") return nil } diff --git a/crypto/olm/account.go b/crypto/olm/account.go index 68393e8a..2ec5dd70 100644 --- a/crypto/olm/account.go +++ b/crypto/olm/account.go @@ -87,6 +87,8 @@ type Account interface { RemoveOneTimeKeys(s Session) error } +var Driver = "none" + var InitBlankAccount func() Account var InitNewAccount func() (Account, error) var InitNewAccountFromPickled func(pickled, key []byte) (Account, error) From 84e5d6bda1dfa792bc4098a6cf41ae25527c83e5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 11 Sep 2025 14:13:18 +0300 Subject: [PATCH 293/581] crypto/machine: allow canceling background context --- crypto/decryptolm.go | 3 ++- crypto/machine.go | 21 ++++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crypto/decryptolm.go b/crypto/decryptolm.go index f54210a7..bd9f1753 100644 --- a/crypto/decryptolm.go +++ b/crypto/decryptolm.go @@ -272,6 +272,7 @@ func (mach *OlmMachine) tryDecryptOlmCiphertextWithExistingSession( if err != nil { log.Warn().Err(err). Hex("ciphertext_hash", ciphertextHash[:]). + Hex("ciphertext_hash_repeat", ptr.Ptr(exerrors.Must(olmMessageHash(ciphertext)))[:]). Str("session_description", session.Describe()). Msg("Failed to decrypt olm message") if olmType == id.OlmMsgTypePreKey { @@ -315,7 +316,7 @@ const MinUnwedgeInterval = 1 * time.Hour func (mach *OlmMachine) unwedgeDevice(log zerolog.Logger, sender id.UserID, senderKey id.SenderKey) { log = log.With().Str("action", "unwedge olm session").Logger() - ctx := log.WithContext(mach.BackgroundCtx) + ctx := log.WithContext(mach.backgroundCtx) mach.recentlyUnwedgedLock.Lock() prevUnwedge, ok := mach.recentlyUnwedged[senderKey] delta := time.Now().Sub(prevUnwedge) diff --git a/crypto/machine.go b/crypto/machine.go index da3ebe67..eb238922 100644 --- a/crypto/machine.go +++ b/crypto/machine.go @@ -15,6 +15,7 @@ import ( "time" "github.com/rs/zerolog" + "go.mau.fi/util/ptr" "go.mau.fi/util/exzerolog" @@ -34,7 +35,8 @@ type OlmMachine struct { CryptoStore Store StateStore StateStore - BackgroundCtx context.Context + backgroundCtx context.Context + cancelBackgroundCtx context.CancelFunc PlaintextMentions bool AllowEncryptedState bool @@ -121,8 +123,6 @@ func NewOlmMachine(client *mautrix.Client, log *zerolog.Logger, cryptoStore Stor CryptoStore: cryptoStore, StateStore: stateStore, - BackgroundCtx: context.Background(), - SendKeysMinTrust: id.TrustStateUnset, ShareKeysMinTrust: id.TrustStateCrossSignedTOFU, @@ -135,6 +135,7 @@ func NewOlmMachine(client *mautrix.Client, log *zerolog.Logger, cryptoStore Stor recentlyUnwedged: make(map[id.IdentityKey]time.Time), secretListeners: make(map[string]chan<- string), } + mach.backgroundCtx, mach.cancelBackgroundCtx = context.WithCancel(context.Background()) mach.AllowKeyShare = mach.defaultAllowKeyShare return mach } @@ -147,6 +148,11 @@ func (mach *OlmMachine) machOrContextLog(ctx context.Context) *zerolog.Logger { return log } +func (mach *OlmMachine) SetBackgroundCtx(ctx context.Context) { + mach.cancelBackgroundCtx() + mach.backgroundCtx, mach.cancelBackgroundCtx = context.WithCancel(ctx) +} + // Load loads the Olm account information from the crypto store. If there's no olm account, a new one is created. // This must be called before using the machine. func (mach *OlmMachine) Load(ctx context.Context) (err error) { @@ -165,6 +171,15 @@ func (mach *OlmMachine) Load(ctx context.Context) (err error) { return nil } +func (mach *OlmMachine) Destroy() { + mach.Log.Debug(). + Str("machine_ptr", fmt.Sprintf("%p", mach)). + Str("account_ptr", fmt.Sprintf("%p", ptr.Val(mach.account).Internal)). + Msg("Destroying olm machine") + mach.cancelBackgroundCtx() + mach.account = nil +} + func (mach *OlmMachine) saveAccount(ctx context.Context) error { err := mach.CryptoStore.PutAccount(ctx, mach.account) if err != nil { From c716f30959c011246078c077e00fcf858353af12 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 11 Sep 2025 14:14:08 +0300 Subject: [PATCH 294/581] crypto/register: don't use init in *olm packages --- crypto/goolm/account/register.go | 2 +- crypto/goolm/pk/register.go | 2 +- crypto/goolm/register.go | 14 ++++---- crypto/goolm/session/register.go | 2 +- crypto/libolm/account.go | 12 ------- crypto/libolm/inboundgroupsession.go | 15 -------- crypto/libolm/outboundgroupsession.go | 12 ------- crypto/libolm/pk.go | 10 ------ crypto/libolm/register.go | 50 ++++++++++++++++++++++++++- crypto/libolm/session.go | 9 ----- crypto/registergoolm.go | 8 ++++- crypto/registerlibolm.go | 6 +++- 12 files changed, 72 insertions(+), 70 deletions(-) diff --git a/crypto/goolm/account/register.go b/crypto/goolm/account/register.go index c6b9e523..ec392d7e 100644 --- a/crypto/goolm/account/register.go +++ b/crypto/goolm/account/register.go @@ -10,7 +10,7 @@ import ( "maunium.net/go/mautrix/crypto/olm" ) -func init() { +func Register() { olm.InitNewAccount = func() (olm.Account, error) { return NewAccount() } diff --git a/crypto/goolm/pk/register.go b/crypto/goolm/pk/register.go index b7af6a5b..0e27b568 100644 --- a/crypto/goolm/pk/register.go +++ b/crypto/goolm/pk/register.go @@ -8,7 +8,7 @@ package pk import "maunium.net/go/mautrix/crypto/olm" -func init() { +func Register() { olm.InitNewPKSigningFromSeed = func(seed []byte) (olm.PKSigning, error) { return NewSigningFromSeed(seed) } diff --git a/crypto/goolm/register.go b/crypto/goolm/register.go index 17e7f207..800f567f 100644 --- a/crypto/goolm/register.go +++ b/crypto/goolm/register.go @@ -7,15 +7,13 @@ package goolm import ( - // Need to import these subpackages to ensure they are registered - _ "maunium.net/go/mautrix/crypto/goolm/account" - _ "maunium.net/go/mautrix/crypto/goolm/pk" - _ "maunium.net/go/mautrix/crypto/goolm/session" - + "maunium.net/go/mautrix/crypto/goolm/account" + "maunium.net/go/mautrix/crypto/goolm/pk" + "maunium.net/go/mautrix/crypto/goolm/session" "maunium.net/go/mautrix/crypto/olm" ) -func init() { +func Register() { olm.Driver = "goolm" olm.GetVersion = func() (major, minor, patch uint8) { @@ -24,4 +22,8 @@ func init() { olm.SetPickleKeyImpl = func(key []byte) { panic("gob and json encoding is deprecated and not supported with goolm") } + + account.Register() + pk.Register() + session.Register() } diff --git a/crypto/goolm/session/register.go b/crypto/goolm/session/register.go index 09ed42d4..a88d12f6 100644 --- a/crypto/goolm/session/register.go +++ b/crypto/goolm/session/register.go @@ -10,7 +10,7 @@ import ( "maunium.net/go/mautrix/crypto/olm" ) -func init() { +func Register() { // Inbound Session olm.InitInboundGroupSessionFromPickled = func(pickled, key []byte) (olm.InboundGroupSession, error) { if len(pickled) == 0 { diff --git a/crypto/libolm/account.go b/crypto/libolm/account.go index a2212ccc..f6f916e7 100644 --- a/crypto/libolm/account.go +++ b/crypto/libolm/account.go @@ -23,18 +23,6 @@ type Account struct { mem []byte } -func init() { - olm.InitNewAccount = func() (olm.Account, error) { - return NewAccount() - } - olm.InitBlankAccount = func() olm.Account { - return NewBlankAccount() - } - olm.InitNewAccountFromPickled = func(pickled, key []byte) (olm.Account, error) { - return AccountFromPickled(pickled, key) - } -} - // Ensure that [Account] implements [olm.Account]. var _ olm.Account = (*Account)(nil) diff --git a/crypto/libolm/inboundgroupsession.go b/crypto/libolm/inboundgroupsession.go index d7912a7a..5606475d 100644 --- a/crypto/libolm/inboundgroupsession.go +++ b/crypto/libolm/inboundgroupsession.go @@ -21,21 +21,6 @@ type InboundGroupSession struct { mem []byte } -func init() { - olm.InitInboundGroupSessionFromPickled = func(pickled, key []byte) (olm.InboundGroupSession, error) { - return InboundGroupSessionFromPickled(pickled, key) - } - olm.InitNewInboundGroupSession = func(sessionKey []byte) (olm.InboundGroupSession, error) { - return NewInboundGroupSession(sessionKey) - } - olm.InitInboundGroupSessionImport = func(sessionKey []byte) (olm.InboundGroupSession, error) { - return InboundGroupSessionImport(sessionKey) - } - olm.InitBlankInboundGroupSession = func() olm.InboundGroupSession { - return NewBlankInboundGroupSession() - } -} - // Ensure that [InboundGroupSession] implements [olm.InboundGroupSession]. var _ olm.InboundGroupSession = (*InboundGroupSession)(nil) diff --git a/crypto/libolm/outboundgroupsession.go b/crypto/libolm/outboundgroupsession.go index 94df66d7..646929eb 100644 --- a/crypto/libolm/outboundgroupsession.go +++ b/crypto/libolm/outboundgroupsession.go @@ -21,18 +21,6 @@ type OutboundGroupSession struct { mem []byte } -func init() { - olm.InitNewOutboundGroupSessionFromPickled = func(pickled, key []byte) (olm.OutboundGroupSession, error) { - if len(pickled) == 0 { - return nil, olm.EmptyInput - } - s := NewBlankOutboundGroupSession() - return s, s.Unpickle(pickled, key) - } - olm.InitNewOutboundGroupSession = func() (olm.OutboundGroupSession, error) { return NewOutboundGroupSession() } - olm.InitNewBlankOutboundGroupSession = func() olm.OutboundGroupSession { return NewBlankOutboundGroupSession() } -} - // Ensure that [OutboundGroupSession] implements [olm.OutboundGroupSession]. var _ olm.OutboundGroupSession = (*OutboundGroupSession)(nil) diff --git a/crypto/libolm/pk.go b/crypto/libolm/pk.go index 172f4191..35532140 100644 --- a/crypto/libolm/pk.go +++ b/crypto/libolm/pk.go @@ -35,16 +35,6 @@ type PKSigning struct { // Ensure that [PKSigning] implements [olm.PKSigning]. var _ olm.PKSigning = (*PKSigning)(nil) -func init() { - olm.InitNewPKSigning = func() (olm.PKSigning, error) { return NewPKSigning() } - olm.InitNewPKSigningFromSeed = func(seed []byte) (olm.PKSigning, error) { - return NewPKSigningFromSeed(seed) - } - olm.InitNewPKDecryptionFromPrivateKey = func(privateKey []byte) (olm.PKDecryption, error) { - return NewPkDecryption(privateKey) - } -} - func pkSigningSize() uint { return uint(C.olm_pk_signing_size()) } diff --git a/crypto/libolm/register.go b/crypto/libolm/register.go index 06c07ea8..f091d822 100644 --- a/crypto/libolm/register.go +++ b/crypto/libolm/register.go @@ -11,7 +11,7 @@ import ( var pickleKey = []byte("maunium.net/go/mautrix/crypto/olm") -func init() { +func Register() { olm.Driver = "libolm" olm.GetVersion = func() (major, minor, patch uint8) { @@ -24,4 +24,52 @@ func init() { olm.SetPickleKeyImpl = func(key []byte) { pickleKey = key } + + olm.InitNewAccount = func() (olm.Account, error) { + return NewAccount() + } + olm.InitBlankAccount = func() olm.Account { + return NewBlankAccount() + } + olm.InitNewAccountFromPickled = func(pickled, key []byte) (olm.Account, error) { + return AccountFromPickled(pickled, key) + } + + olm.InitSessionFromPickled = func(pickled, key []byte) (olm.Session, error) { + return SessionFromPickled(pickled, key) + } + olm.InitNewBlankSession = func() olm.Session { + return NewBlankSession() + } + + olm.InitNewPKSigning = func() (olm.PKSigning, error) { return NewPKSigning() } + olm.InitNewPKSigningFromSeed = func(seed []byte) (olm.PKSigning, error) { + return NewPKSigningFromSeed(seed) + } + olm.InitNewPKDecryptionFromPrivateKey = func(privateKey []byte) (olm.PKDecryption, error) { + return NewPkDecryption(privateKey) + } + + olm.InitInboundGroupSessionFromPickled = func(pickled, key []byte) (olm.InboundGroupSession, error) { + return InboundGroupSessionFromPickled(pickled, key) + } + olm.InitNewInboundGroupSession = func(sessionKey []byte) (olm.InboundGroupSession, error) { + return NewInboundGroupSession(sessionKey) + } + olm.InitInboundGroupSessionImport = func(sessionKey []byte) (olm.InboundGroupSession, error) { + return InboundGroupSessionImport(sessionKey) + } + olm.InitBlankInboundGroupSession = func() olm.InboundGroupSession { + return NewBlankInboundGroupSession() + } + + olm.InitNewOutboundGroupSessionFromPickled = func(pickled, key []byte) (olm.OutboundGroupSession, error) { + if len(pickled) == 0 { + return nil, olm.EmptyInput + } + s := NewBlankOutboundGroupSession() + return s, s.Unpickle(pickled, key) + } + olm.InitNewOutboundGroupSession = func() (olm.OutboundGroupSession, error) { return NewOutboundGroupSession() } + olm.InitNewBlankOutboundGroupSession = func() olm.OutboundGroupSession { return NewBlankOutboundGroupSession() } } diff --git a/crypto/libolm/session.go b/crypto/libolm/session.go index 810dc7a6..57e631c3 100644 --- a/crypto/libolm/session.go +++ b/crypto/libolm/session.go @@ -39,15 +39,6 @@ type Session struct { // Ensure that [Session] implements [olm.Session]. var _ olm.Session = (*Session)(nil) -func init() { - olm.InitSessionFromPickled = func(pickled, key []byte) (olm.Session, error) { - return SessionFromPickled(pickled, key) - } - olm.InitNewBlankSession = func() olm.Session { - return NewBlankSession() - } -} - // sessionSize is the size of a session object in bytes. func sessionSize() uint { return uint(C.olm_session_size()) diff --git a/crypto/registergoolm.go b/crypto/registergoolm.go index f5cecafc..6b5b65fd 100644 --- a/crypto/registergoolm.go +++ b/crypto/registergoolm.go @@ -2,4 +2,10 @@ package crypto -import _ "maunium.net/go/mautrix/crypto/goolm" +import ( + "maunium.net/go/mautrix/crypto/goolm" +) + +func init() { + goolm.Register() +} diff --git a/crypto/registerlibolm.go b/crypto/registerlibolm.go index ab388a5c..ef78b6b5 100644 --- a/crypto/registerlibolm.go +++ b/crypto/registerlibolm.go @@ -2,4 +2,8 @@ package crypto -import _ "maunium.net/go/mautrix/crypto/libolm" +import "maunium.net/go/mautrix/crypto/libolm" + +func init() { + libolm.Register() +} From 87fe12741427972dffcfbbdddd30a4f53f306419 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 11 Sep 2025 14:17:24 +0300 Subject: [PATCH 295/581] crypto/decryptolm: retry prekey decryption with goolm --- crypto/decryptolm.go | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crypto/decryptolm.go b/crypto/decryptolm.go index bd9f1753..ba3c9831 100644 --- a/crypto/decryptolm.go +++ b/crypto/decryptolm.go @@ -20,6 +20,7 @@ import ( "go.mau.fi/util/exerrors" "go.mau.fi/util/ptr" + "maunium.net/go/mautrix/crypto/goolm/account" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -171,6 +172,7 @@ func (mach *OlmMachine) tryDecryptOlmCiphertext(ctx context.Context, sender id.U return nil, DecryptionFailedForNormalMessage } + accountBackup, err := mach.account.Internal.Pickle([]byte("tmp")) log.Trace().Msg("Trying to create inbound session") endTimeTrace = mach.timeTrace(ctx, "creating inbound olm session", time.Second) session, err := mach.createInboundSession(ctx, senderKey, ciphertext) @@ -191,13 +193,20 @@ func (mach *OlmMachine) tryDecryptOlmCiphertext(ctx context.Context, sender id.U plaintext, err = session.Decrypt(ciphertext, olmType) endTimeTrace() if err != nil { - go mach.unwedgeDevice(log, sender, senderKey) log.Debug(). Hex("ciphertext_hash", ciphertextHash[:]). Hex("ciphertext_hash_repeat", ptr.Ptr(exerrors.Must(olmMessageHash(ciphertext)))[:]). Str("ciphertext", ciphertext). Str("olm_session_description", session.Describe()). Msg("DEBUG: Failed to decrypt prekey olm message with newly created session") + err2 := mach.goolmRetryHack(ctx, senderKey, ciphertext, accountBackup) + if err2 != nil { + log.Debug().Err(err2).Msg("Goolm confirmed decryption failure") + } else { + log.Warn().Msg("Goolm decryption was successful after libolm failure?") + } + + go mach.unwedgeDevice(log, sender, senderKey) return nil, fmt.Errorf("failed to decrypt olm event with session created from prekey message: %w", err) } @@ -214,6 +223,23 @@ func (mach *OlmMachine) tryDecryptOlmCiphertext(ctx context.Context, sender id.U return plaintext, nil } +func (mach *OlmMachine) goolmRetryHack(ctx context.Context, senderKey id.SenderKey, ciphertext string, accountBackup []byte) error { + acc, err := account.AccountFromPickled(accountBackup, []byte("tmp")) + if err != nil { + return fmt.Errorf("failed to unpickle olm account: %w", err) + } + sess, err := acc.NewInboundSessionFrom(&senderKey, ciphertext) + if err != nil { + return fmt.Errorf("failed to create inbound session: %w", err) + } + _, err = sess.Decrypt(ciphertext, id.OlmMsgTypePreKey) + if err != nil { + // This is the expected result if libolm failed + return fmt.Errorf("failed to decrypt with new session: %w", err) + } + return nil +} + const MaxOlmSessionsPerDevice = 5 func (mach *OlmMachine) tryDecryptOlmCiphertextWithExistingSession( From 5dbab3ae9927fa9cabaf608aeed7661a7a3c5d62 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 11 Sep 2025 14:46:21 +0300 Subject: [PATCH 296/581] crypto/machine: don't clear account on Destroy() --- crypto/machine.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto/machine.go b/crypto/machine.go index eb238922..ab3e4591 100644 --- a/crypto/machine.go +++ b/crypto/machine.go @@ -177,7 +177,7 @@ func (mach *OlmMachine) Destroy() { Str("account_ptr", fmt.Sprintf("%p", ptr.Val(mach.account).Internal)). Msg("Destroying olm machine") mach.cancelBackgroundCtx() - mach.account = nil + // TODO actually destroy something? } func (mach *OlmMachine) saveAccount(ctx context.Context) error { From 4603a344ce1daa95911106766911c52d65be3ee2 Mon Sep 17 00:00:00 2001 From: Tiago Loureiro Date: Thu, 11 Sep 2025 15:10:14 -0300 Subject: [PATCH 297/581] event: add org.matrix.msc3381.poll.end type (#412) --- event/type.go | 1 + 1 file changed, 1 insertion(+) diff --git a/event/type.go b/event/type.go index 3f01a067..5035f2fa 100644 --- a/event/type.go +++ b/event/type.go @@ -239,6 +239,7 @@ var ( EventUnstablePollStart = Type{Type: "org.matrix.msc3381.poll.start", Class: MessageEventType} EventUnstablePollResponse = Type{Type: "org.matrix.msc3381.poll.response", Class: MessageEventType} + EventUnstablePollEnd = Type{Type: "org.matrix.msc3381.poll.end", Class: MessageEventType} ) // Ephemeral events From 3a6f20bb623cac1b00ac07175904bb7d5af8614f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 12 Sep 2025 19:10:45 +0300 Subject: [PATCH 298/581] crypto/sqlstore: ignore unused sessions in olm unwedging --- crypto/decryptolm.go | 5 ++++- crypto/sql_store.go | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crypto/decryptolm.go b/crypto/decryptolm.go index ba3c9831..30cc4cfe 100644 --- a/crypto/decryptolm.go +++ b/crypto/decryptolm.go @@ -376,7 +376,10 @@ func (mach *OlmMachine) unwedgeDevice(log zerolog.Logger, sender id.UserID, send return } - log.Debug().Stringer("device_id", deviceIdentity.DeviceID).Msg("Creating new Olm session") + log.Debug(). + Time("last_created", lastCreatedAt). + Stringer("device_id", deviceIdentity.DeviceID). + Msg("Creating new Olm session") mach.devicesToUnwedgeLock.Lock() mach.devicesToUnwedge[senderKey] = true mach.devicesToUnwedgeLock.Unlock() diff --git a/crypto/sql_store.go b/crypto/sql_store.go index b0625763..4405cc31 100644 --- a/crypto/sql_store.go +++ b/crypto/sql_store.go @@ -251,8 +251,9 @@ func (store *SQLCryptoStore) GetLatestSession(ctx context.Context, key id.Sender } // GetNewestSessionCreationTS gets the creation timestamp of the most recently created session with the given sender key. +// This will exclude sessions that have never been used to encrypt or decrypt a message. func (store *SQLCryptoStore) GetNewestSessionCreationTS(ctx context.Context, key id.SenderKey) (createdAt time.Time, err error) { - err = store.DB.QueryRow(ctx, "SELECT created_at FROM crypto_olm_session WHERE sender_key=$1 AND account_id=$2 ORDER BY created_at DESC LIMIT 1", + err = store.DB.QueryRow(ctx, "SELECT created_at FROM crypto_olm_session WHERE sender_key=$1 AND account_id=$2 AND (encrypted_at <> created_at OR decrypted_at <> created_at) ORDER BY created_at DESC LIMIT 1", key, store.AccountID).Scan(&createdAt) if errors.Is(err, sql.ErrNoRows) { err = nil From 717c8c3092609f156b671b50f4f5194ac2ba730a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 13 Sep 2025 01:38:06 +0300 Subject: [PATCH 299/581] bridgev2/database: normalize disappearing settings before insert --- bridgev2/database/disappear.go | 14 ++++++++++++++ bridgev2/portal.go | 14 ++++---------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/bridgev2/database/disappear.go b/bridgev2/database/disappear.go index 537d0552..9874e472 100644 --- a/bridgev2/database/disappear.go +++ b/bridgev2/database/disappear.go @@ -37,6 +37,20 @@ type DisappearingSetting struct { DisappearAt time.Time } +func (ds DisappearingSetting) Normalize() DisappearingSetting { + if ds.Type == event.DisappearingTypeNone { + ds.Timer = 0 + } else if ds.Timer == 0 { + ds.Type = event.DisappearingTypeNone + } + return ds +} + +func (ds DisappearingSetting) StartingAt(start time.Time) DisappearingSetting { + ds.DisappearAt = start.Add(ds.Timer) + return ds +} + func (ds DisappearingSetting) ToEventContent() *event.BeeperDisappearingTimer { if ds.Type == event.DisappearingTypeNone || ds.Timer == 0 { return &event.BeeperDisappearingTimer{} diff --git a/bridgev2/portal.go b/bridgev2/portal.go index f3797247..7961a223 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1105,13 +1105,9 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin } if portal.Disappear.Type != event.DisappearingTypeNone { go portal.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{ - RoomID: portal.MXID, - EventID: message.MXID, - DisappearingSetting: database.DisappearingSetting{ - Type: portal.Disappear.Type, - Timer: portal.Disappear.Timer, - DisappearAt: message.Timestamp.Add(portal.Disappear.Timer), - }, + RoomID: portal.MXID, + EventID: message.MXID, + DisappearingSetting: portal.Disappear.StartingAt(message.Timestamp), }) } if resp.Pending { @@ -4159,9 +4155,7 @@ type UpdateDisappearingSettingOpts struct { } func (portal *Portal) UpdateDisappearingSetting(ctx context.Context, setting database.DisappearingSetting, opts UpdateDisappearingSettingOpts) bool { - if setting.Timer == 0 { - setting.Type = event.DisappearingTypeNone - } + setting = setting.Normalize() if portal.Disappear.Timer == setting.Timer && portal.Disappear.Type == setting.Type { return false } From b5bec2e96c2c65f424fb7d5a813f362afc0782eb Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 13 Sep 2025 13:44:46 +0300 Subject: [PATCH 300/581] client: stabilize support for state_after --- client.go | 2 +- responses.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client.go b/client.go index 71c3fb18..edeab732 100644 --- a/client.go +++ b/client.go @@ -754,7 +754,7 @@ func (req *ReqSync) BuildQuery() map[string]string { query["full_state"] = "true" } if req.UseStateAfter { - query["org.matrix.msc4222.use_state_after"] = "true" + query["use_state_after"] = "true" } if req.BeeperStreaming { query["com.beeper.streaming"] = "true" diff --git a/responses.go b/responses.go index 8ab78373..e2627724 100644 --- a/responses.go +++ b/responses.go @@ -397,7 +397,7 @@ type BeeperInboxPreviewEvent struct { type SyncJoinedRoom struct { Summary LazyLoadSummary `json:"summary"` State SyncEventsList `json:"state"` - StateAfter *SyncEventsList `json:"org.matrix.msc4222.state_after,omitempty"` + StateAfter *SyncEventsList `json:"state_after,omitempty"` Timeline SyncTimeline `json:"timeline"` Ephemeral SyncEventsList `json:"ephemeral"` AccountData SyncEventsList `json:"account_data"` From c37ddcc3a5e9bba4525bc650d593a7b5ce700fd8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 16 Sep 2025 14:45:37 +0300 Subject: [PATCH 301/581] Bump version to v0.25.1 --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ go.mod | 22 +++++++++++----------- go.sum | 40 ++++++++++++++++++++-------------------- version.go | 2 +- 4 files changed, 75 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a15550..5c33645f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,46 @@ +## v0.25.1 (2025-09-16) + +* *(client)* Fixed HTTP method of delete devices API call + (thanks to [@fmseals] in [#393]). +* *(client)* Added wrappers for [MSC4323]: User suspension & locking endpoints + (thanks to [@nexy7574] in [#407]). +* *(client)* Stabilized support for extensible profiles. +* *(client)* Stabilized support for `state_after` in sync. +* *(client)* Removed deprecated MSC2716 requests. +* *(crypto)* Added fallback to ensure `m.relates_to` is always copied even if + the content struct doesn't implement `Relatable`. +* *(crypto)* Changed olm unwedging to ignore newly created sessions if they + haven't been used successfully in either direction. +* *(federation)* Added utilities for generating, parsing, validating and + authorizing PDUs. + * Note: the new PDU code depends on `GOEXPERIMENT=jsonv2` +* *(event)* Added `is_animated` flag from [MSC4230] to file info. +* *(event)* Added types for [MSC4332]: In-room bot commands. +* *(event)* Added missing poll end event type for [MSC3381]. +* *(appservice)* Fixed URLs not being escaped properly when using unix socket + for homeserver connections. +* *(format)* Added more helpers for forming markdown links. +* *(event,bridgev2)* Added support for Beeper's disappearing message state event. +* *(bridgev2)* Redesigned group creation interface and added support in commands + and provisioning API. +* *(bridgev2)* Added GetEvent to Matrix interface to allow network connectors to + get an old event. The method is best effort only, as some configurations don't + allow fetching old events. +* *(bridgev2)* Added shared logic for provisioning that can be reused by the + API, commands and other sources. +* *(bridgev2)* Fixed mentions and URL previews not being copied over when + caption and media are merged. +* *(bridgev2)* Removed config option to change provisioning API prefix, which + had already broken in the previous release. + +[@fmseals]: https://github.com/fmseals +[#393]: https://github.com/mautrix/go/pull/393 +[#407]: https://github.com/mautrix/go/pull/407 +[MSC3381]: https://github.com/matrix-org/matrix-spec-proposals/pull/3381 +[MSC4230]: https://github.com/matrix-org/matrix-spec-proposals/pull/4230 +[MSC4323]: https://github.com/matrix-org/matrix-spec-proposals/pull/4323 +[MSC4332]: https://github.com/matrix-org/matrix-spec-proposals/pull/4332 + ## v0.25.0 (2025-08-16) * Bumped minimum Go version to 1.24. diff --git a/go.mod b/go.mod index 4abdc4ff..751e8015 100644 --- a/go.mod +++ b/go.mod @@ -2,27 +2,27 @@ module maunium.net/go/mautrix go 1.24.0 -toolchain go1.25.0 +toolchain go1.25.1 require ( filippo.io/edwards25519 v1.1.0 github.com/chzyer/readline v1.5.1 - github.com/coder/websocket v1.8.13 + github.com/coder/websocket v1.8.14 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.32 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.34.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.13 - go.mau.fi/util v0.9.0 + go.mau.fi/util v0.9.1 go.mau.fi/zeroconfig v0.2.0 - golang.org/x/crypto v0.41.0 - golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 - golang.org/x/net v0.43.0 - golang.org/x/sync v0.16.0 + golang.org/x/crypto v0.42.0 + golang.org/x/exp v0.0.0-20250911091902-df9299821621 + golang.org/x/net v0.44.0 + golang.org/x/sync v0.17.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 ) @@ -32,11 +32,11 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect + github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index bb5d5cdb..dafb9600 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= -github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= -github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -26,8 +26,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE= -github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490 h1:QTvNkZ5ylY0PGgA+Lih+GdboMLY/G9SEGLMEGVjTVA4= +github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -37,8 +37,8 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -51,26 +51,26 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.0 h1:ya3s3pX+Y8R2fgp0DbE7a0o3FwncoelDX5iyaeVE8ls= -go.mau.fi/util v0.9.0/go.mod h1:pdL3lg2aaeeHIreGXNnPwhJPXkXdc3ZxsI6le8hOWEA= +go.mau.fi/util v0.9.1 h1:A+XKHRsjKkFi2qOm4RriR1HqY2hoOXNS3WFHaC89r2Y= +go.mau.fi/util v0.9.1/go.mod h1:M0bM9SyaOWJniaHs9hxEzz91r5ql6gYq6o1q5O1SsjQ= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= -golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= +golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= diff --git a/version.go b/version.go index fd0d0a8d..4821a354 100644 --- a/version.go +++ b/version.go @@ -7,7 +7,7 @@ import ( "strings" ) -const Version = "v0.25.0" +const Version = "v0.25.1" var GoModVersion = "" var Commit = "" From 5af25d2eb7371805df62a47dc52f2b72e956ecac Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 16 Sep 2025 18:02:14 +0300 Subject: [PATCH 302/581] event/poll: add missing omitempty --- event/poll.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/event/poll.go b/event/poll.go index 47131a8f..9082f65e 100644 --- a/event/poll.go +++ b/event/poll.go @@ -35,7 +35,7 @@ type MSC1767Message struct { } type PollStartEventContent struct { - RelatesTo *RelatesTo `json:"m.relates_to"` + RelatesTo *RelatesTo `json:"m.relates_to,omitempty"` Mentions *Mentions `json:"m.mentions,omitempty"` PollStart struct { Kind string `json:"kind"` From af2e6c7ce0dede45f4829689061c14db61d5a065 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 17 Sep 2025 14:47:09 +0300 Subject: [PATCH 303/581] bridgev2/portal: ensure state key is set when handling state events --- bridgev2/errors.go | 1 + bridgev2/portal.go | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/bridgev2/errors.go b/bridgev2/errors.go index 026a95f4..52bebe81 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -55,6 +55,7 @@ var ( ErrRoomMetadataNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing room metadata")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) ErrRedactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting messages")).WithIsCertain(true).WithErrorAsMessage() ErrUnexpectedParsedContentType error = WrapErrorInStatus(errors.New("unexpected parsed content type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) + ErrInvalidStateKey error = WrapErrorInStatus(errors.New("room metadata state key is unset or non-empty")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) ErrDatabaseError error = WrapErrorInStatus(errors.New("database error")).WithMessage("internal database error").WithIsCertain(true).WithSendNotice(true) ErrTargetMessageNotFound error = WrapErrorInStatus(errors.New("target message not found")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) ErrUnsupportedMessageType error = WrapErrorInStatus(errors.New("unsupported message type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 7961a223..39b8272b 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1449,6 +1449,9 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( evt *event.Event, fn func(APIType, context.Context, *MatrixRoomMeta[ContentType]) (bool, error), ) EventHandlingResult { + if evt.StateKey == nil || *evt.StateKey != "" { + return EventHandlingResultFailed.WithMSSError(ErrInvalidStateKey) + } api, ok := sender.Client.(APIType) if !ok { return EventHandlingResultIgnored.WithMSSError(ErrRoomMetadataNotSupported) @@ -1583,6 +1586,9 @@ func (portal *Portal) handleMatrixMembership( origSender *OrigSender, evt *event.Event, ) EventHandlingResult { + if evt.StateKey == nil { + return EventHandlingResultFailed.WithMSSError(ErrInvalidStateKey) + } log := zerolog.Ctx(ctx) content, ok := evt.Content.Parsed.(*event.MemberEventContent) if !ok { @@ -1667,6 +1673,9 @@ func (portal *Portal) handleMatrixPowerLevels( origSender *OrigSender, evt *event.Event, ) EventHandlingResult { + if evt.StateKey == nil || *evt.StateKey != "" { + return EventHandlingResultFailed.WithMSSError(ErrInvalidStateKey) + } log := zerolog.Ctx(ctx) content, ok := evt.Content.Parsed.(*event.PowerLevelsEventContent) if !ok { From e6a1fa6fd7bd906a176abc914456a5f1680a8b9b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 17 Sep 2025 14:18:43 +0200 Subject: [PATCH 304/581] bridgev2/provisioning: sync ghost info when searching (#413) --- bridgev2/provisionutil/listcontacts.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bridgev2/provisionutil/listcontacts.go b/bridgev2/provisionutil/listcontacts.go index d2cf5e90..ce163e67 100644 --- a/bridgev2/provisionutil/listcontacts.go +++ b/bridgev2/provisionutil/listcontacts.go @@ -34,7 +34,7 @@ func GetContactList(ctx context.Context, login *bridgev2.UserLogin) (*RespGetCon return nil, err } return &RespGetContactList{ - Contacts: processResolveIdentifiers(ctx, login.Bridge, resp), + Contacts: processResolveIdentifiers(ctx, login.Bridge, resp, false), }, nil } @@ -49,11 +49,11 @@ func SearchUsers(ctx context.Context, login *bridgev2.UserLogin, query string) ( return nil, err } return &RespSearchUsers{ - Results: processResolveIdentifiers(ctx, login.Bridge, resp), + Results: processResolveIdentifiers(ctx, login.Bridge, resp, true), }, nil } -func processResolveIdentifiers(ctx context.Context, br *bridgev2.Bridge, resp []*bridgev2.ResolveIdentifierResponse) (apiResp []*RespResolveIdentifier) { +func processResolveIdentifiers(ctx context.Context, br *bridgev2.Bridge, resp []*bridgev2.ResolveIdentifierResponse, syncInfo bool) (apiResp []*RespResolveIdentifier) { apiResp = make([]*RespResolveIdentifier, len(resp)) for i, contact := range resp { apiContact := &RespResolveIdentifier{ @@ -69,6 +69,9 @@ func processResolveIdentifiers(ctx context.Context, br *bridgev2.Bridge, resp [] } } if contact.Ghost != nil { + if syncInfo && contact.UserInfo != nil { + contact.Ghost.UpdateInfo(ctx, contact.UserInfo) + } if contact.Ghost.Name != "" { apiContact.Name = contact.Ghost.Name } From 35ac4fcb8d91b38463521551a30040e1217678c4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 17 Sep 2025 21:45:30 +0300 Subject: [PATCH 305/581] bridgev2/matrix: don't encrypt reactions in batch sends --- bridgev2/matrix/connector.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index c5ee40fe..ab1764dd 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -643,7 +643,7 @@ func (br *Connector) BatchSend(ctx context.Context, roomID id.RoomID, req *mautr if intent != nil { intent.AddDoublePuppetValueWithTS(&evt.Content, evt.Timestamp) } - if evt.Type != event.EventEncrypted { + if evt.Type != event.EventEncrypted && evt.Type != event.EventReaction { err = br.Crypto.Encrypt(ctx, roomID, evt.Type, &evt.Content) if err != nil { return nil, err From 5b860f8bfb3c26c39e73db0ebf9fd816ae43c745 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 17 Sep 2025 22:30:16 +0300 Subject: [PATCH 306/581] responses: fix marshaling RespUserProfile --- responses.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/responses.go b/responses.go index e2627724..4d66cdb8 100644 --- a/responses.go +++ b/responses.go @@ -210,7 +210,7 @@ func (r *RespUserProfile) MarshalJSON() ([]byte, error) { } else { delete(marshalMap, "avatar_url") } - return json.Marshal(r.Extra) + return json.Marshal(marshalMap) } type RespMutualRooms struct { From e932aff2090f56bc1fbb07832cb2e150afd1a553 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 17 Sep 2025 22:30:32 +0300 Subject: [PATCH 307/581] crypto/ssss: use constant time comparison when decrypting account data --- crypto/ssss/key.go | 12 ++++++++++-- crypto/ssss/meta_test.go | 17 +++-------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/crypto/ssss/key.go b/crypto/ssss/key.go index aa22360a..cd8e3fce 100644 --- a/crypto/ssss/key.go +++ b/crypto/ssss/key.go @@ -7,6 +7,8 @@ package ssss import ( + "crypto/hmac" + "crypto/sha256" "encoding/base64" "fmt" "strings" @@ -108,12 +110,18 @@ func (key *Key) Decrypt(eventType string, data EncryptedKeyData) ([]byte, error) return nil, err } + mac, err := base64.RawStdEncoding.DecodeString(strings.TrimRight(data.MAC, "=")) + if err != nil { + return nil, err + } + // derive the AES and HMAC keys for the requested event type using the SSSS key aesKey, hmacKey := utils.DeriveKeysSHA256(key.Key, eventType) // compare the stored MAC with the one we calculated from the ciphertext - calcMac := utils.HMACSHA256B64(payload, hmacKey) - if strings.TrimRight(data.MAC, "=") != calcMac { + h := hmac.New(sha256.New, hmacKey[:]) + h.Write(payload) + if !hmac.Equal(h.Sum(nil), mac) { return nil, ErrKeyDataMACMismatch } diff --git a/crypto/ssss/meta_test.go b/crypto/ssss/meta_test.go index 4f2ff378..7a5ef8b9 100644 --- a/crypto/ssss/meta_test.go +++ b/crypto/ssss/meta_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "go.mau.fi/util/exerrors" "maunium.net/go/mautrix/crypto/ssss" ) @@ -70,23 +71,11 @@ func getKeyMeta(meta string) *ssss.KeyMetadata { } func getKey1() *ssss.Key { - km := getKeyMeta(key1Meta) - key, err := km.VerifyRecoveryKey(key1ID, key1RecoveryKey) - if err != nil { - panic(err) - } - key.ID = key1ID - return key + return exerrors.Must(getKeyMeta(key1Meta).VerifyRecoveryKey(key1ID, key1RecoveryKey)) } func getKey2() *ssss.Key { - km := getKeyMeta(key2Meta) - key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) - if err != nil { - panic(err) - } - key.ID = key2ID - return key + return exerrors.Must(getKeyMeta(key2Meta).VerifyRecoveryKey(key2ID, key2RecoveryKey)) } func TestKeyMetadata_VerifyRecoveryKey_Correct(t *testing.T) { From e19d009d59ef914d76f6f3b2729fa5073084d1cb Mon Sep 17 00:00:00 2001 From: Tiago Loureiro Date: Thu, 18 Sep 2025 11:07:13 -0300 Subject: [PATCH 308/581] event: add EventUnstablePollEnd to GuessClass() (#414) --- event/type.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/event/type.go b/event/type.go index 5035f2fa..1b4fbf76 100644 --- a/event/type.go +++ b/event/type.go @@ -128,7 +128,7 @@ func (et *Type) GuessClass() TypeClass { InRoomVerificationKey.Type, InRoomVerificationMAC.Type, InRoomVerificationCancel.Type, CallInvite.Type, CallCandidates.Type, CallAnswer.Type, CallReject.Type, CallSelectAnswer.Type, CallNegotiate.Type, CallHangup.Type, BeeperMessageStatus.Type, EventUnstablePollStart.Type, EventUnstablePollResponse.Type, - BeeperTranscription.Type: + EventUnstablePollEnd.Type, BeeperTranscription.Type: return MessageEventType case ToDeviceRoomKey.Type, ToDeviceRoomKeyRequest.Type, ToDeviceForwardedRoomKey.Type, ToDeviceRoomKeyWithheld.Type, ToDeviceBeeperRoomKeyAck.Type: From 8780c2eb449948f7ccaffffbbfe6c081c26266d1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Sep 2025 13:22:58 +0300 Subject: [PATCH 309/581] bridgev2/portal: set exclude from timeline flag for creation state --- bridgev2/portal.go | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 39b8272b..bbe41e02 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4444,8 +4444,6 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo req := mautrix.ReqCreateRoom{ Visibility: "private", - Name: portal.Name, - Topic: portal.Topic, CreationContent: make(map[string]any), InitialState: make([]*event.Event, 0, 6), Preset: "private_chat", @@ -4488,26 +4486,47 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo StateKey: &bridgeInfoStateKey, Type: event.StateBeeperRoomFeatures, Content: event.Content{Parsed: roomFeatures}, + }, &event.Event{ + Type: event.StateTopic, + Content: event.Content{ + Parsed: &event.TopicEventContent{Topic: portal.Topic}, + Raw: map[string]any{ + "com.beeper.exclude_from_timeline": true, + }, + }, }) if roomFeatures.DisappearingTimer != nil { req.InitialState = append(req.InitialState, &event.Event{ - Type: event.StateBeeperDisappearingTimer, - Content: event.Content{Parsed: portal.Disappear.ToEventContent()}, + Type: event.StateBeeperDisappearingTimer, + Content: event.Content{ + Parsed: portal.Disappear.ToEventContent(), + Raw: map[string]any{ + "com.beeper.exclude_from_timeline": true, + }, + }, }) portal.CapState.Flags |= database.CapStateFlagDisappearingTimerSet } - if req.Topic == "" { - // Add explicit topic event if topic is empty to ensure the event is set. - // This ensures that there won't be an extra event later if PUT /state/... is called. + if portal.Name != "" { req.InitialState = append(req.InitialState, &event.Event{ - Type: event.StateTopic, - Content: event.Content{Parsed: &event.TopicEventContent{Topic: ""}}, + Type: event.StateRoomName, + Content: event.Content{ + Parsed: &event.RoomNameEventContent{Name: portal.Name}, + Raw: map[string]any{ + "com.beeper.exclude_from_timeline": true, + }, + }, }) } if portal.AvatarMXC != "" { req.InitialState = append(req.InitialState, &event.Event{ - Type: event.StateRoomAvatar, - Content: event.Content{Parsed: &event.RoomAvatarEventContent{URL: portal.AvatarMXC}}, + Type: event.StateRoomAvatar, + Content: event.Content{ + Parsed: &event.RoomAvatarEventContent{URL: portal.AvatarMXC}, + Raw: map[string]any{ + "com.beeper.exclude_from_timeline": true, + }, + }, }) } if portal.Parent != nil && portal.Parent.MXID != "" { From b760023dcaa3770d44557c4a9f99ad1dfa27a07f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Sep 2025 14:26:21 +0300 Subject: [PATCH 310/581] bridgev2/portal: add support for implicit read receipts to network --- bridgev2/networkinterface.go | 6 ++++ bridgev2/portal.go | 59 +++++++++++++++++++++++++++--------- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 8293be51..fa87086a 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -346,6 +346,10 @@ type NetworkGeneralCapabilities struct { // Should the bridge re-request user info on incoming messages even if the ghost already has info? // By default, info is only requested for ghosts with no name, and other updating is left to events. AggressiveUpdateInfo bool + // Should the bridge call HandleMatrixReadReceipt with fake data when receiving a new message? + // This should be enabled if the network requires each message to be marked as read independently, + // and doesn't automatically do it when sending a message. + ImplicitReadReceipts bool // If the bridge uses the pending message mechanism ([MatrixMessage.AddPendingToSave]) // to handle asynchronous message responses, this field can be set to enable // automatic timeout errors in case the asynchronous response never arrives. @@ -1361,6 +1365,8 @@ type MatrixReadReceipt struct { LastRead time.Time // The receipt metadata. Receipt event.ReadReceipt + // Whether the receipt is implicit, i.e. triggered by an incoming timeline event rather than an explicit receipt. + Implicit bool } type MatrixTyping struct { diff --git a/bridgev2/portal.go b/bridgev2/portal.go index bbe41e02..8924566f 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -587,7 +587,7 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * // Tombstones aren't bridged so they don't need a login return portal.handleMatrixTombstone(ctx, evt) } - login, _, err := portal.FindPreferredLogin(ctx, sender, true) + login, userPortal, err := portal.FindPreferredLogin(ctx, sender, true) if err != nil { log.Err(err).Msg("Failed to get user login to handle Matrix event") if errors.Is(err, ErrNotLoggedIn) { @@ -646,6 +646,21 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * } // Copy logger because many of the handlers will use UpdateContext ctx = log.With().Str("login_id", string(login.ID)).Logger().WithContext(ctx) + + if origSender == nil && portal.Bridge.Network.GetCapabilities().ImplicitReadReceipts { + rrLog := log.With().Str("subaction", "implicit read receipt").Logger() + rrCtx := rrLog.WithContext(ctx) + rrLog.Debug().Msg("Sending implicit read receipt for event") + evtTS := time.UnixMilli(evt.Timestamp) + portal.callReadReceiptHandler(rrCtx, login, nil, &MatrixReadReceipt{ + Portal: portal, + EventID: evt.ID, + Implicit: true, + ReadUpTo: evtTS, + Receipt: event.ReadReceipt{Timestamp: evtTS}, + }, userPortal) + } + switch evt.Type { case event.EventMessage, event.EventSticker, event.EventUnstablePollStart, event.EventUnstablePollResponse: return portal.handleMatrixMessage(ctx, login, origSender, evt) @@ -735,15 +750,10 @@ func (portal *Portal) handleMatrixReadReceipt(ctx context.Context, user *User, e EventID: eventID, Receipt: receipt, } - if userPortal == nil { - userPortal = database.UserPortalFor(login.UserLogin, portal.PortalKey) - } else { - evt.LastRead = userPortal.LastRead - userPortal = userPortal.CopyWithoutValues() - } evt.ExactMessage, err = portal.Bridge.DB.Message.GetPartByMXID(ctx, eventID) if err != nil { log.Err(err).Msg("Failed to get exact message from database") + evt.ReadUpTo = receipt.Timestamp } else if evt.ExactMessage != nil { log.UpdateContext(func(c zerolog.Context) zerolog.Context { return c.Str("exact_message_id", string(evt.ExactMessage.ID)).Time("exact_message_ts", evt.ExactMessage.Timestamp) @@ -752,19 +762,38 @@ func (portal *Portal) handleMatrixReadReceipt(ctx context.Context, user *User, e } else { evt.ReadUpTo = receipt.Timestamp } - err = rrClient.HandleMatrixReadReceipt(ctx, evt) + portal.callReadReceiptHandler(ctx, login, rrClient, evt, userPortal) +} + +func (portal *Portal) callReadReceiptHandler( + ctx context.Context, + login *UserLogin, + rrClient ReadReceiptHandlingNetworkAPI, + evt *MatrixReadReceipt, + userPortal *database.UserPortal, +) { + if rrClient == nil { + var ok bool + rrClient, ok = login.Client.(ReadReceiptHandlingNetworkAPI) + if !ok { + return + } + } + if userPortal == nil { + userPortal = database.UserPortalFor(login.UserLogin, portal.PortalKey) + } else { + evt.LastRead = userPortal.LastRead + userPortal = userPortal.CopyWithoutValues() + } + err := rrClient.HandleMatrixReadReceipt(ctx, evt) if err != nil { - log.Err(err).Msg("Failed to handle read receipt") + zerolog.Ctx(ctx).Err(err).Msg("Failed to handle read receipt") return } - if evt.ExactMessage != nil { - userPortal.LastRead = evt.ExactMessage.Timestamp - } else { - userPortal.LastRead = receipt.Timestamp - } + userPortal.LastRead = evt.ReadUpTo err = portal.Bridge.DB.UserPortal.Put(ctx, userPortal) if err != nil { - log.Err(err).Msg("Failed to save user portal metadata") + zerolog.Ctx(ctx).Err(err).Msg("Failed to save user portal metadata") } portal.Bridge.DisappearLoop.StartAll(ctx, portal.MXID) } From 6acb04aa1e9aa21e361de21b28a7ab65739ea163 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Sep 2025 19:15:02 +0300 Subject: [PATCH 311/581] federation/pdu: use option to trust internal metadata for GetEventID --- federation/pdu/hash.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/federation/pdu/hash.go b/federation/pdu/hash.go index 050029df..38ef83e9 100644 --- a/federation/pdu/hash.go +++ b/federation/pdu/hash.go @@ -72,7 +72,12 @@ func (pdu *PDU) GetRoomID() (id.RoomID, error) { } } +var UseInternalMetaForGetEventID = false + func (pdu *PDU) GetEventID(roomVersion id.RoomVersion) (id.EventID, error) { + if UseInternalMetaForGetEventID && pdu.InternalMeta.EventID != "" { + return pdu.InternalMeta.EventID, nil + } return pdu.calculateEventID(roomVersion, '$') } From 2240aa0267fbf9b651a8604598d3d1842f24126f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Sep 2025 19:50:41 +0300 Subject: [PATCH 312/581] bridgev2/portal: log if room create event is taking long --- bridgev2/portal.go | 82 +++++++++++++++++++++++----------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 8924566f..d2a02188 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -329,55 +329,55 @@ func (portal *Portal) eventLoop() { func (portal *Portal) handleSingleEventAsync(idx int, rawEvt any) (outerRes EventHandlingResult) { ctx := portal.getEventCtxWithLog(rawEvt, idx) - if _, isCreate := rawEvt.(*portalCreateEvent); isCreate { - portal.handleSingleEvent(ctx, rawEvt, func(res EventHandlingResult) { - outerRes = res - }) - } else if portal.Bridge.Config.AsyncEvents { - outerRes = EventHandlingResultQueued + if portal.Bridge.Config.AsyncEvents { go portal.handleSingleEvent(ctx, rawEvt, func(res EventHandlingResult) {}) - } else { - log := zerolog.Ctx(ctx) - doneCh := make(chan struct{}) - var backgrounded atomic.Bool - start := time.Now() - var handleDuration time.Duration - // Note: this will not set the success flag if the handler times out - outerRes = EventHandlingResult{Queued: true} - go portal.handleSingleEvent(ctx, rawEvt, func(res EventHandlingResult) { - outerRes = res - handleDuration = time.Since(start) - close(doneCh) - if backgrounded.Load() { + return EventHandlingResultQueued + } + log := zerolog.Ctx(ctx) + doneCh := make(chan struct{}) + var backgrounded atomic.Bool + start := time.Now() + var handleDuration time.Duration + // Note: this will not set the success flag if the handler times out + outerRes = EventHandlingResult{Queued: true} + go portal.handleSingleEvent(ctx, rawEvt, func(res EventHandlingResult) { + outerRes = res + handleDuration = time.Since(start) + close(doneCh) + if backgrounded.Load() { + log.Debug(). + Time("started_at", start). + Stringer("duration", handleDuration). + Msg("Event that took too long finally finished handling") + } + }) + tick := time.NewTicker(30 * time.Second) + _, isCreate := rawEvt.(*portalCreateEvent) + defer tick.Stop() + for i := 0; i < 10; i++ { + select { + case <-doneCh: + if i > 0 { log.Debug(). Time("started_at", start). Stringer("duration", handleDuration). - Msg("Event that took too long finally finished handling") + Msg("Event that took long finished handling") } - }) - tick := time.NewTicker(30 * time.Second) - defer tick.Stop() - for i := 0; i < 10; i++ { - select { - case <-doneCh: - if i > 0 { - log.Debug(). - Time("started_at", start). - Stringer("duration", handleDuration). - Msg("Event that took long finished handling") - } - return - case <-tick.C: - log.Warn(). - Time("started_at", start). - Msg("Event handling is taking long") + return + case <-tick.C: + log.Warn(). + Time("started_at", start). + Msg("Event handling is taking long") + if isCreate { + // Never background portal creation events + i = 1 } } - log.Warn(). - Time("started_at", start). - Msg("Event handling is taking too long, continuing in background") - backgrounded.Store(true) } + log.Warn(). + Time("started_at", start). + Msg("Event handling is taking too long, continuing in background") + backgrounded.Store(true) return } From b42fb5096aab3fe5e8f3c462433745030e17ba54 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Sep 2025 19:53:22 +0300 Subject: [PATCH 313/581] bridgev2/portal: also log long events when using async events --- bridgev2/portal.go | 14 +++++++------- bridgev2/portalinternal.go | 8 ++++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index d2a02188..f1c06171 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -291,7 +291,7 @@ func (portal *Portal) queueEvent(ctx context.Context, evt portalEvent) EventHand portal.eventsLock.Lock() defer portal.eventsLock.Unlock() portal.eventIdx++ - return portal.handleSingleEventAsync(portal.eventIdx, evt) + return portal.handleSingleEventWithDelayLogging(portal.eventIdx, evt) } else { select { case portal.events <- evt: @@ -323,16 +323,16 @@ func (portal *Portal) eventLoop() { i := 0 for rawEvt := range portal.events { i++ - portal.handleSingleEventAsync(i, rawEvt) + if portal.Bridge.Config.AsyncEvents { + go portal.handleSingleEventWithDelayLogging(i, rawEvt) + } else { + portal.handleSingleEventWithDelayLogging(i, rawEvt) + } } } -func (portal *Portal) handleSingleEventAsync(idx int, rawEvt any) (outerRes EventHandlingResult) { +func (portal *Portal) handleSingleEventWithDelayLogging(idx int, rawEvt any) (outerRes EventHandlingResult) { ctx := portal.getEventCtxWithLog(rawEvt, idx) - if portal.Bridge.Config.AsyncEvents { - go portal.handleSingleEvent(ctx, rawEvt, func(res EventHandlingResult) {}) - return EventHandlingResultQueued - } log := zerolog.Ctx(ctx) doneCh := make(chan struct{}) var backgrounded atomic.Bool diff --git a/bridgev2/portalinternal.go b/bridgev2/portalinternal.go index 0223b4f2..ddbadc76 100644 --- a/bridgev2/portalinternal.go +++ b/bridgev2/portalinternal.go @@ -37,8 +37,8 @@ func (portal *PortalInternals) EventLoop() { (*Portal)(portal).eventLoop() } -func (portal *PortalInternals) HandleSingleEventAsync(idx int, rawEvt any) (outerRes EventHandlingResult) { - return (*Portal)(portal).handleSingleEventAsync(idx, rawEvt) +func (portal *PortalInternals) HandleSingleEventWithDelayLogging(idx int, rawEvt any) (outerRes EventHandlingResult) { + return (*Portal)(portal).handleSingleEventWithDelayLogging(idx, rawEvt) } func (portal *PortalInternals) GetEventCtxWithLog(rawEvt any, idx int) context.Context { @@ -73,6 +73,10 @@ func (portal *PortalInternals) HandleMatrixReadReceipt(ctx context.Context, user (*Portal)(portal).handleMatrixReadReceipt(ctx, user, eventID, receipt) } +func (portal *PortalInternals) CallReadReceiptHandler(ctx context.Context, login *UserLogin, rrClient ReadReceiptHandlingNetworkAPI, evt *MatrixReadReceipt, userPortal *database.UserPortal) { + (*Portal)(portal).callReadReceiptHandler(ctx, login, rrClient, evt, userPortal) +} + func (portal *PortalInternals) HandleMatrixTyping(ctx context.Context, evt *event.Event) EventHandlingResult { return (*Portal)(portal).handleMatrixTyping(ctx, evt) } From 9fbf1b85981027257d771ba895f24510f601d404 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Sep 2025 20:26:55 +0300 Subject: [PATCH 314/581] bridgev2: make split portal migration errors fatal --- bridgev2/bridge.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index 24619c79..fe7bd107 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -9,6 +9,7 @@ package bridgev2 import ( "context" "fmt" + "os" "sync" "time" @@ -279,7 +280,8 @@ func (br *Bridge) MigrateToSplitPortals(ctx context.Context) bool { } affected, err := br.DB.Portal.MigrateToSplitPortals(ctx) if err != nil { - log.Err(err).Msg("Failed to migrate portals") + log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to migrate portals") + os.Exit(31) return false } log.Info().Int64("rows_affected", affected).Msg("Migrated to split portals") From f7bfa885c9c2299dfb83eb9c5ab51afbdeec05e8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Sep 2025 20:42:38 +0300 Subject: [PATCH 315/581] bridgev2: improve split portal migration --- bridgev2/bridge.go | 26 +++++++++++++++++++ bridgev2/database/portal.go | 30 +++++++++++++++------- bridgev2/matrix/intent.go | 3 +++ bridgev2/matrix/mxmain/example-config.yaml | 1 + 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index fe7bd107..5a0ae30c 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -285,7 +285,33 @@ func (br *Bridge) MigrateToSplitPortals(ctx context.Context) bool { return false } log.Info().Int64("rows_affected", affected).Msg("Migrated to split portals") + withoutReceiver, err := br.DB.Portal.GetAllWithoutReceiver(ctx) + if err != nil { + log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to get portals that failed to migrate") + os.Exit(32) + return false + } + log.Info().Int("remaining_portals", len(withoutReceiver)).Msg("Deleting remaining portals without receiver") + for _, portal := range withoutReceiver { + if err = br.DB.Portal.Delete(ctx, portal.PortalKey); err != nil { + log.Err(err). + Str("portal_id", string(portal.ID)). + Stringer("mxid", portal.MXID). + Msg("Failed to delete portal database row that failed to migrate") + } else if err = br.Bot.DeleteRoom(ctx, portal.MXID, true); err != nil { + log.Err(err). + Str("portal_id", string(portal.ID)). + Stringer("mxid", portal.MXID). + Msg("Failed to delete portal room that failed to migrate") + } else { + log.Debug(). + Str("portal_id", string(portal.ID)). + Stringer("mxid", portal.MXID). + Msg("Deleted portal that wasn't updated by split portal migration query") + } + } br.DB.KV.Set(ctx, database.KeySplitPortalsEnabled, "true") + log.Info().Msg("Finished split portal migration successfully") return affected > 0 } diff --git a/bridgev2/database/portal.go b/bridgev2/database/portal.go index c3aa7121..8570d840 100644 --- a/bridgev2/database/portal.go +++ b/bridgev2/database/portal.go @@ -88,6 +88,7 @@ const ( getPortalByIDWithUncertainReceiverQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND id=$2 AND (receiver=$3 OR receiver='')` getPortalByMXIDQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND mxid=$2` getAllPortalsWithMXIDQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND mxid IS NOT NULL` + getAllPortalsWithoutReceiver = getPortalBaseQuery + `WHERE bridge_id=$1 AND receiver=''` getAllDMPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND room_type='dm' AND other_user_id=$2` getAllPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1` getChildPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND parent_id=$2 AND parent_receiver=$3` @@ -123,15 +124,22 @@ const ( reIDPortalQuery = `UPDATE portal SET id=$4, receiver=$5 WHERE bridge_id=$1 AND id=$2 AND receiver=$3` migrateToSplitPortalsQuery = ` UPDATE portal - SET receiver=COALESCE(( - SELECT login_id - FROM user_portal - WHERE bridge_id=portal.bridge_id AND portal_id=portal.id AND portal_receiver='' - LIMIT 1 - ), ( - SELECT id FROM user_login WHERE bridge_id=portal.bridge_id LIMIT 1 - ), '') - WHERE receiver='' AND bridge_id=$1 + SET receiver=new_receiver + FROM ( + SELECT bridge_id, id, COALESCE(( + SELECT login_id + FROM user_portal + WHERE bridge_id=portal.bridge_id AND portal_id=portal.id AND portal_receiver='' + LIMIT 1 + ), ( + SELECT id FROM user_login WHERE bridge_id=portal.bridge_id LIMIT 1 + ), '') AS new_receiver + FROM portal + WHERE receiver='' AND bridge_id=$1 + ) updates + WHERE portal.bridge_id=updates.bridge_id AND portal.id=updates.id AND portal.receiver='' AND NOT EXISTS ( + SELECT 1 FROM portal p2 WHERE p2.bridge_id=updates.bridge_id AND p2.id=updates.id AND p2.receiver=updates.new_receiver + ) ` ) @@ -159,6 +167,10 @@ func (pq *PortalQuery) GetAllWithMXID(ctx context.Context) ([]*Portal, error) { return pq.QueryMany(ctx, getAllPortalsWithMXIDQuery, pq.BridgeID) } +func (pq *PortalQuery) GetAllWithoutReceiver(ctx context.Context) ([]*Portal, error) { + return pq.QueryMany(ctx, getAllPortalsWithoutReceiver, pq.BridgeID) +} + func (pq *PortalQuery) GetAll(ctx context.Context) ([]*Portal, error) { return pq.QueryMany(ctx, getAllPortalsQuery, pq.BridgeID) } diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index 2c68a692..ab59a582 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -577,6 +577,9 @@ func (as *ASIntent) MarkAsDM(ctx context.Context, roomID id.RoomID, withUser id. } func (as *ASIntent) DeleteRoom(ctx context.Context, roomID id.RoomID, puppetsOnly bool) error { + if roomID == "" { + return nil + } if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) { err := as.Matrix.BeeperDeleteRoom(ctx, roomID) if err != nil { diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 488f0b4c..95fa13eb 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -15,6 +15,7 @@ bridge: # By default, users who are in the same group on the remote network will be # in the same Matrix room bridged to that group. If this is set to true, # every user will get their own Matrix room instead. + # SETTING THIS IS IRREVERSIBLE AND POTENTIALLY DESTRUCTIVE IF PORTALS ALREADY EXIST. split_portals: false # Should the bridge resend `m.bridge` events to all portals on startup? resend_bridge_info: false From 820d0ee66bbd0e9ee4eab776e634f533a9fb5ef8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Sep 2025 21:01:34 +0300 Subject: [PATCH 316/581] bridgev2: only delete rooms in split portal migration after starting connectors --- bridgev2/bridge.go | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index 5a0ae30c..83418290 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -183,7 +183,11 @@ func (br *Bridge) StartConnectors(ctx context.Context) error { } } if !br.Background { - br.didSplitPortals = br.MigrateToSplitPortals(ctx) + var postMigrate func() + br.didSplitPortals, postMigrate = br.MigrateToSplitPortals(ctx) + if postMigrate != nil { + defer postMigrate() + } } br.Log.Info().Msg("Starting Matrix connector") err := br.Matrix.Start(ctx) @@ -272,25 +276,26 @@ func (br *Bridge) ResendBridgeInfo(ctx context.Context, resendInfo, resendCaps b Msg("Resent bridge info to all portals") } -func (br *Bridge) MigrateToSplitPortals(ctx context.Context) bool { +func (br *Bridge) MigrateToSplitPortals(ctx context.Context) (bool, func()) { log := zerolog.Ctx(ctx).With().Str("action", "migrate to split portals").Logger() ctx = log.WithContext(ctx) if !br.Config.SplitPortals || br.DB.KV.Get(ctx, database.KeySplitPortalsEnabled) == "true" { - return false + return false, nil } affected, err := br.DB.Portal.MigrateToSplitPortals(ctx) if err != nil { log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to migrate portals") os.Exit(31) - return false + return false, nil } log.Info().Int64("rows_affected", affected).Msg("Migrated to split portals") withoutReceiver, err := br.DB.Portal.GetAllWithoutReceiver(ctx) if err != nil { log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to get portals that failed to migrate") os.Exit(32) - return false + return false, nil } + var roomsToDelete []id.RoomID log.Info().Int("remaining_portals", len(withoutReceiver)).Msg("Deleting remaining portals without receiver") for _, portal := range withoutReceiver { if err = br.DB.Portal.Delete(ctx, portal.PortalKey); err != nil { @@ -298,21 +303,30 @@ func (br *Bridge) MigrateToSplitPortals(ctx context.Context) bool { Str("portal_id", string(portal.ID)). Stringer("mxid", portal.MXID). Msg("Failed to delete portal database row that failed to migrate") - } else if err = br.Bot.DeleteRoom(ctx, portal.MXID, true); err != nil { - log.Err(err). - Str("portal_id", string(portal.ID)). - Stringer("mxid", portal.MXID). - Msg("Failed to delete portal room that failed to migrate") - } else { + } else if portal.MXID != "" { log.Debug(). Str("portal_id", string(portal.ID)). Stringer("mxid", portal.MXID). - Msg("Deleted portal that wasn't updated by split portal migration query") + Msg("Marked portal room for deletion from homeserver") + roomsToDelete = append(roomsToDelete, portal.MXID) + } else { + log.Debug(). + Str("portal_id", string(portal.ID)). + Msg("Deleted portal row with no Matrix room") } } br.DB.KV.Set(ctx, database.KeySplitPortalsEnabled, "true") log.Info().Msg("Finished split portal migration successfully") - return affected > 0 + return affected > 0, func() { + for _, roomID := range roomsToDelete { + if err = br.Bot.DeleteRoom(ctx, roomID, true); err != nil { + log.Err(err). + Stringer("mxid", roomID). + Msg("Failed to delete portal room that failed to migrate") + } + } + log.Info().Int("room_count", len(roomsToDelete)).Msg("Finished deleting rooms that failed to migrate") + } } func (br *Bridge) StartLogins(ctx context.Context) error { From 54c0e5c2f623459ff885a19453f16699378a8356 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Sep 2025 21:19:01 +0300 Subject: [PATCH 317/581] bridgev2/portal: remove portal from cache if loading parent/relay fails --- bridgev2/portal.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index f1c06171..be029f25 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -123,6 +123,8 @@ func (br *Bridge) loadPortal(ctx context.Context, dbPortal *database.Portal, que currentlyTypingGhosts: exsync.NewSet[id.UserID](), outgoingMessages: make(map[networkid.TransactionID]*outgoingMessage), } + // Putting the portal in the cache before it's fully initialized is mildly dangerous, + // but loading the relay user login may depend on it. br.portalsByKey[portal.PortalKey] = portal if portal.MXID != "" { br.portalsByMXID[portal.MXID] = portal @@ -131,12 +133,20 @@ func (br *Bridge) loadPortal(ctx context.Context, dbPortal *database.Portal, que if portal.ParentKey.ID != "" { portal.Parent, err = br.UnlockedGetPortalByKey(ctx, portal.ParentKey, false) if err != nil { + delete(br.portalsByKey, portal.PortalKey) + if portal.MXID != "" { + delete(br.portalsByMXID, portal.MXID) + } return nil, fmt.Errorf("failed to load parent portal (%s): %w", portal.ParentKey, err) } } if portal.RelayLoginID != "" { portal.Relay, err = br.unlockedGetExistingUserLoginByID(ctx, portal.RelayLoginID) if err != nil { + delete(br.portalsByKey, portal.PortalKey) + if portal.MXID != "" { + delete(br.portalsByMXID, portal.MXID) + } return nil, fmt.Errorf("failed to load relay login (%s): %w", portal.RelayLoginID, err) } } From fbf8718e229a5a2554bf04478ef72e65d1f8f96e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Sep 2025 21:19:27 +0300 Subject: [PATCH 318/581] bridgev2: also fix portal parent receivers in split portal migration --- bridgev2/bridge.go | 9 ++++++++- bridgev2/database/portal.go | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index 83418290..2ad6a614 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -289,10 +289,17 @@ func (br *Bridge) MigrateToSplitPortals(ctx context.Context) (bool, func()) { return false, nil } log.Info().Int64("rows_affected", affected).Msg("Migrated to split portals") + affected2, err := br.DB.Portal.FixParentsAfterSplitPortalMigration(ctx) + if err != nil { + log.Err(err).Msg("Failed to fix parent portals after split portal migration") + os.Exit(31) + return false, nil + } + log.Info().Int64("rows_affected", affected2).Msg("Updated parent receivers after split portal migration") withoutReceiver, err := br.DB.Portal.GetAllWithoutReceiver(ctx) if err != nil { log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to get portals that failed to migrate") - os.Exit(32) + os.Exit(31) return false, nil } var roomsToDelete []id.RoomID diff --git a/bridgev2/database/portal.go b/bridgev2/database/portal.go index 8570d840..e02b9e44 100644 --- a/bridgev2/database/portal.go +++ b/bridgev2/database/portal.go @@ -131,6 +131,11 @@ const ( FROM user_portal WHERE bridge_id=portal.bridge_id AND portal_id=portal.id AND portal_receiver='' LIMIT 1 + ), ( + SELECT login_id + FROM user_portal + WHERE portal.parent_id<>'' AND bridge_id=portal.bridge_id AND portal_id=portal.parent_id + LIMIT 1 ), ( SELECT id FROM user_login WHERE bridge_id=portal.bridge_id LIMIT 1 ), '') AS new_receiver @@ -141,6 +146,9 @@ const ( SELECT 1 FROM portal p2 WHERE p2.bridge_id=updates.bridge_id AND p2.id=updates.id AND p2.receiver=updates.new_receiver ) ` + fixParentsAfterSplitPortalMigrationQuery = ` + UPDATE portal SET parent_receiver=receiver WHERE parent_receiver='' AND receiver<>'' AND parent_id<>''; + ` ) func (pq *PortalQuery) GetByKey(ctx context.Context, key networkid.PortalKey) (*Portal, error) { @@ -209,6 +217,14 @@ func (pq *PortalQuery) MigrateToSplitPortals(ctx context.Context) (int64, error) return res.RowsAffected() } +func (pq *PortalQuery) FixParentsAfterSplitPortalMigration(ctx context.Context) (int64, error) { + res, err := pq.GetDB().Exec(ctx, fixParentsAfterSplitPortalMigrationQuery, pq.BridgeID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + func (p *Portal) Scan(row dbutil.Scannable) (*Portal, error) { var mxid, parentID, parentReceiver, relayLoginID, otherUserID, disappearType sql.NullString var disappearTimer sql.NullInt64 From 0012a23d85023945f94796c8efcc794920841e22 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Sep 2025 21:21:25 +0300 Subject: [PATCH 319/581] bridgev2/portal: don't allow queuing events into uninitialized portals --- bridgev2/portal.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index be029f25..575edfb8 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -303,6 +303,9 @@ func (portal *Portal) queueEvent(ctx context.Context, evt portalEvent) EventHand portal.eventIdx++ return portal.handleSingleEventWithDelayLogging(portal.eventIdx, evt) } else { + if portal.events == nil { + panic(fmt.Errorf("queueEvent into uninitialized portal %s", portal.PortalKey)) + } select { case portal.events <- evt: return EventHandlingResultQueued From 0a84c052dda8036a4bb59452234fd4211eadccc5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 21 Sep 2025 20:10:59 +0300 Subject: [PATCH 320/581] crypto: add utilities for cross-signing --- crypto/cross_sign_pubkey.go | 14 +++++++++++++ crypto/cross_sign_ssss.go | 40 +++++++++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crypto/cross_sign_pubkey.go b/crypto/cross_sign_pubkey.go index 77efab5b..f85d1ea3 100644 --- a/crypto/cross_sign_pubkey.go +++ b/crypto/cross_sign_pubkey.go @@ -20,6 +20,20 @@ type CrossSigningPublicKeysCache struct { UserSigningKey id.Ed25519 } +func (mach *OlmMachine) GetOwnVerificationStatus(ctx context.Context) (hasKeys, isVerified bool, err error) { + pubkeys := mach.GetOwnCrossSigningPublicKeys(ctx) + if pubkeys != nil { + hasKeys = true + isVerified, err = mach.CryptoStore.IsKeySignedBy( + ctx, mach.Client.UserID, mach.GetAccount().SigningKey(), mach.Client.UserID, pubkeys.SelfSigningKey, + ) + if err != nil { + err = fmt.Errorf("failed to check if current device is signed by own self-signing key: %w", err) + } + } + return +} + func (mach *OlmMachine) GetOwnCrossSigningPublicKeys(ctx context.Context) *CrossSigningPublicKeysCache { if mach.crossSigningPubkeys != nil { return mach.crossSigningPubkeys diff --git a/crypto/cross_sign_ssss.go b/crypto/cross_sign_ssss.go index 389a9fd2..50b58ea0 100644 --- a/crypto/cross_sign_ssss.go +++ b/crypto/cross_sign_ssss.go @@ -71,6 +71,42 @@ func (mach *OlmMachine) GenerateAndUploadCrossSigningKeysWithPassword(ctx contex }, passphrase) } +func (mach *OlmMachine) VerifyWithRecoveryKey(ctx context.Context, recoveryKey string) error { + keyID, keyData, err := mach.SSSS.GetDefaultKeyData(ctx) + if err != nil { + return fmt.Errorf("failed to get default SSSS key data: %w", err) + } + key, err := keyData.VerifyRecoveryKey(keyID, recoveryKey) + if err != nil { + return err + } + err = mach.FetchCrossSigningKeysFromSSSS(ctx, key) + if err != nil { + return fmt.Errorf("failed to fetch cross-signing keys from SSSS: %w", err) + } + err = mach.SignOwnDevice(ctx, mach.OwnIdentity()) + if err != nil { + return fmt.Errorf("failed to sign own device: %w", err) + } + err = mach.SignOwnMasterKey(ctx) + if err != nil { + return fmt.Errorf("failed to sign own master key: %w", err) + } + return nil +} + +func (mach *OlmMachine) GenerateAndVerifyWithRecoveryKey(ctx context.Context) (recoveryKey string, err error) { + recoveryKey, _, err = mach.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + if err != nil { + err = fmt.Errorf("failed to generate and upload cross-signing keys: %w", err) + } else if err = mach.SignOwnDevice(ctx, mach.OwnIdentity()); err != nil { + err = fmt.Errorf("failed to sign own device: %w", err) + } else if err = mach.SignOwnMasterKey(ctx); err != nil { + err = fmt.Errorf("failed to sign own master key: %w", err) + } + return +} + // GenerateAndUploadCrossSigningKeys generates a new key with all corresponding cross-signing keys. // // A passphrase can be provided to generate the SSSS key. If the passphrase is empty, a random key @@ -97,12 +133,12 @@ func (mach *OlmMachine) GenerateAndUploadCrossSigningKeys(ctx context.Context, u // Publish cross-signing keys err = mach.PublishCrossSigningKeys(ctx, keysCache, uiaCallback) if err != nil { - return "", nil, fmt.Errorf("failed to publish cross-signing keys: %w", err) + return key.RecoveryKey(), keysCache, fmt.Errorf("failed to publish cross-signing keys: %w", err) } err = mach.SSSS.SetDefaultKeyID(ctx, key.ID) if err != nil { - return "", nil, fmt.Errorf("failed to mark %s as the default key: %w", key.ID, err) + return key.RecoveryKey(), keysCache, fmt.Errorf("failed to mark %s as the default key: %w", key.ID, err) } return key.RecoveryKey(), keysCache, nil From 6c37f2b21f24dcc186ac2a4d00db9708a21511f4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 21 Sep 2025 20:12:05 +0300 Subject: [PATCH 321/581] bridgev2/matrix: add config option to self-sign bot device --- bridgev2/bridgeconfig/encryption.go | 1 + bridgev2/bridgeconfig/upgrade.go | 1 + bridgev2/database/kvstore.go | 1 + bridgev2/matrix/crypto.go | 55 ++++++++++++++++++++-- bridgev2/matrix/mxmain/example-config.yaml | 4 ++ 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/bridgev2/bridgeconfig/encryption.go b/bridgev2/bridgeconfig/encryption.go index 1ef7e18f..5a19b3ad 100644 --- a/bridgev2/bridgeconfig/encryption.go +++ b/bridgev2/bridgeconfig/encryption.go @@ -16,6 +16,7 @@ type EncryptionConfig struct { Require bool `yaml:"require"` Appservice bool `yaml:"appservice"` MSC4190 bool `yaml:"msc4190"` + SelfSign bool `yaml:"self_sign"` PlaintextMentions bool `yaml:"plaintext_mentions"` diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index f41f77d8..6533338f 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -157,6 +157,7 @@ func doUpgrade(helper up.Helper) { } else { helper.Copy(up.Bool, "encryption", "msc4190") } + helper.Copy(up.Bool, "encryption", "self_sign") helper.Copy(up.Bool, "encryption", "allow_key_sharing") if secret, ok := helper.Get(up.Str, "encryption", "pickle_key"); !ok || secret == "generate" { helper.Set(up.Str, random.String(64), "encryption", "pickle_key") diff --git a/bridgev2/database/kvstore.go b/bridgev2/database/kvstore.go index 52b4984e..bca26ed5 100644 --- a/bridgev2/database/kvstore.go +++ b/bridgev2/database/kvstore.go @@ -23,6 +23,7 @@ const ( KeySplitPortalsEnabled Key = "split_portals_enabled" KeyBridgeInfoVersion Key = "bridge_info_version" KeyEncryptionStateResynced Key = "encryption_state_resynced" + KeyRecoveryKey Key = "recovery_key" ) type KVQuery struct { diff --git a/bridgev2/matrix/crypto.go b/bridgev2/matrix/crypto.go index 2325ddfa..d77f1d44 100644 --- a/bridgev2/matrix/crypto.go +++ b/bridgev2/matrix/crypto.go @@ -24,6 +24,7 @@ import ( "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/crypto" "maunium.net/go/mautrix/crypto/olm" "maunium.net/go/mautrix/event" @@ -135,7 +136,14 @@ func (helper *CryptoHelper) Init(ctx context.Context) error { return err } if isExistingDevice { - helper.verifyKeysAreOnServer(ctx) + if !helper.verifyKeysAreOnServer(ctx) { + return nil + } + } + if helper.bridge.Config.Encryption.SelfSign { + if !helper.doSelfSign(ctx) { + os.Exit(34) + } } go helper.resyncEncryptionInfo(context.TODO()) @@ -143,6 +151,46 @@ func (helper *CryptoHelper) Init(ctx context.Context) error { return nil } +func (helper *CryptoHelper) doSelfSign(ctx context.Context) bool { + log := zerolog.Ctx(ctx) + hasKeys, isVerified, err := helper.mach.GetOwnVerificationStatus(ctx) + if err != nil { + log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to check verification status") + return false + } + log.Debug().Bool("has_keys", hasKeys).Bool("is_verified", isVerified).Msg("Checked verification status") + keyInDB := helper.bridge.Bridge.DB.KV.Get(ctx, database.KeyRecoveryKey) + if !hasKeys || keyInDB == "overwrite" { + if keyInDB != "" && keyInDB != "overwrite" { + log.WithLevel(zerolog.FatalLevel). + Msg("No keys on server, but database already has recovery key. Delete `recovery_key` from `kv_store` manually to continue.") + return false + } + recoveryKey, err := helper.mach.GenerateAndVerifyWithRecoveryKey(ctx) + if recoveryKey != "" { + helper.bridge.Bridge.DB.KV.Set(ctx, database.KeyRecoveryKey, recoveryKey) + } + if err != nil { + log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to generate recovery key and self-sign") + return false + } + log.Info().Msg("Generated new recovery key and self-signed bot device") + } else if !isVerified { + if keyInDB == "" { + log.WithLevel(zerolog.FatalLevel). + Msg("Server already has cross-signing keys, but no key in database. Add `recovery_key` to `kv_store`, or set it to `overwrite` to generate new keys.") + return false + } + err = helper.mach.VerifyWithRecoveryKey(ctx, keyInDB) + if err != nil { + log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to verify with recovery key") + return false + } + log.Info().Msg("Verified bot device with existing recovery key") + } + return true +} + func (helper *CryptoHelper) resyncEncryptionInfo(ctx context.Context) { log := helper.log.With().Str("action", "resync encryption event").Logger() rows, err := helper.store.DB.Query(ctx, `SELECT room_id FROM mx_room_state WHERE encryption='{"resync":true}'`) @@ -274,7 +322,7 @@ func (helper *CryptoHelper) loginBot(ctx context.Context) (*mautrix.Client, bool return client, deviceID != "", nil } -func (helper *CryptoHelper) verifyKeysAreOnServer(ctx context.Context) { +func (helper *CryptoHelper) verifyKeysAreOnServer(ctx context.Context) bool { helper.log.Debug().Msg("Making sure keys are still on server") resp, err := helper.client.QueryKeys(ctx, &mautrix.ReqQueryKeys{ DeviceKeys: map[id.UserID]mautrix.DeviceIDList{ @@ -287,10 +335,11 @@ func (helper *CryptoHelper) verifyKeysAreOnServer(ctx context.Context) { } device, ok := resp.DeviceKeys[helper.client.UserID][helper.client.DeviceID] if ok && len(device.Keys) > 0 { - return + return true } helper.log.Warn().Msg("Existing device doesn't have keys on server, resetting crypto") helper.Reset(ctx, false) + return false } func (helper *CryptoHelper) Start() { diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 95fa13eb..d8634028 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -365,6 +365,10 @@ encryption: # Only relevant when using end-to-bridge encryption, required when using encryption with next-gen auth (MSC3861). # Changing this option requires updating the appservice registration file. msc4190: false + # Should the bridge bot generate a recovery key and cross-signing keys and verify itself? + # Note that without the latest version of MSC4190, this will fail if you reset the bridge database. + # The generated recovery key will be saved in the kv_store table under `recovery_key`. + self_sign: false # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. # You must use a client that supports requesting keys from other users to use this feature. allow_key_sharing: true From 658b2e1d1d9a67c4d6b52725732fde8b2b47d6cf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 21 Sep 2025 20:30:22 +0300 Subject: [PATCH 322/581] bridgev2/matrix: share device keys as part of e2ee init --- bridgev2/matrix/crypto.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bridgev2/matrix/crypto.go b/bridgev2/matrix/crypto.go index d77f1d44..f4a2e9a0 100644 --- a/bridgev2/matrix/crypto.go +++ b/bridgev2/matrix/crypto.go @@ -139,6 +139,11 @@ func (helper *CryptoHelper) Init(ctx context.Context) error { if !helper.verifyKeysAreOnServer(ctx) { return nil } + } else { + err = helper.ShareKeys(ctx) + if err != nil { + return fmt.Errorf("failed to share device keys: %w", err) + } } if helper.bridge.Config.Encryption.SelfSign { if !helper.doSelfSign(ctx) { From 0198ef315c029b38cd303a15e85caf2eb364f00f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 21 Sep 2025 20:51:51 +0300 Subject: [PATCH 323/581] changelog: update --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c33645f..794008c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## unreleased + +* *(crypto)* Added helper methods for generating and verifying with recovery + keys. +* *(bridgev2/matrix)* Added config option to automatically generate a recovery + key for the bridge bot and self-sign the bridge's device. +* *(bridgev2/matrix)* Added initial support for using appservice/MSC3202 mode + for encryption with standard servers like Synapse. +* *(bridgev2)* Added optional support for implicit read receipts. +* *(bridgev2)* Extended event duration logging to log any event taking too long. +* *(bridgev2)* Fixed various bugs with migrating to split portals. +* *(event)* Fixed poll start events having incorrect null `m.relates_to`. +* *(event)* Added event type constant for poll end events. +* *(client)* Fixed `RespUserProfile` losing standard fields when re-marshaling. + ## v0.25.1 (2025-09-16) * *(client)* Fixed HTTP method of delete devices API call From cf814a5aaae1f9029c3c7e5d14f34231ec8b6723 Mon Sep 17 00:00:00 2001 From: Toni Spets Date: Mon, 22 Sep 2025 13:30:08 +0300 Subject: [PATCH 324/581] error: Add RespError WithExtraData convenience function (#416) To dynamically build errors with extra keys like returning `max_delay` for `M_MAX_DELAY_EXCEEDED`. --- error.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/error.go b/error.go index 6f4880df..bea4caae 100644 --- a/error.go +++ b/error.go @@ -177,6 +177,16 @@ func (e RespError) WithStatus(status int) RespError { return e } +func (e RespError) WithExtraData(extraData map[string]any) RespError { + if e.ExtraData == nil { + e.ExtraData = make(map[string]any) + } else { + e.ExtraData = maps.Clone(e.ExtraData) + } + maps.Copy(e.ExtraData, extraData) + return e +} + // Error returns the errcode and error message. func (e RespError) Error() string { return e.ErrCode + ": " + e.Err From f9fb77d6aad75604351cb09d846f448a9e22ac9d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 22 Sep 2025 13:46:37 +0300 Subject: [PATCH 325/581] client: add user directory search method --- CHANGELOG.md | 1 + client.go | 9 +++++++++ requests.go | 5 +++++ responses.go | 29 +++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 794008c9..831e3094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * *(event)* Fixed poll start events having incorrect null `m.relates_to`. * *(event)* Added event type constant for poll end events. * *(client)* Fixed `RespUserProfile` losing standard fields when re-marshaling. +* *(client)* Added wrapper for searching user directory. ## v0.25.1 (2025-09-16) diff --git a/client.go b/client.go index edeab732..bf41ffb9 100644 --- a/client.go +++ b/client.go @@ -1055,6 +1055,15 @@ func (cli *Client) GetProfile(ctx context.Context, mxid id.UserID) (resp *RespUs return } +func (cli *Client) SearchUserDirectory(ctx context.Context, query string, limit int) (resp *RespSearchUserDirectory, err error) { + urlPath := cli.BuildClientURL("v3", "user_directory", "search") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, &ReqSearchUserDirectory{ + SearchTerm: query, + Limit: limit, + }, &resp) + return +} + func (cli *Client) GetMutualRooms(ctx context.Context, otherUserID id.UserID, extras ...ReqMutualRooms) (resp *RespMutualRooms, err error) { if cli.SpecVersions != nil && !cli.SpecVersions.Supports(FeatureMutualRooms) { err = fmt.Errorf("server does not support fetching mutual rooms") diff --git a/requests.go b/requests.go index 4b5ce74b..9dfe09ab 100644 --- a/requests.go +++ b/requests.go @@ -183,6 +183,11 @@ type ReqKnockRoom struct { Reason string `json:"reason,omitempty"` } +type ReqSearchUserDirectory struct { + SearchTerm string `json:"search_term"` + Limit int `json:"limit,omitempty"` +} + type ReqMutualRooms struct { From string `json:"-"` } diff --git a/responses.go b/responses.go index 4d66cdb8..82ba003a 100644 --- a/responses.go +++ b/responses.go @@ -213,6 +213,35 @@ func (r *RespUserProfile) MarshalJSON() ([]byte, error) { return json.Marshal(marshalMap) } +type RespSearchUserDirectory struct { + Limited bool `json:"limited"` + Results []*RespUserProfile `json:"results"` +} + +type UserDirectoryEntry struct { + RespUserProfile + UserID id.UserID `json:"user_id"` +} + +func (r *UserDirectoryEntry) UnmarshalJSON(data []byte) error { + err := r.RespUserProfile.UnmarshalJSON(data) + if err != nil { + return err + } + userIDStr, _ := r.Extra["user_id"].(string) + r.UserID = id.UserID(userIDStr) + delete(r.Extra, "user_id") + return nil +} + +func (r *UserDirectoryEntry) MarshalJSON() ([]byte, error) { + if r.Extra == nil { + r.Extra = make(map[string]any) + } + r.Extra["user_id"] = r.UserID.String() + return r.RespUserProfile.MarshalJSON() +} + type RespMutualRooms struct { Joined []id.RoomID `json:"joined"` NextBatch string `json:"next_batch,omitempty"` From c4701ba06c2bf7db5b8205dab7fe6c3ce58c7b42 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 22 Sep 2025 14:30:41 +0300 Subject: [PATCH 326/581] responses: fix RespSearchUserDirectory type --- responses.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/responses.go b/responses.go index 82ba003a..a79be28b 100644 --- a/responses.go +++ b/responses.go @@ -214,8 +214,8 @@ func (r *RespUserProfile) MarshalJSON() ([]byte, error) { } type RespSearchUserDirectory struct { - Limited bool `json:"limited"` - Results []*RespUserProfile `json:"results"` + Limited bool `json:"limited"` + Results []*UserDirectoryEntry `json:"results"` } type UserDirectoryEntry struct { From 23b18aa0ca2907903cf520be30e5541e1e3da82b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 22 Sep 2025 14:46:47 +0300 Subject: [PATCH 327/581] bridgev2/provisioning: fix login_id query param name --- bridgev2/matrix/provisioning.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/matrix/provisioning.yaml b/bridgev2/matrix/provisioning.yaml index 5bb27272..21c93ca4 100644 --- a/bridgev2/matrix/provisioning.yaml +++ b/bridgev2/matrix/provisioning.yaml @@ -400,7 +400,7 @@ components: - username - meow@example.com loginID: - name: loginID + name: login_id in: query description: An optional explicit login ID to do the action through. required: false From b3c883bc7fa39021bf8cfde6d51ce5860b8dded5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 22 Sep 2025 16:05:28 +0300 Subject: [PATCH 328/581] event: add beeper chat delete event --- event/beeper.go | 4 ++++ event/capabilities.d.ts | 5 +++++ event/capabilities.go | 12 +++++++----- event/content.go | 1 + event/type.go | 3 ++- 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/event/beeper.go b/event/beeper.go index 921e3466..95b4a571 100644 --- a/event/beeper.go +++ b/event/beeper.go @@ -86,6 +86,10 @@ type BeeperRoomKeyAckEventContent struct { FirstMessageIndex int `json:"first_message_index"` } +type BeeperChatDeleteEventContent struct { + DeleteForEveryone bool `json:"delete_for_everyone,omitempty"` +} + type IntOrString int func (ios *IntOrString) UnmarshalJSON(data []byte) error { diff --git a/event/capabilities.d.ts b/event/capabilities.d.ts index 27164a5f..37848575 100644 --- a/event/capabilities.d.ts +++ b/event/capabilities.d.ts @@ -55,6 +55,11 @@ export interface RoomFeatures { allowed_reactions?: string[] /** Whether custom emoji reactions are allowed. */ custom_emoji_reactions?: boolean + + /** Whether deleting the chat for yourself is supported. */ + delete_chat?: boolean + /** Whether deleting the chat for all participants is supported. */ + delete_chat_for_everyone?: boolean } declare type integer = number diff --git a/event/capabilities.go b/event/capabilities.go index 94662428..31a6b7aa 100644 --- a/event/capabilities.go +++ b/event/capabilities.go @@ -51,11 +51,12 @@ type RoomFeatures struct { AllowedReactions []string `json:"allowed_reactions,omitempty"` CustomEmojiReactions bool `json:"custom_emoji_reactions,omitempty"` - ReadReceipts bool `json:"read_receipts,omitempty"` - TypingNotifications bool `json:"typing_notifications,omitempty"` - Archive bool `json:"archive,omitempty"` - MarkAsUnread bool `json:"mark_as_unread,omitempty"` - DeleteChat bool `json:"delete_chat,omitempty"` + ReadReceipts bool `json:"read_receipts,omitempty"` + TypingNotifications bool `json:"typing_notifications,omitempty"` + Archive bool `json:"archive,omitempty"` + MarkAsUnread bool `json:"mark_as_unread,omitempty"` + DeleteChat bool `json:"delete_chat,omitempty"` + DeleteChatForEveryone bool `json:"delete_chat_for_everyone,omitempty"` } func (rf *RoomFeatures) GetID() string { @@ -262,6 +263,7 @@ func (rf *RoomFeatures) Hash() []byte { hashBool(hasher, "archive", rf.Archive) hashBool(hasher, "mark_as_unread", rf.MarkAsUnread) hashBool(hasher, "delete_chat", rf.DeleteChat) + hashBool(hasher, "delete_chat_for_everyone", rf.DeleteChatForEveryone) return hasher.Sum(nil) } diff --git a/event/content.go b/event/content.go index 5e093273..c0ff51ad 100644 --- a/event/content.go +++ b/event/content.go @@ -63,6 +63,7 @@ var TypeMap = map[Type]reflect.Type{ BeeperMessageStatus: reflect.TypeOf(BeeperMessageStatusEventContent{}), BeeperTranscription: reflect.TypeOf(BeeperTranscriptionEventContent{}), + BeeperDeleteChat: reflect.TypeOf(BeeperChatDeleteEventContent{}), AccountDataRoomTags: reflect.TypeOf(TagEventContent{}), AccountDataDirectChats: reflect.TypeOf(DirectChatsEventContent{}), diff --git a/event/type.go b/event/type.go index 1b4fbf76..56ea82f6 100644 --- a/event/type.go +++ b/event/type.go @@ -128,7 +128,7 @@ func (et *Type) GuessClass() TypeClass { InRoomVerificationKey.Type, InRoomVerificationMAC.Type, InRoomVerificationCancel.Type, CallInvite.Type, CallCandidates.Type, CallAnswer.Type, CallReject.Type, CallSelectAnswer.Type, CallNegotiate.Type, CallHangup.Type, BeeperMessageStatus.Type, EventUnstablePollStart.Type, EventUnstablePollResponse.Type, - EventUnstablePollEnd.Type, BeeperTranscription.Type: + EventUnstablePollEnd.Type, BeeperTranscription.Type, BeeperDeleteChat.Type: return MessageEventType case ToDeviceRoomKey.Type, ToDeviceRoomKeyRequest.Type, ToDeviceForwardedRoomKey.Type, ToDeviceRoomKeyWithheld.Type, ToDeviceBeeperRoomKeyAck.Type: @@ -236,6 +236,7 @@ var ( BeeperMessageStatus = Type{"com.beeper.message_send_status", MessageEventType} BeeperTranscription = Type{"com.beeper.transcription", MessageEventType} + BeeperDeleteChat = Type{"com.beeper.delete_chat", MessageEventType} EventUnstablePollStart = Type{Type: "org.matrix.msc3381.poll.start", Class: MessageEventType} EventUnstablePollResponse = Type{Type: "org.matrix.msc3381.poll.response", Class: MessageEventType} From a9ff1443f70678599bb74a2c941de23593827bb2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 22 Sep 2025 16:05:42 +0300 Subject: [PATCH 329/581] bridgev2: add interface for deleting chats from Matrix Closes #408 --- bridgev2/errors.go | 2 + bridgev2/matrix/connector.go | 1 + bridgev2/networkinterface.go | 9 ++++ bridgev2/portal.go | 83 +++++++++++++++++++++++++++++++----- 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/bridgev2/errors.go b/bridgev2/errors.go index 52bebe81..694224f1 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -44,6 +44,7 @@ var ( ErrNoPortal error = WrapErrorInStatus(errors.New("room is not a portal")).WithIsCertain(true).WithSendNotice(false) ErrIgnoringReactionFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring reaction event from relayed user")).WithIsCertain(true).WithSendNotice(false) ErrIgnoringPollFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring poll event from relayed user")).WithIsCertain(true).WithSendNotice(false) + ErrIgnoringDeleteChatRelayedUser error = WrapErrorInStatus(errors.New("ignoring delete chat event from relayed user")).WithIsCertain(true).WithSendNotice(false) ErrEditsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support edits")).WithIsCertain(true).WithErrorAsMessage() ErrEditsNotSupportedInPortal error = WrapErrorInStatus(errors.New("edits are not allowed in this chat")).WithIsCertain(true).WithErrorAsMessage() ErrCaptionsNotAllowed error = WrapErrorInStatus(errors.New("captions are not supported here")).WithIsCertain(true).WithErrorAsMessage() @@ -65,6 +66,7 @@ var ( ErrMediaReuploadFailed error = WrapErrorInStatus(errors.New("failed to reupload media")).WithMessage("failed to reupload media").WithIsCertain(true).WithSendNotice(true) ErrMediaConvertFailed error = WrapErrorInStatus(errors.New("failed to convert media")).WithMessage("failed to convert media").WithIsCertain(true).WithSendNotice(true) ErrMembershipNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group membership")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) + ErrDeleteChatNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting chats")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) ErrPowerLevelsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group power levels")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) ErrRemoteEchoTimeout = WrapErrorInStatus(errors.New("remote echo timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) ErrRemoteAckTimeout = WrapErrorInStatus(errors.New("remote ack timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index ab1764dd..3dd9ae1a 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -148,6 +148,7 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) { br.EventProcessor.On(event.StateTopic, br.handleRoomEvent) br.EventProcessor.On(event.StateTombstone, br.handleRoomEvent) br.EventProcessor.On(event.StateBeeperDisappearingTimer, br.handleRoomEvent) + br.EventProcessor.On(event.BeeperDeleteChat, br.handleRoomEvent) br.EventProcessor.On(event.EphemeralEventReceipt, br.handleEphemeralEvent) br.EventProcessor.On(event.EphemeralEventTyping, br.handleEphemeralEvent) br.Bot = br.AS.BotIntent() diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index fa87086a..8dffbb34 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -697,6 +697,14 @@ type DisappearTimerChangingNetworkAPI interface { HandleMatrixDisappearingTimer(ctx context.Context, msg *MatrixDisappearingTimer) (bool, error) } +// DeleteChatHandlingNetworkAPI is an optional interface that network connectors +// can implement to delete a chat from the remote network. +type DeleteChatHandlingNetworkAPI interface { + NetworkAPI + // HandleMatrixDeleteChat is called when the user explicitly deletes a chat. + HandleMatrixDeleteChat(ctx context.Context, msg *MatrixDeleteChat) error +} + type ResolveIdentifierResponse struct { // Ghost is the ghost of the user that the identifier resolves to. // This field should be set whenever possible. However, it is not required, @@ -1380,6 +1388,7 @@ type MatrixViewingChat struct { Portal *Portal } +type MatrixDeleteChat = MatrixEventBase[*event.BeeperChatDeleteEventContent] type MatrixMarkedUnread = MatrixRoomMeta[*event.MarkedUnreadEventContent] type MatrixMute = MatrixRoomMeta[*event.BeeperMuteEventContent] type MatrixRoomTag = MatrixRoomMeta[*event.TagEventContent] diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 575edfb8..f53691fa 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -706,6 +706,8 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * return portal.handleMatrixMembership(ctx, login, origSender, evt) case event.StatePowerLevels: return portal.handleMatrixPowerLevels(ctx, login, origSender, evt) + case event.BeeperDeleteChat: + return portal.handleMatrixDeleteChat(ctx, login, origSender, evt) default: return EventHandlingResultIgnored } @@ -1622,6 +1624,58 @@ func (portal *Portal) getTargetUser(ctx context.Context, userID id.UserID) (Ghos } } +func (portal *Portal) handleMatrixDeleteChat( + ctx context.Context, + sender *UserLogin, + origSender *OrigSender, + evt *event.Event, +) EventHandlingResult { + if origSender != nil { + return EventHandlingResultFailed.WithMSSError(ErrIgnoringDeleteChatRelayedUser) + } + log := zerolog.Ctx(ctx) + content, ok := evt.Content.Parsed.(*event.BeeperChatDeleteEventContent) + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + api, ok := sender.Client.(DeleteChatHandlingNetworkAPI) + if !ok { + return EventHandlingResultIgnored.WithMSSError(ErrDeleteChatNotSupported) + } + err := api.HandleMatrixDeleteChat(ctx, &MatrixDeleteChat{ + Event: evt, + Content: content, + Portal: portal, + }) + if err != nil { + log.Err(err).Msg("Failed to handle Matrix chat delete") + return EventHandlingResultFailed.WithMSSError(err) + } + if portal.Receiver == "" { + _, others, err := portal.findOtherLogins(ctx, sender) + if err != nil { + log.Err(err).Msg("Failed to check if portal has other logins") + return EventHandlingResultFailed.WithError(err) + } else if len(others) > 0 { + log.Debug().Msg("Not deleting portal after chat delete as other logins are present") + return EventHandlingResultSuccess + } + } + err = portal.Delete(ctx) + if err != nil { + log.Err(err).Msg("Failed to delete portal from database") + return EventHandlingResultFailed.WithMSSError(err) + } + err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, false) + if err != nil { + log.Err(err).Msg("Failed to delete Matrix room") + return EventHandlingResultFailed.WithMSSError(err) + } + // No MSS here as the portal was deleted + return EventHandlingResultSuccess +} + func (portal *Portal) handleMatrixMembership( ctx context.Context, sender *UserLogin, @@ -3160,11 +3214,11 @@ func (portal *Portal) handleRemoteMessageRemove(ctx context.Context, source *Use onlyForMeProvider, ok := evt.(RemoteDeleteOnlyForMe) onlyForMe := ok && onlyForMeProvider.DeleteOnlyForMe() if onlyForMe && portal.Receiver == "" { - logins, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) + _, others, err := portal.findOtherLogins(ctx, source) if err != nil { log.Err(err).Msg("Failed to check if portal has other logins") return EventHandlingResultFailed.WithError(err) - } else if len(logins) > 1 { + } else if len(others) > 0 { log.Debug().Msg("Ignoring delete for me event in portal with multiple logins") return EventHandlingResultIgnored } @@ -3413,22 +3467,29 @@ func (portal *Portal) handleRemoteChatResync(ctx context.Context, source *UserLo return EventHandlingResultSuccess } +func (portal *Portal) findOtherLogins(ctx context.Context, source *UserLogin) (ownUP *database.UserPortal, others []*database.UserPortal, err error) { + others, err = portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) + if err != nil { + return + } + others = slices.DeleteFunc(others, func(up *database.UserPortal) bool { + if up.LoginID == source.ID { + ownUP = up + return true + } + return false + }) + return +} + func (portal *Portal) handleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) EventHandlingResult { log := zerolog.Ctx(ctx) if portal.Receiver == "" && evt.DeleteOnlyForMe() { - logins, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) + ownUP, logins, err := portal.findOtherLogins(ctx, source) if err != nil { log.Err(err).Msg("Failed to check if portal has other logins") return EventHandlingResultFailed.WithError(err) } - var ownUP *database.UserPortal - logins = slices.DeleteFunc(logins, func(up *database.UserPortal) bool { - if up.LoginID == source.ID { - ownUP = up - return true - } - return false - }) if len(logins) > 0 { log.Debug().Msg("Not deleting portal with other logins in remote chat delete event") if ownUP != nil { From d5c6393f2350a941d4a7041d902473f79209fd81 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 22 Sep 2025 16:11:21 +0300 Subject: [PATCH 330/581] bridgev2/portal: don't process any more events if portal is deleted --- bridgev2/portal.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index f53691fa..4637d6ba 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -90,7 +90,8 @@ type Portal struct { functionalMembersLock sync.Mutex functionalMembersCache *event.ElementFunctionalMembersContent - events chan portalEvent + events chan portalEvent + deleted bool eventsLock sync.Mutex eventIdx int @@ -335,6 +336,9 @@ func (portal *Portal) eventLoop() { } i := 0 for rawEvt := range portal.events { + if portal.deleted { + return + } i++ if portal.Bridge.Config.AsyncEvents { go portal.handleSingleEventWithDelayLogging(i, rawEvt) @@ -4811,6 +4815,7 @@ func (portal *Portal) unlockedDeleteCache() { // TODO there's a small risk of this racing with a queueEvent call close(portal.events) } + portal.deleted = true } func (portal *Portal) Save(ctx context.Context) error { From a8b5fa91566f680a66fef022649e85113fc81f38 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 22 Sep 2025 16:27:01 +0300 Subject: [PATCH 331/581] client: fix some footguns in compileRequest * add warning log if RequestBody is used without length instead of silently discarding the body * fix wrapping RequestBody in nopcloser * always set content length --- client.go | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/client.go b/client.go index bf41ffb9..d274b007 100644 --- a/client.go +++ b/client.go @@ -418,8 +418,18 @@ var requestID int32 var logSensitiveContent = os.Getenv("MAUTRIX_LOG_SENSITIVE_CONTENT") == "yes" func (params *FullRequest) compileRequest(ctx context.Context) (*http.Request, error) { + reqID := atomic.AddInt32(&requestID, 1) + logger := zerolog.Ctx(ctx) + if logger.GetLevel() == zerolog.Disabled || logger == zerolog.DefaultContextLogger { + logger = params.Logger + } + ctx = logger.With(). + Int32("req_id", reqID). + Logger().WithContext(ctx) + var logBody any - reqBody := params.RequestBody + var reqBody io.Reader + var reqLen int64 if params.RequestJSON != nil { jsonStr, err := json.Marshal(params.RequestJSON) if err != nil { @@ -434,12 +444,22 @@ func (params *FullRequest) compileRequest(ctx context.Context) (*http.Request, e logBody = params.RequestJSON } reqBody = bytes.NewReader(jsonStr) + reqLen = int64(len(jsonStr)) } else if params.RequestBytes != nil { logBody = fmt.Sprintf("<%d bytes>", len(params.RequestBytes)) reqBody = bytes.NewReader(params.RequestBytes) - params.RequestLength = int64(len(params.RequestBytes)) - } else if params.RequestLength > 0 && params.RequestBody != nil { - logBody = fmt.Sprintf("<%d bytes>", params.RequestLength) + reqLen = int64(len(params.RequestBytes)) + } else if params.RequestBody != nil { + logBody = "" + reqLen = -1 + if params.RequestLength > 0 { + logBody = fmt.Sprintf("<%d bytes>", params.RequestLength) + reqLen = params.RequestLength + } else if params.RequestLength == 0 { + zerolog.Ctx(ctx).Warn(). + Msg("RequestBody passed without specifying request length") + } + reqBody = params.RequestBody if rsc, ok := params.RequestBody.(io.ReadSeekCloser); ok { // Prevent HTTP from closing the request body, it might be needed for retries reqBody = nopCloseSeeker{rsc} @@ -448,15 +468,8 @@ func (params *FullRequest) compileRequest(ctx context.Context) (*http.Request, e params.RequestJSON = struct{}{} logBody = params.RequestJSON reqBody = bytes.NewReader([]byte("{}")) + reqLen = 2 } - reqID := atomic.AddInt32(&requestID, 1) - logger := zerolog.Ctx(ctx) - if logger.GetLevel() == zerolog.Disabled || logger == zerolog.DefaultContextLogger { - logger = params.Logger - } - ctx = logger.With(). - Int32("req_id", reqID). - Logger().WithContext(ctx) ctx = context.WithValue(ctx, LogBodyContextKey, logBody) ctx = context.WithValue(ctx, LogRequestIDContextKey, int(reqID)) req, err := http.NewRequestWithContext(ctx, params.Method, params.URL, reqBody) @@ -472,9 +485,7 @@ func (params *FullRequest) compileRequest(ctx context.Context) (*http.Request, e if params.RequestJSON != nil { req.Header.Set("Content-Type", "application/json") } - if params.RequestLength > 0 && params.RequestBody != nil { - req.ContentLength = params.RequestLength - } + req.ContentLength = reqLen return req, nil } From 4635590fca48c9f7584db00e84bb02a12cdb79fc Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 22 Sep 2025 18:24:26 +0300 Subject: [PATCH 332/581] bridgev2/portal: add temporary flag to slack bridge info To let clients detect that https://github.com/mautrix/slack/commit/952806ea5204c420f771d0d51718384e4448370e is done --- bridgev2/portal.go | 3 +++ event/state.go | 2 ++ 2 files changed, 5 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 4637d6ba..5db45268 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -3822,6 +3822,9 @@ func (portal *Portal) getBridgeInfo() (string, event.BridgeEventContent) { if portal.RoomType == database.RoomTypeDM || portal.RoomType == database.RoomTypeGroupDM { bridgeInfo.BeeperRoomType = "dm" } + if bridgeInfo.Protocol.ID == "slackgo" { + bridgeInfo.TempSlackRemoteIDMigratedFlag = true + } parent := portal.GetTopLevelParent() if parent != nil { bridgeInfo.Network = &event.BridgeInfoSection{ diff --git a/event/state.go b/event/state.go index ba7c608d..ed5434c9 100644 --- a/event/state.go +++ b/event/state.go @@ -231,6 +231,8 @@ type BridgeEventContent struct { BeeperRoomType string `json:"com.beeper.room_type,omitempty"` BeeperRoomTypeV2 string `json:"com.beeper.room_type.v2,omitempty"` + + TempSlackRemoteIDMigratedFlag bool `json:"com.beeper.slack_remote_id_migrated,omitempty"` } // DisappearingType represents the type of a disappearing message timer. From 5c580a7859038f636fa517c6428e83252bfb46fa Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 22 Sep 2025 20:28:44 +0300 Subject: [PATCH 333/581] crypto/sqlstore: fix query used for olm unwedging --- crypto/sql_store.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto/sql_store.go b/crypto/sql_store.go index 4405cc31..13940d79 100644 --- a/crypto/sql_store.go +++ b/crypto/sql_store.go @@ -253,7 +253,7 @@ func (store *SQLCryptoStore) GetLatestSession(ctx context.Context, key id.Sender // GetNewestSessionCreationTS gets the creation timestamp of the most recently created session with the given sender key. // This will exclude sessions that have never been used to encrypt or decrypt a message. func (store *SQLCryptoStore) GetNewestSessionCreationTS(ctx context.Context, key id.SenderKey) (createdAt time.Time, err error) { - err = store.DB.QueryRow(ctx, "SELECT created_at FROM crypto_olm_session WHERE sender_key=$1 AND account_id=$2 AND (encrypted_at <> created_at OR decrypted_at <> created_at) ORDER BY created_at DESC LIMIT 1", + err = store.DB.QueryRow(ctx, "SELECT created_at FROM crypto_olm_session WHERE sender_key=$1 AND account_id=$2 AND (last_encrypted <> created_at OR last_decrypted <> created_at) ORDER BY created_at DESC LIMIT 1", key, store.AccountID).Scan(&createdAt) if errors.Is(err, sql.ErrNoRows) { err = nil From cf29b07f32ceedaad4e4511629eb436670c897b4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 24 Sep 2025 20:29:49 +0300 Subject: [PATCH 334/581] appservice/websocket: use io.ReadAll instead of json decoder --- appservice/websocket.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/appservice/websocket.go b/appservice/websocket.go index 309cc485..1e401c53 100644 --- a/appservice/websocket.go +++ b/appservice/websocket.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "path/filepath" @@ -292,10 +293,16 @@ func (as *AppService) consumeWebsocket(ctx context.Context, stopFunc func(error) as.Log.Debug().Msg("Ignoring non-text message from websocket") continue } - var msg WebsocketMessage - err = json.NewDecoder(reader).Decode(&msg) + data, err := io.ReadAll(reader) if err != nil { - as.Log.Debug().Err(err).Msg("Error reading JSON from websocket") + as.Log.Debug().Err(err).Msg("Error reading data from websocket") + stopFunc(parseCloseError(err)) + return + } + var msg WebsocketMessage + err = json.Unmarshal(data, &msg) + if err != nil { + as.Log.Debug().Err(err).Msg("Error parsing JSON received from websocket") stopFunc(parseCloseError(err)) return } From b0481d4b4368eccb0ea2e6441832e71e100ec1e3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 26 Sep 2025 12:55:36 +0300 Subject: [PATCH 335/581] client: re-add support for unstable profile fields --- client.go | 9 +++++++++ versions.go | 17 ++++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/client.go b/client.go index d274b007..62843218 100644 --- a/client.go +++ b/client.go @@ -1125,6 +1125,9 @@ func (cli *Client) SetDisplayName(ctx context.Context, displayName string) (err // SetProfileField sets an arbitrary profile field. See https://spec.matrix.org/v1.16/client-server-api/#put_matrixclientv3profileuseridkeyname func (cli *Client) SetProfileField(ctx context.Context, key string, value any) (err error) { urlPath := cli.BuildClientURL("v3", "profile", cli.UserID, key) + if key != "displayname" && key != "avatar_url" && !cli.SpecVersions.Supports(FeatureArbitraryProfileFields) && cli.SpecVersions.Supports(FeatureUnstableProfileFields) { + urlPath = cli.BuildClientURL("unstable", "uk.tcpip.msc4133", "profile", cli.UserID, key) + } _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, map[string]any{ key: value, }, nil) @@ -1134,6 +1137,9 @@ func (cli *Client) SetProfileField(ctx context.Context, key string, value any) ( // DeleteProfileField deletes an arbitrary profile field. See https://spec.matrix.org/v1.16/client-server-api/#put_matrixclientv3profileuseridkeyname func (cli *Client) DeleteProfileField(ctx context.Context, key string) (err error) { urlPath := cli.BuildClientURL("v3", "profile", cli.UserID, key) + if key != "displayname" && key != "avatar_url" && !cli.SpecVersions.Supports(FeatureArbitraryProfileFields) && cli.SpecVersions.Supports(FeatureUnstableProfileFields) { + urlPath = cli.BuildClientURL("unstable", "uk.tcpip.msc4133", "profile", cli.UserID, key) + } _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, nil) return } @@ -1141,6 +1147,9 @@ func (cli *Client) DeleteProfileField(ctx context.Context, key string) (err erro // GetProfileField gets an arbitrary profile field and parses the response into the given struct. See https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv3profileuseridkeyname func (cli *Client) GetProfileField(ctx context.Context, userID id.UserID, key string, into any) (err error) { urlPath := cli.BuildClientURL("v3", "profile", userID, key) + if key != "displayname" && key != "avatar_url" && !cli.SpecVersions.Supports(FeatureArbitraryProfileFields) && cli.SpecVersions.Supports(FeatureUnstableProfileFields) { + urlPath = cli.BuildClientURL("unstable", "uk.tcpip.msc4133", "profile", cli.UserID, key) + } _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, into) return } diff --git a/versions.go b/versions.go index c3be86cc..0392532e 100644 --- a/versions.go +++ b/versions.go @@ -60,13 +60,15 @@ type UnstableFeature struct { } var ( - FeatureAsyncUploads = UnstableFeature{UnstableFlag: "fi.mau.msc2246.stable", SpecVersion: SpecV17} - FeatureAppservicePing = UnstableFeature{UnstableFlag: "fi.mau.msc2659.stable", SpecVersion: SpecV17} - FeatureAuthenticatedMedia = UnstableFeature{UnstableFlag: "org.matrix.msc3916.stable", SpecVersion: SpecV111} - FeatureMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms"} - FeatureUserRedaction = UnstableFeature{UnstableFlag: "org.matrix.msc4194"} - FeatureViewRedactedContent = UnstableFeature{UnstableFlag: "fi.mau.msc2815"} - FeatureAccountModeration = UnstableFeature{UnstableFlag: "uk.timedout.msc4323"} + FeatureAsyncUploads = UnstableFeature{UnstableFlag: "fi.mau.msc2246.stable", SpecVersion: SpecV17} + FeatureAppservicePing = UnstableFeature{UnstableFlag: "fi.mau.msc2659.stable", SpecVersion: SpecV17} + FeatureAuthenticatedMedia = UnstableFeature{UnstableFlag: "org.matrix.msc3916.stable", SpecVersion: SpecV111} + FeatureMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms"} + FeatureUserRedaction = UnstableFeature{UnstableFlag: "org.matrix.msc4194"} + FeatureViewRedactedContent = UnstableFeature{UnstableFlag: "fi.mau.msc2815"} + FeatureAccountModeration = UnstableFeature{UnstableFlag: "uk.timedout.msc4323"} + FeatureUnstableProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133"} + FeatureArbitraryProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133.stable", SpecVersion: SpecV116} BeeperFeatureHungry = UnstableFeature{UnstableFlag: "com.beeper.hungry"} BeeperFeatureBatchSending = UnstableFeature{UnstableFlag: "com.beeper.batch_sending"} @@ -118,6 +120,7 @@ var ( SpecV113 = MustParseSpecVersion("v1.13") SpecV114 = MustParseSpecVersion("v1.14") SpecV115 = MustParseSpecVersion("v1.15") + SpecV116 = MustParseSpecVersion("v1.16") ) func (svf SpecVersionFormat) String() string { From 0685bd778619fd05d6455f1479d6e2369515f691 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 26 Sep 2025 16:56:48 +0300 Subject: [PATCH 336/581] crypto/verificationhelper: extract mockserver to new package --- .../verificationhelper_qr_crosssign_test.go | 13 ++- .../verificationhelper_qr_self_test.go | 32 +++--- .../verificationhelper_sas_test.go | 46 ++++---- .../verificationhelper_test.go | 56 +++++----- .../mockserver.go | 100 ++++++++---------- 5 files changed, 117 insertions(+), 130 deletions(-) rename crypto/verificationhelper/mockserver_test.go => mockserver/mockserver.go (68%) diff --git a/crypto/verificationhelper/verificationhelper_qr_crosssign_test.go b/crypto/verificationhelper/verificationhelper_qr_crosssign_test.go index aace2230..5e3f146b 100644 --- a/crypto/verificationhelper/verificationhelper_qr_crosssign_test.go +++ b/crypto/verificationhelper/verificationhelper_qr_crosssign_test.go @@ -32,7 +32,6 @@ func TestCrossSignVerification_ScanQRAndConfirmScan(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("sendingScansQR=%t", tc.sendingScansQR), func(t *testing.T) { ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginAliceBob(t, ctx) - defer ts.Close() sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) var err error @@ -51,10 +50,10 @@ func TestCrossSignVerification_ScanQRAndConfirmScan(t *testing.T) { // event on the sending device. txnID, err := sendingHelper.StartVerification(ctx, bobUserID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) err = receivingHelper.AcceptVerification(ctx, txnID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) receivingShownQRCode := receivingCallbacks.GetQRCodeShown(txnID) require.NotNil(t, receivingShownQRCode) @@ -83,7 +82,7 @@ func TestCrossSignVerification_ScanQRAndConfirmScan(t *testing.T) { // Handle the start and done events on the receiving client and // confirm the scan. - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) // Ensure that the receiving device detected that its QR code // was scanned. @@ -98,7 +97,7 @@ func TestCrossSignVerification_ScanQRAndConfirmScan(t *testing.T) { doneEvt = sendingInbox[0].Content.AsVerificationDone() assert.Equal(t, txnID, doneEvt.TransactionID) - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) } else { // receiving scans QR // Emulate scanning the QR code shown by the sending device on // the receiving device. @@ -121,7 +120,7 @@ func TestCrossSignVerification_ScanQRAndConfirmScan(t *testing.T) { // Handle the start and done events on the receiving client and // confirm the scan. - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) // Ensure that the sending device detected that its QR code was // scanned. @@ -136,7 +135,7 @@ func TestCrossSignVerification_ScanQRAndConfirmScan(t *testing.T) { doneEvt = receivingInbox[0].Content.AsVerificationDone() assert.Equal(t, txnID, doneEvt.TransactionID) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) } // Ensure that both devices have marked the verification as done. diff --git a/crypto/verificationhelper/verificationhelper_qr_self_test.go b/crypto/verificationhelper/verificationhelper_qr_self_test.go index 937cc414..ea918cd4 100644 --- a/crypto/verificationhelper/verificationhelper_qr_self_test.go +++ b/crypto/verificationhelper/verificationhelper_qr_self_test.go @@ -36,7 +36,6 @@ func TestSelfVerification_Accept_QRContents(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("sendingGenerated=%t receivingGenerated=%t err=%s", tc.sendingGeneratedCrossSigningKeys, tc.receivingGeneratedCrossSigningKeys, tc.expectedAcceptError), func(t *testing.T) { ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - defer ts.Close() sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) var err error @@ -62,7 +61,7 @@ func TestSelfVerification_Accept_QRContents(t *testing.T) { // event on the sending device. txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) err = receivingHelper.AcceptVerification(ctx, txnID) if tc.expectedAcceptError != "" { @@ -72,7 +71,7 @@ func TestSelfVerification_Accept_QRContents(t *testing.T) { require.NoError(t, err) } - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) receivingShownQRCode := receivingCallbacks.GetQRCodeShown(txnID) require.NotNil(t, receivingShownQRCode) @@ -135,7 +134,6 @@ func TestSelfVerification_ScanQRAndConfirmScan(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("sendingGeneratedCrossSigningKeys=%t sendingScansQR=%t", tc.sendingGeneratedCrossSigningKeys, tc.sendingScansQR), func(t *testing.T) { ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - defer ts.Close() sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) var err error @@ -152,10 +150,10 @@ func TestSelfVerification_ScanQRAndConfirmScan(t *testing.T) { // event on the sending device. txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) err = receivingHelper.AcceptVerification(ctx, txnID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) receivingShownQRCode := receivingCallbacks.GetQRCodeShown(txnID) require.NotNil(t, receivingShownQRCode) @@ -184,7 +182,7 @@ func TestSelfVerification_ScanQRAndConfirmScan(t *testing.T) { // Handle the start and done events on the receiving client and // confirm the scan. - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) // Ensure that the receiving device detected that its QR code // was scanned. @@ -199,7 +197,7 @@ func TestSelfVerification_ScanQRAndConfirmScan(t *testing.T) { doneEvt = sendingInbox[0].Content.AsVerificationDone() assert.Equal(t, txnID, doneEvt.TransactionID) - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) } else { // receiving scans QR // Emulate scanning the QR code shown by the sending device on // the receiving device. @@ -222,7 +220,7 @@ func TestSelfVerification_ScanQRAndConfirmScan(t *testing.T) { // Handle the start and done events on the receiving client and // confirm the scan. - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) // Ensure that the sending device detected that its QR code was // scanned. @@ -237,7 +235,7 @@ func TestSelfVerification_ScanQRAndConfirmScan(t *testing.T) { doneEvt = receivingInbox[0].Content.AsVerificationDone() assert.Equal(t, txnID, doneEvt.TransactionID) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) } // Ensure that both devices have marked the verification as done. @@ -251,7 +249,6 @@ func TestSelfVerification_ScanQRTransactionIDCorrupted(t *testing.T) { ctx := log.Logger.WithContext(context.TODO()) ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - defer ts.Close() sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) var err error @@ -263,10 +260,10 @@ func TestSelfVerification_ScanQRTransactionIDCorrupted(t *testing.T) { // event on the sending device. txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) err = receivingHelper.AcceptVerification(ctx, txnID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) receivingShownQRCodeBytes := receivingCallbacks.GetQRCodeShown(txnID).Bytes() sendingShownQRCodeBytes := sendingCallbacks.GetQRCodeShown(txnID).Bytes() @@ -310,7 +307,6 @@ func TestSelfVerification_ScanQRKeyCorrupted(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("sendingGeneratedCrossSigningKeys=%t sendingScansQR=%t corrupt=%d", tc.sendingGeneratedCrossSigningKeys, tc.sendingScansQR, tc.corruptByte), func(t *testing.T) { ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - defer ts.Close() sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) var err error @@ -327,10 +323,10 @@ func TestSelfVerification_ScanQRKeyCorrupted(t *testing.T) { // event on the sending device. txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) err = receivingHelper.AcceptVerification(ctx, txnID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) receivingShownQRCodeBytes := receivingCallbacks.GetQRCodeShown(txnID).Bytes() sendingShownQRCodeBytes := sendingCallbacks.GetQRCodeShown(txnID).Bytes() @@ -348,7 +344,7 @@ func TestSelfVerification_ScanQRKeyCorrupted(t *testing.T) { // Ensure that the receiving device received a cancellation. receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] assert.Len(t, receivingInbox, 1) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) cancellation := receivingCallbacks.GetVerificationCancellation(txnID) require.NotNil(t, cancellation) assert.Equal(t, event.VerificationCancelCodeKeyMismatch, cancellation.Code) @@ -362,7 +358,7 @@ func TestSelfVerification_ScanQRKeyCorrupted(t *testing.T) { // Ensure that the sending device received a cancellation. sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] assert.Len(t, sendingInbox, 1) - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) cancellation := sendingCallbacks.GetVerificationCancellation(txnID) require.NotNil(t, cancellation) assert.Equal(t, event.VerificationCancelCodeKeyMismatch, cancellation.Code) diff --git a/crypto/verificationhelper/verificationhelper_sas_test.go b/crypto/verificationhelper/verificationhelper_sas_test.go index 5747ac34..283eca84 100644 --- a/crypto/verificationhelper/verificationhelper_sas_test.go +++ b/crypto/verificationhelper/verificationhelper_sas_test.go @@ -36,7 +36,6 @@ func TestVerification_SAS(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("sendingGenerated=%t sendingStartsSAS=%t sendingConfirmsFirst=%t", tc.sendingGeneratedCrossSigningKeys, tc.sendingStartsSAS, tc.sendingConfirmsFirst), func(t *testing.T) { ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - defer ts.Close() sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) var err error @@ -60,10 +59,10 @@ func TestVerification_SAS(t *testing.T) { // event on the sending device. txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) err = receivingHelper.AcceptVerification(ctx, txnID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) // Test that the start event is correct var startEvt *event.VerificationStartEventContent @@ -102,7 +101,7 @@ func TestVerification_SAS(t *testing.T) { if tc.sendingStartsSAS { // Process the verification start event on the receiving // device. - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) // Receiving device sent the accept event to the sending device sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] @@ -110,7 +109,7 @@ func TestVerification_SAS(t *testing.T) { acceptEvt = sendingInbox[0].Content.AsVerificationAccept() } else { // Process the verification start event on the sending device. - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) // Sending device sent the accept event to the receiving device receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] @@ -129,7 +128,7 @@ func TestVerification_SAS(t *testing.T) { var firstKeyEvt *event.VerificationKeyEventContent if tc.sendingStartsSAS { // Process the verification accept event on the sending device. - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) // Sending device sends first key event to the receiving // device. @@ -139,7 +138,7 @@ func TestVerification_SAS(t *testing.T) { } else { // Process the verification accept event on the receiving // device. - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) // Receiving device sends first key event to the sending // device. @@ -155,7 +154,7 @@ func TestVerification_SAS(t *testing.T) { var secondKeyEvt *event.VerificationKeyEventContent if tc.sendingStartsSAS { // Process the first key event on the receiving device. - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) // Receiving device sends second key event to the sending // device. @@ -170,7 +169,7 @@ func TestVerification_SAS(t *testing.T) { assert.Len(t, descriptions, 7) } else { // Process the first key event on the sending device. - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) // Sending device sends second key event to the receiving // device. @@ -191,10 +190,10 @@ func TestVerification_SAS(t *testing.T) { // Ensure that the SAS codes are the same. if tc.sendingStartsSAS { // Process the second key event on the sending device. - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) } else { // Process the second key event on the receiving device. - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) } assert.Equal(t, sendingCallbacks.GetDecimalsShown(txnID), receivingCallbacks.GetDecimalsShown(txnID)) sendingEmojis, sendingDescriptions := sendingCallbacks.GetEmojisAndDescriptionsShown(txnID) @@ -274,10 +273,10 @@ func TestVerification_SAS(t *testing.T) { // Test the transaction is done on both sides. We have to dispatch // twice to process and drain all of the events. - ts.dispatchToDevice(t, ctx, sendingClient) - ts.dispatchToDevice(t, ctx, receivingClient) - ts.dispatchToDevice(t, ctx, sendingClient) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, receivingClient) assert.True(t, sendingCallbacks.IsVerificationDone(txnID)) assert.True(t, receivingCallbacks.IsVerificationDone(txnID)) }) @@ -288,7 +287,6 @@ func TestVerification_SAS_BothCallStart(t *testing.T) { ctx := log.Logger.WithContext(context.TODO()) ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - defer ts.Close() sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) var err error @@ -305,10 +303,10 @@ func TestVerification_SAS_BothCallStart(t *testing.T) { // event on the sending device. txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) err = receivingHelper.AcceptVerification(ctx, txnID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) err = sendingHelper.StartSAS(ctx, txnID) require.NoError(t, err) @@ -325,7 +323,7 @@ func TestVerification_SAS_BothCallStart(t *testing.T) { assert.Equal(t, txnID, sendingInbox[0].Content.AsVerificationStart().TransactionID) // Process the start event from the receiving client to the sending client. - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) receivingInbox = ts.DeviceInbox[aliceUserID][receivingDeviceID] assert.Len(t, receivingInbox, 2) assert.Equal(t, txnID, receivingInbox[0].Content.AsVerificationStart().TransactionID) @@ -333,13 +331,13 @@ func TestVerification_SAS_BothCallStart(t *testing.T) { // Process the rest of the events until we need to confirm the SAS. for len(ts.DeviceInbox[aliceUserID][sendingDeviceID]) > 0 || len(ts.DeviceInbox[aliceUserID][receivingDeviceID]) > 0 { - ts.dispatchToDevice(t, ctx, receivingClient) - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, sendingClient) } // Confirm the SAS only the receiving device. receivingHelper.ConfirmSAS(ctx, txnID) - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) // Verification is not done until both devices confirm the SAS. assert.False(t, sendingCallbacks.IsVerificationDone(txnID)) @@ -350,13 +348,13 @@ func TestVerification_SAS_BothCallStart(t *testing.T) { // Dispatching the events to the receiving device should get us to the done // state on the receiving device. - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) assert.False(t, sendingCallbacks.IsVerificationDone(txnID)) assert.True(t, receivingCallbacks.IsVerificationDone(txnID)) // Dispatching the events to the sending client should get us to the done // state on the sending device. - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) assert.True(t, sendingCallbacks.IsVerificationDone(txnID)) assert.True(t, receivingCallbacks.IsVerificationDone(txnID)) } diff --git a/crypto/verificationhelper/verificationhelper_test.go b/crypto/verificationhelper/verificationhelper_test.go index b4c21c18..ce5ec5b4 100644 --- a/crypto/verificationhelper/verificationhelper_test.go +++ b/crypto/verificationhelper/verificationhelper_test.go @@ -19,6 +19,7 @@ import ( "maunium.net/go/mautrix/crypto/verificationhelper" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/mockserver" ) var aliceUserID = id.UserID("@alice:example.org") @@ -31,9 +32,19 @@ func init() { zerolog.DefaultContextLogger = &log.Logger } -func initServerAndLoginTwoAlice(t *testing.T, ctx context.Context) (ts *mockServer, sendingClient, receivingClient *mautrix.Client, sendingCryptoStore, receivingCryptoStore crypto.Store, sendingMachine, receivingMachine *crypto.OlmMachine) { +func addDeviceID(ctx context.Context, cryptoStore crypto.Store, userID id.UserID, deviceID id.DeviceID) { + err := cryptoStore.PutDevice(ctx, userID, &id.Device{ + UserID: userID, + DeviceID: deviceID, + }) + if err != nil { + panic(err) + } +} + +func initServerAndLoginTwoAlice(t *testing.T, ctx context.Context) (ts *mockserver.MockServer, sendingClient, receivingClient *mautrix.Client, sendingCryptoStore, receivingCryptoStore crypto.Store, sendingMachine, receivingMachine *crypto.OlmMachine) { t.Helper() - ts = createMockServer(t) + ts = mockserver.Create(t) sendingClient, sendingCryptoStore = ts.Login(t, ctx, aliceUserID, sendingDeviceID) sendingMachine = sendingClient.Crypto.(*cryptohelper.CryptoHelper).Machine() @@ -47,9 +58,9 @@ func initServerAndLoginTwoAlice(t *testing.T, ctx context.Context) (ts *mockServ return } -func initServerAndLoginAliceBob(t *testing.T, ctx context.Context) (ts *mockServer, sendingClient, receivingClient *mautrix.Client, sendingCryptoStore, receivingCryptoStore crypto.Store, sendingMachine, receivingMachine *crypto.OlmMachine) { +func initServerAndLoginAliceBob(t *testing.T, ctx context.Context) (ts *mockserver.MockServer, sendingClient, receivingClient *mautrix.Client, sendingCryptoStore, receivingCryptoStore crypto.Store, sendingMachine, receivingMachine *crypto.OlmMachine) { t.Helper() - ts = createMockServer(t) + ts = mockserver.Create(t) sendingClient, sendingCryptoStore = ts.Login(t, ctx, aliceUserID, sendingDeviceID) sendingMachine = sendingClient.Crypto.(*cryptohelper.CryptoHelper).Machine() @@ -116,8 +127,7 @@ func TestVerification_Start(t *testing.T) { for i, tc := range testCases { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { - ts := createMockServer(t) - defer ts.Close() + ts := mockserver.Create(t) client, cryptoStore := ts.Login(t, ctx, aliceUserID, sendingDeviceID) addDeviceID(ctx, cryptoStore, aliceUserID, sendingDeviceID) @@ -166,7 +176,6 @@ func TestVerification_StartThenCancel(t *testing.T) { for _, sendingCancels := range []bool{true, false} { t.Run(fmt.Sprintf("sendingCancels=%t", sendingCancels), func(t *testing.T) { ts, sendingClient, receivingClient, sendingCryptoStore, receivingCryptoStore, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - defer ts.Close() _, _, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) bystanderClient, _ := ts.Login(t, ctx, aliceUserID, bystanderDeviceID) @@ -186,13 +195,13 @@ func TestVerification_StartThenCancel(t *testing.T) { receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] assert.Len(t, receivingInbox, 1) assert.Equal(t, txnID, receivingInbox[0].Content.AsVerificationRequest().TransactionID) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) // Process the request event on the bystander device. bystanderInbox := ts.DeviceInbox[aliceUserID][bystanderDeviceID] assert.Len(t, bystanderInbox, 1) assert.Equal(t, txnID, bystanderInbox[0].Content.AsVerificationRequest().TransactionID) - ts.dispatchToDevice(t, ctx, bystanderClient) + ts.DispatchToDevice(t, ctx, bystanderClient) // Cancel the verification request. var cancelEvt *event.VerificationCancelEventContent @@ -231,7 +240,7 @@ func TestVerification_StartThenCancel(t *testing.T) { if !sendingCancels { // Process the cancellation event on the sending device. - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) // Ensure that the cancellation event was sent to the bystander device. assert.Len(t, ts.DeviceInbox[aliceUserID][bystanderDeviceID], 1) @@ -247,8 +256,7 @@ func TestVerification_StartThenCancel(t *testing.T) { func TestVerification_Accept_NoSupportedMethods(t *testing.T) { ctx := log.Logger.WithContext(context.TODO()) - ts := createMockServer(t) - defer ts.Close() + ts := mockserver.Create(t) sendingClient, sendingCryptoStore := ts.Login(t, ctx, aliceUserID, sendingDeviceID) receivingClient, _ := ts.Login(t, ctx, aliceUserID, receivingDeviceID) @@ -274,7 +282,7 @@ func TestVerification_Accept_NoSupportedMethods(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, txnID) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) // Ensure that the receiver ignored the request because it // doesn't support any of the verification methods in the @@ -314,7 +322,6 @@ func TestVerification_Accept_CorrectMethodsPresented(t *testing.T) { for i, tc := range testCases { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - defer ts.Close() recoveryKey, sendingCrossSigningKeysCache, err := sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") assert.NoError(t, err) @@ -333,7 +340,7 @@ func TestVerification_Accept_CorrectMethodsPresented(t *testing.T) { require.NoError(t, err) // Process the verification request on the receiving device. - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) // Ensure that the receiving device received a verification // request with the correct transaction ID. @@ -373,7 +380,7 @@ func TestVerification_Accept_CorrectMethodsPresented(t *testing.T) { // Receive the m.key.verification.ready event on the sending // device. - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) // Ensure that the sending device got a notification about the // transaction being ready. @@ -402,7 +409,6 @@ func TestVerification_Accept_CorrectMethodsPresented(t *testing.T) { func TestVerification_Accept_CancelOnNonParticipatingDevices(t *testing.T) { ctx := log.Logger.WithContext(context.TODO()) ts, sendingClient, receivingClient, sendingCryptoStore, receivingCryptoStore, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - defer ts.Close() _, _, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) nonParticipatingDeviceID1 := id.DeviceID("non-participating1") @@ -419,12 +425,12 @@ func TestVerification_Accept_CancelOnNonParticipatingDevices(t *testing.T) { // the receiving device. txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) err = receivingHelper.AcceptVerification(ctx, txnID) require.NoError(t, err) // Receive the m.key.verification.ready event on the sending device. - ts.dispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, sendingClient) // The sending and receiving devices should not have any cancellation // events in their inboxes. @@ -444,7 +450,6 @@ func TestVerification_Accept_CancelOnNonParticipatingDevices(t *testing.T) { func TestVerification_ErrorOnDoubleAccept(t *testing.T) { ctx := log.Logger.WithContext(context.TODO()) ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - defer ts.Close() _, _, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) _, _, err := sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") @@ -452,7 +457,7 @@ func TestVerification_ErrorOnDoubleAccept(t *testing.T) { txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) err = receivingHelper.AcceptVerification(ctx, txnID) require.NoError(t, err) err = receivingHelper.AcceptVerification(ctx, txnID) @@ -472,7 +477,6 @@ func TestVerification_ErrorOnDoubleAccept(t *testing.T) { func TestVerification_CancelOnDoubleStart(t *testing.T) { ctx := log.Logger.WithContext(context.TODO()) ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - defer ts.Close() sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) _, _, err := sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") @@ -481,15 +485,15 @@ func TestVerification_CancelOnDoubleStart(t *testing.T) { // Send and accept the first verification request. txnID1, err := sendingHelper.StartVerification(ctx, aliceUserID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) err = receivingHelper.AcceptVerification(ctx, txnID1) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, sendingClient) // Process the m.key.verification.ready event + ts.DispatchToDevice(t, ctx, sendingClient) // Process the m.key.verification.ready event // Send a second verification request txnID2, err := sendingHelper.StartVerification(ctx, aliceUserID) require.NoError(t, err) - ts.dispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, receivingClient) // Ensure that the sending device received a cancellation event for both of // the ongoing transactions. @@ -507,7 +511,7 @@ func TestVerification_CancelOnDoubleStart(t *testing.T) { assert.NotNil(t, receivingCallbacks.GetVerificationCancellation(txnID1)) assert.NotNil(t, receivingCallbacks.GetVerificationCancellation(txnID2)) - ts.dispatchToDevice(t, ctx, sendingClient) // Process the m.key.verification.cancel events + ts.DispatchToDevice(t, ctx, sendingClient) // Process the m.key.verification.cancel events assert.NotNil(t, sendingCallbacks.GetVerificationCancellation(txnID1)) assert.NotNil(t, sendingCallbacks.GetVerificationCancellation(txnID2)) } diff --git a/crypto/verificationhelper/mockserver_test.go b/mockserver/mockserver.go similarity index 68% rename from crypto/verificationhelper/mockserver_test.go rename to mockserver/mockserver.go index 45ca7781..9f62b567 100644 --- a/crypto/verificationhelper/mockserver_test.go +++ b/mockserver/mockserver.go @@ -1,10 +1,10 @@ -// Copyright (c) 2024 Sumner Evans +// Copyright (c) 2025 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. -package verificationhelper_test +package mockserver import ( "context" @@ -15,7 +15,7 @@ import ( "strings" "testing" - "github.com/rs/zerolog/log" // zerolog-allow-global-log + globallog "github.com/rs/zerolog/log" // zerolog-allow-global-log "github.com/stretchr/testify/require" "go.mau.fi/util/random" @@ -26,10 +26,9 @@ import ( "maunium.net/go/mautrix/id" ) -// mockServer is a mock Matrix server that wraps an [httptest.Server] to allow -// testing of the interactive verification process. -type mockServer struct { - *httptest.Server +type MockServer struct { + Router *http.ServeMux + Server *httptest.Server AccessTokenToUserID map[string]id.UserID DeviceInbox map[id.UserID]map[id.DeviceID][]event.Event @@ -40,10 +39,10 @@ type mockServer struct { UserSigningKeys map[id.UserID]mautrix.CrossSigningKeys } -func createMockServer(t *testing.T) *mockServer { +func Create(t *testing.T) *MockServer { t.Helper() - server := mockServer{ + server := MockServer{ AccessTokenToUserID: map[string]id.UserID{}, DeviceInbox: map[id.UserID]map[id.DeviceID][]event.Event{}, AccountData: map[id.UserID]map[event.Type]json.RawMessage{}, @@ -61,12 +60,13 @@ func createMockServer(t *testing.T) *mockServer { router.HandleFunc("POST /_matrix/client/v3/keys/device_signing/upload", server.postDeviceSigningUpload) router.HandleFunc("POST /_matrix/client/v3/keys/signatures/upload", server.emptyResp) router.HandleFunc("POST /_matrix/client/v3/keys/upload", server.postKeysUpload) - + server.Router = router server.Server = httptest.NewServer(router) + t.Cleanup(server.Server.Close) return &server } -func (ms *mockServer) getUserID(r *http.Request) id.UserID { +func (ms *MockServer) getUserID(r *http.Request) id.UserID { authHeader := r.Header.Get("Authorization") authHeader = strings.TrimPrefix(authHeader, "Bearer ") userID, ok := ms.AccessTokenToUserID[authHeader] @@ -76,11 +76,11 @@ func (ms *mockServer) getUserID(r *http.Request) id.UserID { return userID } -func (s *mockServer) emptyResp(w http.ResponseWriter, _ *http.Request) { +func (ms *MockServer) emptyResp(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("{}")) } -func (s *mockServer) postLogin(w http.ResponseWriter, r *http.Request) { +func (ms *MockServer) postLogin(w http.ResponseWriter, r *http.Request) { var loginReq mautrix.ReqLogin json.NewDecoder(r.Body).Decode(&loginReq) @@ -91,7 +91,7 @@ func (s *mockServer) postLogin(w http.ResponseWriter, r *http.Request) { accessToken := random.String(30) userID := id.UserID(loginReq.Identifier.User) - s.AccessTokenToUserID[accessToken] = userID + ms.AccessTokenToUserID[accessToken] = userID json.NewEncoder(w).Encode(&mautrix.RespLogin{ AccessToken: accessToken, @@ -100,40 +100,40 @@ func (s *mockServer) postLogin(w http.ResponseWriter, r *http.Request) { }) } -func (s *mockServer) putSendToDevice(w http.ResponseWriter, r *http.Request) { +func (ms *MockServer) putSendToDevice(w http.ResponseWriter, r *http.Request) { var req mautrix.ReqSendToDevice json.NewDecoder(r.Body).Decode(&req) evtType := event.Type{Type: r.PathValue("type"), Class: event.ToDeviceEventType} for user, devices := range req.Messages { for device, content := range devices { - if _, ok := s.DeviceInbox[user]; !ok { - s.DeviceInbox[user] = map[id.DeviceID][]event.Event{} + if _, ok := ms.DeviceInbox[user]; !ok { + ms.DeviceInbox[user] = map[id.DeviceID][]event.Event{} } content.ParseRaw(evtType) - s.DeviceInbox[user][device] = append(s.DeviceInbox[user][device], event.Event{ - Sender: s.getUserID(r), + ms.DeviceInbox[user][device] = append(ms.DeviceInbox[user][device], event.Event{ + Sender: ms.getUserID(r), Type: evtType, Content: *content, }) } } - s.emptyResp(w, r) + ms.emptyResp(w, r) } -func (s *mockServer) putAccountData(w http.ResponseWriter, r *http.Request) { +func (ms *MockServer) putAccountData(w http.ResponseWriter, r *http.Request) { userID := id.UserID(r.PathValue("userID")) eventType := event.Type{Type: r.PathValue("type"), Class: event.AccountDataEventType} jsonData, _ := io.ReadAll(r.Body) - if _, ok := s.AccountData[userID]; !ok { - s.AccountData[userID] = map[event.Type]json.RawMessage{} + if _, ok := ms.AccountData[userID]; !ok { + ms.AccountData[userID] = map[event.Type]json.RawMessage{} } - s.AccountData[userID][eventType] = json.RawMessage(jsonData) - s.emptyResp(w, r) + ms.AccountData[userID][eventType] = json.RawMessage(jsonData) + ms.emptyResp(w, r) } -func (s *mockServer) postKeysQuery(w http.ResponseWriter, r *http.Request) { +func (ms *MockServer) postKeysQuery(w http.ResponseWriter, r *http.Request) { var req mautrix.ReqQueryKeys json.NewDecoder(r.Body).Decode(&req) resp := mautrix.RespQueryKeys{ @@ -143,44 +143,44 @@ func (s *mockServer) postKeysQuery(w http.ResponseWriter, r *http.Request) { DeviceKeys: map[id.UserID]map[id.DeviceID]mautrix.DeviceKeys{}, } for user := range req.DeviceKeys { - resp.MasterKeys[user] = s.MasterKeys[user] - resp.UserSigningKeys[user] = s.UserSigningKeys[user] - resp.SelfSigningKeys[user] = s.SelfSigningKeys[user] - resp.DeviceKeys[user] = s.DeviceKeys[user] + resp.MasterKeys[user] = ms.MasterKeys[user] + resp.UserSigningKeys[user] = ms.UserSigningKeys[user] + resp.SelfSigningKeys[user] = ms.SelfSigningKeys[user] + resp.DeviceKeys[user] = ms.DeviceKeys[user] } json.NewEncoder(w).Encode(&resp) } -func (s *mockServer) postKeysUpload(w http.ResponseWriter, r *http.Request) { +func (ms *MockServer) postKeysUpload(w http.ResponseWriter, r *http.Request) { var req mautrix.ReqUploadKeys json.NewDecoder(r.Body).Decode(&req) - userID := s.getUserID(r) - if _, ok := s.DeviceKeys[userID]; !ok { - s.DeviceKeys[userID] = map[id.DeviceID]mautrix.DeviceKeys{} + userID := ms.getUserID(r) + if _, ok := ms.DeviceKeys[userID]; !ok { + ms.DeviceKeys[userID] = map[id.DeviceID]mautrix.DeviceKeys{} } - s.DeviceKeys[userID][req.DeviceKeys.DeviceID] = *req.DeviceKeys + ms.DeviceKeys[userID][req.DeviceKeys.DeviceID] = *req.DeviceKeys json.NewEncoder(w).Encode(&mautrix.RespUploadKeys{ OneTimeKeyCounts: mautrix.OTKCount{SignedCurve25519: 50}, }) } -func (s *mockServer) postDeviceSigningUpload(w http.ResponseWriter, r *http.Request) { +func (ms *MockServer) postDeviceSigningUpload(w http.ResponseWriter, r *http.Request) { var req mautrix.UploadCrossSigningKeysReq json.NewDecoder(r.Body).Decode(&req) - userID := s.getUserID(r) - s.MasterKeys[userID] = req.Master - s.SelfSigningKeys[userID] = req.SelfSigning - s.UserSigningKeys[userID] = req.UserSigning + userID := ms.getUserID(r) + ms.MasterKeys[userID] = req.Master + ms.SelfSigningKeys[userID] = req.SelfSigning + ms.UserSigningKeys[userID] = req.UserSigning - s.emptyResp(w, r) + ms.emptyResp(w, r) } -func (ms *mockServer) Login(t *testing.T, ctx context.Context, userID id.UserID, deviceID id.DeviceID) (*mautrix.Client, crypto.Store) { +func (ms *MockServer) Login(t *testing.T, ctx context.Context, userID id.UserID, deviceID id.DeviceID) (*mautrix.Client, crypto.Store) { t.Helper() - client, err := mautrix.NewClient(ms.URL, "", "") + client, err := mautrix.NewClient(ms.Server.URL, "", "") require.NoError(t, err) client.StateStore = mautrix.NewMemoryStateStore() @@ -204,7 +204,7 @@ func (ms *mockServer) Login(t *testing.T, ctx context.Context, userID id.UserID, err = cryptoHelper.Init(ctx) require.NoError(t, err) - machineLog := log.Logger.With(). + machineLog := globallog.Logger.With(). Stringer("my_user_id", userID). Stringer("my_device_id", deviceID). Logger() @@ -216,7 +216,7 @@ func (ms *mockServer) Login(t *testing.T, ctx context.Context, userID id.UserID, return client, cryptoStore } -func (ms *mockServer) dispatchToDevice(t *testing.T, ctx context.Context, client *mautrix.Client) { +func (ms *MockServer) DispatchToDevice(t *testing.T, ctx context.Context, client *mautrix.Client) { t.Helper() for _, evt := range ms.DeviceInbox[client.UserID][client.DeviceID] { @@ -224,13 +224,3 @@ func (ms *mockServer) dispatchToDevice(t *testing.T, ctx context.Context, client ms.DeviceInbox[client.UserID][client.DeviceID] = ms.DeviceInbox[client.UserID][client.DeviceID][1:] } } - -func addDeviceID(ctx context.Context, cryptoStore crypto.Store, userID id.UserID, deviceID id.DeviceID) { - err := cryptoStore.PutDevice(ctx, userID, &id.Device{ - UserID: userID, - DeviceID: deviceID, - }) - if err != nil { - panic(err) - } -} From caca057b2304679bc1875c9355547b59a326c9c0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 26 Sep 2025 19:17:16 +0300 Subject: [PATCH 337/581] crypto/helper: always share keys when creating new device --- crypto/cryptohelper/cryptohelper.go | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/crypto/cryptohelper/cryptohelper.go b/crypto/cryptohelper/cryptohelper.go index 56f8b484..74710678 100644 --- a/crypto/cryptohelper/cryptohelper.go +++ b/crypto/cryptohelper/cryptohelper.go @@ -225,13 +225,6 @@ func (helper *CryptoHelper) Init(ctx context.Context) error { helper.ASEventProcessor.On(event.EventEncrypted, helper.HandleEncrypted) } - if helper.client.SetAppServiceDeviceID { - err = helper.mach.ShareKeys(ctx, -1) - if err != nil { - return fmt.Errorf("failed to share keys: %w", err) - } - } - return nil } @@ -268,21 +261,21 @@ func (helper *CryptoHelper) verifyDeviceKeysOnServer(ctx context.Context) error if !ok || len(device.Keys) == 0 { if isShared { return fmt.Errorf("olm account is marked as shared, keys seem to have disappeared from the server") - } else { - helper.log.Debug().Msg("Olm account not shared and keys not on server, so device is probably fine") - return nil } + helper.log.Debug().Msg("Olm account not shared and keys not on server, sharing initial keys") + err = helper.mach.ShareKeys(ctx, -1) + if err != nil { + return fmt.Errorf("failed to share keys: %w", err) + } + return nil } else if !isShared { return fmt.Errorf("olm account is not marked as shared, but there are keys on the server") } else if ed := device.Keys.GetEd25519(helper.client.DeviceID); ownID.SigningKey != ed { return fmt.Errorf("mismatching identity key on server (%q != %q)", ownID.SigningKey, ed) - } - if !isShared { - helper.log.Debug().Msg("Olm account not marked as shared, but keys on server match?") } else { helper.log.Debug().Msg("Olm account marked as shared and keys on server match, device is fine") + return nil } - return nil } var NoSessionFound = crypto.NoSessionFound From fa90bba8205cc229ce767c82c809873c0f3bceb1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 26 Sep 2025 19:48:22 +0300 Subject: [PATCH 338/581] crypto: don't check otk count if sharing new keys --- crypto/machine.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto/machine.go b/crypto/machine.go index ab3e4591..4d2e3880 100644 --- a/crypto/machine.go +++ b/crypto/machine.go @@ -729,7 +729,7 @@ func (mach *OlmMachine) ShareKeys(ctx context.Context, currentOTKCount int) erro start := time.Now() mach.otkUploadLock.Lock() defer mach.otkUploadLock.Unlock() - if mach.lastOTKUpload.Add(1*time.Minute).After(start) || currentOTKCount < 0 { + if mach.lastOTKUpload.Add(1*time.Minute).After(start) || (currentOTKCount < 0 && mach.account.Shared) { log.Debug().Msg("Checking OTK count from server due to suspiciously close share keys requests or negative OTK count") resp, err := mach.Client.UploadKeys(ctx, &mautrix.ReqUploadKeys{}) if err != nil { From acc449daf42192fe090926f4aa65cfebadcdc5cf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 26 Sep 2025 20:37:58 +0300 Subject: [PATCH 339/581] crypto: add basic group session sharing benchmark --- crypto/machine_bench_test.go | 67 ++++++++++++++++++ mockserver/mockserver.go | 131 ++++++++++++++++++++++++++++------- responses_test.go | 2 - 3 files changed, 173 insertions(+), 27 deletions(-) create mode 100644 crypto/machine_bench_test.go diff --git a/crypto/machine_bench_test.go b/crypto/machine_bench_test.go new file mode 100644 index 00000000..fd40d795 --- /dev/null +++ b/crypto/machine_bench_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto_test + +import ( + "context" + "fmt" + "math/rand/v2" + "testing" + + "github.com/rs/zerolog" + globallog "github.com/rs/zerolog/log" // zerolog-allow-global-log + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/crypto/cryptohelper" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/mockserver" +) + +func randomDeviceCount(r *rand.Rand) int { + k := 1 + for k < 10 && r.IntN(3) > 0 { + k++ + } + return k +} + +func BenchmarkOlmMachine_ShareGroupSession(b *testing.B) { + globallog.Logger = zerolog.Nop() + server := mockserver.Create(b) + server.PopOTKs = false + server.MemoryStore = false + var i int + var shareTargets []id.UserID + r := rand.New(rand.NewPCG(293, 0)) + var totalDeviceCount int + for i = 1; i < 1000; i++ { + userID := id.UserID(fmt.Sprintf("@user%d:localhost", i)) + deviceCount := randomDeviceCount(r) + for j := 0; j < deviceCount; j++ { + client, _ := server.Login(b, nil, userID, id.DeviceID(fmt.Sprintf("u%d_d%d", i, j))) + mach := client.Crypto.(*cryptohelper.CryptoHelper).Machine() + keysCache, err := mach.GenerateCrossSigningKeys() + require.NoError(b, err) + err = mach.PublishCrossSigningKeys(context.TODO(), keysCache, nil) + require.NoError(b, err) + } + totalDeviceCount += deviceCount + shareTargets = append(shareTargets, userID) + } + for b.Loop() { + client, _ := server.Login(b, nil, id.UserID(fmt.Sprintf("@benchuser%d:localhost", i)), id.DeviceID(fmt.Sprintf("u%d_d1", i))) + mach := client.Crypto.(*cryptohelper.CryptoHelper).Machine() + keysCache, err := mach.GenerateCrossSigningKeys() + require.NoError(b, err) + err = mach.PublishCrossSigningKeys(context.TODO(), keysCache, nil) + require.NoError(b, err) + err = mach.ShareGroupSession(context.TODO(), "!room:localhost", shareTargets) + require.NoError(b, err) + i++ + } + fmt.Println(totalDeviceCount, "devices total") +} diff --git a/mockserver/mockserver.go b/mockserver/mockserver.go index 9f62b567..e52c387a 100644 --- a/mockserver/mockserver.go +++ b/mockserver/mockserver.go @@ -9,7 +9,9 @@ package mockserver import ( "context" "encoding/json" + "fmt" "io" + "maps" "net/http" "net/http/httptest" "strings" @@ -17,6 +19,9 @@ import ( globallog "github.com/rs/zerolog/log" // zerolog-allow-global-log "github.com/stretchr/testify/require" + "go.mau.fi/util/dbutil" + "go.mau.fi/util/exerrors" + "go.mau.fi/util/exhttp" "go.mau.fi/util/random" "maunium.net/go/mautrix" @@ -26,35 +31,52 @@ import ( "maunium.net/go/mautrix/id" ) +func mustDecode(r *http.Request, data any) { + exerrors.PanicIfNotNil(json.NewDecoder(r.Body).Decode(data)) +} + +type userAndDeviceID struct { + UserID id.UserID + DeviceID id.DeviceID +} + type MockServer struct { Router *http.ServeMux Server *httptest.Server - AccessTokenToUserID map[string]id.UserID + AccessTokenToUserID map[string]userAndDeviceID DeviceInbox map[id.UserID]map[id.DeviceID][]event.Event AccountData map[id.UserID]map[event.Type]json.RawMessage DeviceKeys map[id.UserID]map[id.DeviceID]mautrix.DeviceKeys + OneTimeKeys map[id.UserID]map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey MasterKeys map[id.UserID]mautrix.CrossSigningKeys SelfSigningKeys map[id.UserID]mautrix.CrossSigningKeys UserSigningKeys map[id.UserID]mautrix.CrossSigningKeys + + PopOTKs bool + MemoryStore bool } -func Create(t *testing.T) *MockServer { +func Create(t testing.TB) *MockServer { t.Helper() server := MockServer{ - AccessTokenToUserID: map[string]id.UserID{}, + AccessTokenToUserID: map[string]userAndDeviceID{}, DeviceInbox: map[id.UserID]map[id.DeviceID][]event.Event{}, AccountData: map[id.UserID]map[event.Type]json.RawMessage{}, DeviceKeys: map[id.UserID]map[id.DeviceID]mautrix.DeviceKeys{}, + OneTimeKeys: map[id.UserID]map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey{}, MasterKeys: map[id.UserID]mautrix.CrossSigningKeys{}, SelfSigningKeys: map[id.UserID]mautrix.CrossSigningKeys{}, UserSigningKeys: map[id.UserID]mautrix.CrossSigningKeys{}, + PopOTKs: true, + MemoryStore: true, } router := http.NewServeMux() router.HandleFunc("POST /_matrix/client/v3/login", server.postLogin) router.HandleFunc("POST /_matrix/client/v3/keys/query", server.postKeysQuery) + router.HandleFunc("POST /_matrix/client/v3/keys/claim", server.postKeysClaim) router.HandleFunc("PUT /_matrix/client/v3/sendToDevice/{type}/{txn}", server.putSendToDevice) router.HandleFunc("PUT /_matrix/client/v3/user/{userID}/account_data/{type}", server.putAccountData) router.HandleFunc("POST /_matrix/client/v3/keys/device_signing/upload", server.postDeviceSigningUpload) @@ -66,7 +88,7 @@ func Create(t *testing.T) *MockServer { return &server } -func (ms *MockServer) getUserID(r *http.Request) id.UserID { +func (ms *MockServer) getUserID(r *http.Request) userAndDeviceID { authHeader := r.Header.Get("Authorization") authHeader = strings.TrimPrefix(authHeader, "Bearer ") userID, ok := ms.AccessTokenToUserID[authHeader] @@ -77,12 +99,12 @@ func (ms *MockServer) getUserID(r *http.Request) id.UserID { } func (ms *MockServer) emptyResp(w http.ResponseWriter, _ *http.Request) { - w.Write([]byte("{}")) + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) } func (ms *MockServer) postLogin(w http.ResponseWriter, r *http.Request) { var loginReq mautrix.ReqLogin - json.NewDecoder(r.Body).Decode(&loginReq) + mustDecode(r, &loginReq) deviceID := loginReq.DeviceID if deviceID == "" { @@ -91,9 +113,12 @@ func (ms *MockServer) postLogin(w http.ResponseWriter, r *http.Request) { accessToken := random.String(30) userID := id.UserID(loginReq.Identifier.User) - ms.AccessTokenToUserID[accessToken] = userID + ms.AccessTokenToUserID[accessToken] = userAndDeviceID{ + UserID: userID, + DeviceID: deviceID, + } - json.NewEncoder(w).Encode(&mautrix.RespLogin{ + exhttp.WriteJSONResponse(w, http.StatusOK, &mautrix.RespLogin{ AccessToken: accessToken, DeviceID: deviceID, UserID: userID, @@ -102,7 +127,7 @@ func (ms *MockServer) postLogin(w http.ResponseWriter, r *http.Request) { func (ms *MockServer) putSendToDevice(w http.ResponseWriter, r *http.Request) { var req mautrix.ReqSendToDevice - json.NewDecoder(r.Body).Decode(&req) + mustDecode(r, &req) evtType := event.Type{Type: r.PathValue("type"), Class: event.ToDeviceEventType} for user, devices := range req.Messages { @@ -112,7 +137,7 @@ func (ms *MockServer) putSendToDevice(w http.ResponseWriter, r *http.Request) { } content.ParseRaw(evtType) ms.DeviceInbox[user][device] = append(ms.DeviceInbox[user][device], event.Event{ - Sender: ms.getUserID(r), + Sender: ms.getUserID(r).UserID, Type: evtType, Content: *content, }) @@ -135,7 +160,7 @@ func (ms *MockServer) putAccountData(w http.ResponseWriter, r *http.Request) { func (ms *MockServer) postKeysQuery(w http.ResponseWriter, r *http.Request) { var req mautrix.ReqQueryKeys - json.NewDecoder(r.Body).Decode(&req) + mustDecode(r, &req) resp := mautrix.RespQueryKeys{ MasterKeys: map[id.UserID]mautrix.CrossSigningKeys{}, UserSigningKeys: map[id.UserID]mautrix.CrossSigningKeys{}, @@ -148,29 +173,68 @@ func (ms *MockServer) postKeysQuery(w http.ResponseWriter, r *http.Request) { resp.SelfSigningKeys[user] = ms.SelfSigningKeys[user] resp.DeviceKeys[user] = ms.DeviceKeys[user] } - json.NewEncoder(w).Encode(&resp) + exhttp.WriteJSONResponse(w, http.StatusOK, &resp) +} + +func (ms *MockServer) postKeysClaim(w http.ResponseWriter, r *http.Request) { + var req mautrix.ReqClaimKeys + mustDecode(r, &req) + resp := mautrix.RespClaimKeys{ + OneTimeKeys: map[id.UserID]map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey{}, + } + for user, devices := range req.OneTimeKeys { + resp.OneTimeKeys[user] = map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey{} + for device := range devices { + keys := ms.OneTimeKeys[user][device] + for keyID, key := range keys { + if ms.PopOTKs { + delete(keys, keyID) + } + resp.OneTimeKeys[user][device] = map[id.KeyID]mautrix.OneTimeKey{ + keyID: key, + } + break + } + } + } + exhttp.WriteJSONResponse(w, http.StatusOK, &resp) } func (ms *MockServer) postKeysUpload(w http.ResponseWriter, r *http.Request) { var req mautrix.ReqUploadKeys - json.NewDecoder(r.Body).Decode(&req) + mustDecode(r, &req) - userID := ms.getUserID(r) + uid := ms.getUserID(r) + userID := uid.UserID if _, ok := ms.DeviceKeys[userID]; !ok { ms.DeviceKeys[userID] = map[id.DeviceID]mautrix.DeviceKeys{} } - ms.DeviceKeys[userID][req.DeviceKeys.DeviceID] = *req.DeviceKeys + if _, ok := ms.OneTimeKeys[userID]; !ok { + ms.OneTimeKeys[userID] = map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey{} + } - json.NewEncoder(w).Encode(&mautrix.RespUploadKeys{ - OneTimeKeyCounts: mautrix.OTKCount{SignedCurve25519: 50}, + if req.DeviceKeys != nil { + ms.DeviceKeys[userID][uid.DeviceID] = *req.DeviceKeys + } + otks, ok := ms.OneTimeKeys[userID][uid.DeviceID] + if !ok { + otks = map[id.KeyID]mautrix.OneTimeKey{} + ms.OneTimeKeys[userID][uid.DeviceID] = otks + } + if req.OneTimeKeys != nil { + maps.Copy(otks, req.OneTimeKeys) + } + + exhttp.WriteJSONResponse(w, http.StatusOK, &mautrix.RespUploadKeys{ + OneTimeKeyCounts: mautrix.OTKCount{SignedCurve25519: len(otks)}, }) } func (ms *MockServer) postDeviceSigningUpload(w http.ResponseWriter, r *http.Request) { var req mautrix.UploadCrossSigningKeysReq - json.NewDecoder(r.Body).Decode(&req) + mustDecode(r, &req) - userID := ms.getUserID(r) + userID := ms.getUserID(r).UserID ms.MasterKeys[userID] = req.Master ms.SelfSigningKeys[userID] = req.SelfSigning ms.UserSigningKeys[userID] = req.UserSigning @@ -178,11 +242,14 @@ func (ms *MockServer) postDeviceSigningUpload(w http.ResponseWriter, r *http.Req ms.emptyResp(w, r) } -func (ms *MockServer) Login(t *testing.T, ctx context.Context, userID id.UserID, deviceID id.DeviceID) (*mautrix.Client, crypto.Store) { +func (ms *MockServer) Login(t testing.TB, ctx context.Context, userID id.UserID, deviceID id.DeviceID) (*mautrix.Client, crypto.Store) { t.Helper() + if ctx == nil { + ctx = context.TODO() + } client, err := mautrix.NewClient(ms.Server.URL, "", "") require.NoError(t, err) - client.StateStore = mautrix.NewMemoryStateStore() + client.Client = ms.Server.Client() _, err = client.Login(ctx, &mautrix.ReqLogin{ Type: mautrix.AuthTypePassword, @@ -196,8 +263,22 @@ func (ms *MockServer) Login(t *testing.T, ctx context.Context, userID id.UserID, }) require.NoError(t, err) - cryptoStore := crypto.NewMemoryStore(nil) - cryptoHelper, err := cryptohelper.NewCryptoHelper(client, []byte("test"), cryptoStore) + var store any + if ms.MemoryStore { + store = crypto.NewMemoryStore(nil) + client.StateStore = mautrix.NewMemoryStateStore() + } else { + store, err = dbutil.NewFromConfig("", dbutil.Config{ + PoolConfig: dbutil.PoolConfig{ + Type: "sqlite3-fk-wal", + URI: fmt.Sprintf("file:%s?mode=memory&cache=shared&_txlock=immediate", random.String(10)), + MaxOpenConns: 5, + MaxIdleConns: 1, + }, + }, nil) + require.NoError(t, err) + } + cryptoHelper, err := cryptohelper.NewCryptoHelper(client, []byte("test"), store) require.NoError(t, err) client.Crypto = cryptoHelper @@ -213,10 +294,10 @@ func (ms *MockServer) Login(t *testing.T, ctx context.Context, userID id.UserID, err = cryptoHelper.Machine().ShareKeys(ctx, 50) require.NoError(t, err) - return client, cryptoStore + return client, cryptoHelper.Machine().CryptoStore } -func (ms *MockServer) DispatchToDevice(t *testing.T, ctx context.Context, client *mautrix.Client) { +func (ms *MockServer) DispatchToDevice(t testing.TB, ctx context.Context, client *mautrix.Client) { t.Helper() for _, evt := range ms.DeviceInbox[client.UserID][client.DeviceID] { diff --git a/responses_test.go b/responses_test.go index b23d85ad..73d82635 100644 --- a/responses_test.go +++ b/responses_test.go @@ -8,7 +8,6 @@ package mautrix_test import ( "encoding/json" - "fmt" "testing" "github.com/stretchr/testify/assert" @@ -86,7 +85,6 @@ func TestRespCapabilities_UnmarshalJSON(t *testing.T) { var caps mautrix.RespCapabilities err := json.Unmarshal([]byte(sampleData), &caps) require.NoError(t, err) - fmt.Println(caps) require.NotNil(t, caps.RoomVersions) assert.Equal(t, "9", caps.RoomVersions.Default) From a3c6832c487fd07ee63bd136b2c63e637888fb0e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 26 Sep 2025 23:18:05 +0300 Subject: [PATCH 340/581] federation/eventauth: fix default power levels in pre-v12 rooms --- federation/eventauth/eventauth.go | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/federation/eventauth/eventauth.go b/federation/eventauth/eventauth.go index d4a50969..6c36d478 100644 --- a/federation/eventauth/eventauth.go +++ b/federation/eventauth/eventauth.go @@ -733,23 +733,27 @@ func findEventAndReadString(events []*pdu.PDU, evtType, stateKey, fieldPath, def func getPowerLevels(roomVersion id.RoomVersion, authEvents []*pdu.PDU, createEvt *pdu.PDU) (*event.PowerLevelsEventContent, error) { var err error - powerLevels := findEventAndReadData(authEvents, event.StatePowerLevels.Type, "", func(evt *pdu.PDU) (out event.PowerLevelsEventContent) { + powerLevels := findEventAndReadData(authEvents, event.StatePowerLevels.Type, "", func(evt *pdu.PDU) *event.PowerLevelsEventContent { if evt == nil { - return + return nil } content := evt.Content + out := &event.PowerLevelsEventContent{} if !roomVersion.ValidatePowerLevelInts() { - safeParsePowerLevels(content, &out) + safeParsePowerLevels(content, out) } else { - err = json.Unmarshal(content, &out) + err = json.Unmarshal(content, out) } - return + return out }) if err != nil { // This should never happen thanks to safeParsePowerLevels for v1-9 and strict validation in v10+ return nil, fmt.Errorf("%w: %w", ErrFailedToParsePowerLevels, err) } if roomVersion.PrivilegedRoomCreators() { + if powerLevels == nil { + powerLevels = &event.PowerLevelsEventContent{} + } powerLevels.CreateEvent, err = createEvt.ToClientEvent(roomVersion) if err != nil { return nil, fmt.Errorf("%w: %w", ErrFailedToParsePowerLevels, err) @@ -758,12 +762,14 @@ func getPowerLevels(roomVersion id.RoomVersion, authEvents []*pdu.PDU, createEvt if err != nil { return nil, fmt.Errorf("%w: %w", ErrFailedToParsePowerLevels, err) } - } else { - powerLevels.Users = map[id.UserID]int{ - createEvt.Sender: (1 << 53) - 1, + } else if powerLevels == nil { + powerLevels = &event.PowerLevelsEventContent{ + Users: map[id.UserID]int{ + createEvt.Sender: 100, + }, } } - return &powerLevels, nil + return powerLevels, nil } func parseIntWithVersion(roomVersion id.RoomVersion, val gjson.Result) *int { From ae6a0b4f512c635cb2ab3c0af5e88e4cc11e58f3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 26 Sep 2025 23:26:17 +0300 Subject: [PATCH 341/581] federation/eventauth: fix checking user power level changes --- federation/eventauth/eventauth.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/federation/eventauth/eventauth.go b/federation/eventauth/eventauth.go index 6c36d478..f8d90248 100644 --- a/federation/eventauth/eventauth.go +++ b/federation/eventauth/eventauth.go @@ -664,8 +664,9 @@ func allowPowerChangeMap(roomVersion id.RoomVersion, maxVal int, path, ownID str newVal := new.Get(exgjson.Path(key.Str)) err = allowPowerChange(roomVersion, maxVal, path+"."+key.Str, value, newVal) if err == nil && ownID != "" && key.Str != ownID { - val := parseIntWithVersion(roomVersion, value) - if *val >= maxVal { + parsedOldVal := parseIntWithVersion(roomVersion, value) + parsedNewVal := parseIntWithVersion(roomVersion, newVal) + if *parsedOldVal >= maxVal && *parsedOldVal != *parsedNewVal { err = fmt.Errorf("%w: can't change users.%s from %s to %s with sender level %d", ErrInvalidPowerChange, key.Str, stringifyForError(value), stringifyForError(newVal), maxVal) } } From 6e231a45e4e848f260f98fe242a417375d02a629 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 26 Sep 2025 23:36:03 +0300 Subject: [PATCH 342/581] federation/eventauth: fix gjson path construction in new power level check --- federation/eventauth/eventauth.go | 2 +- federation/eventauth/testroom-v12-success.jsonl | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/federation/eventauth/eventauth.go b/federation/eventauth/eventauth.go index f8d90248..3dfdeb48 100644 --- a/federation/eventauth/eventauth.go +++ b/federation/eventauth/eventauth.go @@ -676,7 +676,7 @@ func allowPowerChangeMap(roomVersion id.RoomVersion, maxVal int, path, ownID str return } new.ForEach(func(key, value gjson.Result) bool { - err = allowPowerChange(roomVersion, maxVal, path+"."+key.Str, old.Get(exgjson.Path(key.Path(key.Str))), value) + err = allowPowerChange(roomVersion, maxVal, path+"."+key.Str, old.Get(exgjson.Path(key.Str)), value) return err == nil }) return diff --git a/federation/eventauth/testroom-v12-success.jsonl b/federation/eventauth/testroom-v12-success.jsonl index 1f0b5357..2b751de3 100644 --- a/federation/eventauth/testroom-v12-success.jsonl +++ b/federation/eventauth/testroom-v12-success.jsonl @@ -15,3 +15,7 @@ {"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":9001},"events_default":0,"historical":12345,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:envs.net":9001,"@tulir:matrix.org":9000},"users_default":0},"depth":15,"hashes":{"sha256":"FnGzbcXc8YOiB1TY33QunGA17Axoyuu3sdVOj5Z408o"},"origin_server_ts":1756071804931,"prev_events":["$p4xvOczrhzQMtRW3-Tf86LYUb5aqpGFIgjwHBuxWIcI"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"uyTUsPR+CzCtlevzB5+sNXvmfbPSp6u7RZC4E4TLVsj45+pjmMRswAvuHP9PT2+Tkl6Hu8ZPigsXgbKZtR35Aw"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw","replaces_state":"$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg"}} {"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":100},"events_default":0,"historical":12345,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:envs.net":9001,"@tulir:matrix.org":9000},"users_default":0},"depth":16,"hashes":{"sha256":"KcivsiLesdnUnKX23Akk3OJEJFGRSY0g4H+p7XIThnw"},"origin_server_ts":1756071812688,"prev_events":["$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"cAK8dO2AVZklY9te5aVKbF1jR/eB5rzeNOXfYPjBLf+aSAS4Z6R2aMKW6hJB9PqRS4S+UZc24DTrjUjnvMzeBA"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU","replaces_state":"$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw"}} {"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU"],"content":{"body":"meow #2","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":17,"hashes":{"sha256":"SgH9fOXGdbdqpRfYmoz1t29+gX8Ze4ThSoj6klZs3Og"},"origin_server_ts":1756247476706,"prev_events":["$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"SMYK7zP3SaQOKhzZUKUBVCKwffYqi3PFAlPM34kRJtmfGU3KZXNBT0zi+veXDMmxkMunqhF2RTHBD6joa0kBAQ"}},"type":"m.room.message","unsigned":{"event_id":"$KFHLO0-ENYOGQXogp84C-ISSu1xtKUzIMaZ6LiBcR_w"}} +{"auth_events":["$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw","$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":100},"events_default":0,"historical":12345,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:beeper.com":8999,"@tulir:envs.net":9001,"@tulir:matrix.org":9000},"users_default":0},"depth":18,"hashes":{"sha256":"l8Mw3VKn/Bvntg7bZ8uh5J8M2IBZM93Xg7hsdaSci8s"},"origin_server_ts":1758918656341,"prev_events":["$KFHLO0-ENYOGQXogp84C-ISSu1xtKUzIMaZ6LiBcR_w"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:matrix.org","signatures":{"matrix.org":{"ed25519:a_RXGa":"cg5LP0WuTnVB5jFhNERLLU5b+EhmyACiOq6cp3gKJnZsTAb1yajcgJybLWKrc8QQqxPa7hPnskRBgt4OBTFNAA"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0","replaces_state":"$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU"}} +{"auth_events":["$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M","$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw","$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0"],"content":{"avatar_url":"mxc://beeper.com/eBdwbHbllONoAySQkXLjbfFM","displayname":"tulir[b]","membership":"invite"},"depth":19,"hashes":{"sha256":"KpmaRUQnJju8TIDMPzakitUIKOWJxTvULpFB3a1CGgc"},"origin_server_ts":1758918665952,"prev_events":["$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:matrix.org","signatures":{"beeper.com":{"ed25519:a_zgvp":"mzI9rPkQ1xHl2/G5Yrn0qmIRt5OyjPNqRwilPfH4jmr1tP+vv3vC0m4mph/MCOq8S1c/DQaCWSpdOX1uWfchBQ"},"matrix.org":{"ed25519:a_RXGa":"kEdfr8DjxC/bdvGYxnniFI/pxDWeyG73OjG/Gu1uoHLhjdtAT/vEQ6lotJJs214/KX5eAaQWobE9qtMvtPwMDw"}},"state_key":"@tulir:beeper.com","type":"m.room.member","unsigned":{"event_id":"$PZJZoUwNySl0jY16DkHBHR0HyAppLdxc0rkSuYp5Mro","invite_room_state":[{"auth_events":[],"content":{"room_version":"12"},"depth":1,"hashes":{"sha256":"qJYytb+EqWPiiZ0ogDODcLeA8XYw/2hVTaLHihcVBZQ"},"origin_server_ts":1756071567186,"prev_events":[],"sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"/9pp+2tkLo6XcZ3opqLeIpa3D96fh3QLpR2PQrZ6Z6j7wyRAvBrcgCpAeMtuyDCzW8Wh1QFEPG4FSsGvVaEFBg"}},"state_key":"","type":"m.room.create","unsigned":{"age":11553}},{"content":{"avatar_url":"mxc://matrix.org/BDYVQFSLvZHMaKHDGiRkvhVg","displayname":"tulir[m]","membership":"join"},"sender":"@tulir:matrix.org","state_key":"@tulir:matrix.org","type":"m.room.member"},{"content":{"name":"event auth test v12!"},"sender":"@tulir:matrix.org","state_key":"","type":"m.room.name"},{"content":{"join_rule":"invite"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.join_rules"}]}} +{"auth_events":["$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M","$PZJZoUwNySl0jY16DkHBHR0HyAppLdxc0rkSuYp5Mro","$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0"],"content":{"avatar_url":"mxc://beeper.com/eBdwbHbllONoAySQkXLjbfFM","displayname":"tulir[b]","membership":"join"},"depth":20,"hashes":{"sha256":"bmaHSm4mYPNBNlUfFsauSTxLrUH4CUSAKYvr1v76qkk"},"origin_server_ts":1758918670276,"prev_events":["$PZJZoUwNySl0jY16DkHBHR0HyAppLdxc0rkSuYp5Mro"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:beeper.com","signatures":{"beeper.com":{"ed25519:a_zgvp":"D3cz3m15m89a3G4c5yWOBCjhtSeI5IxBfQKt5XOr9a44QHyc3nwjjvIJaRrKNcS5tLUJwZ2IpVzjlrpbPHpxDA"}},"state_key":"@tulir:beeper.com","type":"m.room.member","unsigned":{"age":6,"event_id":"$_hayW1Y0HRWp3VEGZZbsMf0Ncg9x6n0ikveD0lbCwMw","replaces_state":"$PZJZoUwNySl0jY16DkHBHR0HyAppLdxc0rkSuYp5Mro"}} +{"auth_events":["$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0","$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":100},"events_default":0,"historical":12345,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:beeper.com":9000,"@tulir:envs.net":9001,"@tulir:matrix.org":8999},"users_default":0},"depth":21,"hashes":{"sha256":"xCj9vszChHiXba9DaPzhtF79Tphek3pRViMp36DOurU"},"origin_server_ts":1758918689485,"prev_events":["$_hayW1Y0HRWp3VEGZZbsMf0Ncg9x6n0ikveD0lbCwMw"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"odkrWD30+ObeYtagULtECB/QmGae7qNy66nmJMWYXiQMYUJw/GMzSmgAiLAWfVYlfD3aEvMb/CBdrhL07tfSBw"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$di6cI89-GxX8-Wbx-0T69l4wg6TUWITRkjWXzG7EBqo","replaces_state":"$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0"}} From 9878c3d67542e48ab050e535d0a92688d8fac6f8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 26 Sep 2025 23:36:58 +0300 Subject: [PATCH 343/581] federation/eventauth: change error message for users-specific power level check --- federation/eventauth/eventauth.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/federation/eventauth/eventauth.go b/federation/eventauth/eventauth.go index 3dfdeb48..7d73abcd 100644 --- a/federation/eventauth/eventauth.go +++ b/federation/eventauth/eventauth.go @@ -110,12 +110,13 @@ var ( ErrMismatchingPrivateStateKey = AuthFailError{Index: "9", Message: "state keys starting with @ must match sender user ID"} - ErrTopLevelPLNotInteger = AuthFailError{Index: "10.1", Message: "invalid type for top-level power level field"} - ErrPLNotInteger = AuthFailError{Index: "10.2", Message: "invalid type for power level"} - ErrInvalidUserIDInPL = AuthFailError{Index: "10.3", Message: "invalid user ID in power levels"} - ErrUserPLNotInteger = AuthFailError{Index: "10.3", Message: "invalid type for user power level"} - ErrCreatorInPowerLevels = AuthFailError{Index: "10.4", Message: "room creators must not be specified in power levels"} - ErrInvalidPowerChange = AuthFailError{Index: "10.x", Message: "illegal power level change"} + ErrTopLevelPLNotInteger = AuthFailError{Index: "10.1", Message: "invalid type for top-level power level field"} + ErrPLNotInteger = AuthFailError{Index: "10.2", Message: "invalid type for power level"} + ErrInvalidUserIDInPL = AuthFailError{Index: "10.3", Message: "invalid user ID in power levels"} + ErrUserPLNotInteger = AuthFailError{Index: "10.3", Message: "invalid type for user power level"} + ErrCreatorInPowerLevels = AuthFailError{Index: "10.4", Message: "room creators must not be specified in power levels"} + ErrInvalidPowerChange = AuthFailError{Index: "10.x", Message: "illegal power level change"} + ErrInvalidUserPowerChange = AuthFailError{Index: "10.9", Message: "illegal power level change"} ) func isRejected(evt *pdu.PDU) bool { @@ -667,7 +668,7 @@ func allowPowerChangeMap(roomVersion id.RoomVersion, maxVal int, path, ownID str parsedOldVal := parseIntWithVersion(roomVersion, value) parsedNewVal := parseIntWithVersion(roomVersion, newVal) if *parsedOldVal >= maxVal && *parsedOldVal != *parsedNewVal { - err = fmt.Errorf("%w: can't change users.%s from %s to %s with sender level %d", ErrInvalidPowerChange, key.Str, stringifyForError(value), stringifyForError(newVal), maxVal) + err = fmt.Errorf("%w: can't change users.%s from %s to %s with sender level %d", ErrInvalidUserPowerChange, key.Str, stringifyForError(value), stringifyForError(newVal), maxVal) } } return err == nil From 743cbb5f2ce71aaa97a56e76adf9597383a1b3c2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 27 Sep 2025 16:26:15 +0300 Subject: [PATCH 344/581] bridgev2/mxmain: add option to mix calendar and semantic versioning --- bridgev2/matrix/mxmain/main.go | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/bridgev2/matrix/mxmain/main.go b/bridgev2/matrix/mxmain/main.go index e6219c50..9fec278d 100644 --- a/bridgev2/matrix/mxmain/main.go +++ b/bridgev2/matrix/mxmain/main.go @@ -62,6 +62,9 @@ type BridgeMain struct { // git tag to see if the built version is the release or a dev build. // You can either bump this right after a release or right before, as long as it matches on the release commit. Version string + // SemCalVer defines whether this bridge uses a mix of semantic and calendar versioning, + // such that the Version field is YY.0M.patch, while git tags are major.YY0M.patch. + SemCalVer bool // PostInit is a function that will be called after the bridge has been initialized but before it is started. PostInit func() @@ -424,6 +427,21 @@ func (br *BridgeMain) Stop() { br.Bridge.StopWithTimeout(5 * time.Second) } +func semverToCalver(semver string) string { + parts := strings.SplitN(semver, ".", 3) + if len(parts) < 2 { + panic(fmt.Errorf("invalid semver for calendar versioning: %s", semver)) + } + if len(parts[1]) != 4 { + panic(fmt.Errorf("invalid minor semver component for calendar versioning: %s", parts[1])) + } + calver := parts[1][:2] + "." + parts[1][2:] + if len(parts) == 3 { + calver += "." + parts[2] + } + return calver +} + // InitVersion formats the bridge version and build time nicely for things like // the `version` bridge command on Matrix and the `--version` CLI flag. // @@ -447,9 +465,13 @@ func (br *BridgeMain) Stop() { // (to use both at the same time, simply merge the ldflags into one, `-ldflags "-X '...' -X ..."`) func (br *BridgeMain) InitVersion(tag, commit, rawBuildTime string) { br.baseVersion = br.Version + rawTag := tag if len(tag) > 0 && tag[0] == 'v' { tag = tag[1:] } + if br.SemCalVer && len(tag) > 0 { + tag = semverToCalver(tag) + } if tag != br.Version { suffix := "" if !strings.HasSuffix(br.Version, "+dev") { @@ -464,7 +486,7 @@ func (br *BridgeMain) InitVersion(tag, commit, rawBuildTime string) { br.LinkifiedVersion = fmt.Sprintf("v%s", br.Version) if tag == br.Version { - br.LinkifiedVersion = fmt.Sprintf("[v%s](%s/releases/v%s)", br.Version, br.URL, tag) + br.LinkifiedVersion = fmt.Sprintf("[v%s](%s/releases/v%s)", br.Version, br.URL, rawTag) } else if len(commit) > 8 { br.LinkifiedVersion = strings.Replace(br.LinkifiedVersion, commit[:8], fmt.Sprintf("[%s](%s/commit/%s)", commit[:8], br.URL, commit), 1) } From d146b6caf80673aad09b26c4bfd117a1ca072dde Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 27 Sep 2025 16:49:42 +0300 Subject: [PATCH 345/581] bridgev2/mxmain: move version calculation to go-util --- bridgev2/matrix/mxmain/main.go | 98 ++++++---------------------------- go.mod | 2 +- go.sum | 4 +- 3 files changed, 19 insertions(+), 85 deletions(-) diff --git a/bridgev2/matrix/mxmain/main.go b/bridgev2/matrix/mxmain/main.go index 9fec278d..9e409875 100644 --- a/bridgev2/matrix/mxmain/main.go +++ b/bridgev2/matrix/mxmain/main.go @@ -26,6 +26,7 @@ import ( "go.mau.fi/util/dbutil" "go.mau.fi/util/exerrors" "go.mau.fi/util/exzerolog" + "go.mau.fi/util/progver" "gopkg.in/yaml.v3" flag "maunium.net/go/mauflag" @@ -89,11 +90,7 @@ type BridgeMain struct { RegistrationPath string SaveConfig bool - baseVersion string - commit string - LinkifiedVersion string - VersionDesc string - BuildTime time.Time + ver progver.ProgramVersion AdditionalShortFlags string AdditionalLongFlags string @@ -102,14 +99,7 @@ type BridgeMain struct { } type VersionJSONOutput struct { - Name string - URL string - - Version string - IsRelease bool - Commit string - FormattedVersion string - BuildTime time.Time + progver.ProgramVersion OS string Arch string @@ -150,18 +140,11 @@ func (br *BridgeMain) PreInit() { flag.PrintHelp() os.Exit(0) } else if *version { - fmt.Println(br.VersionDesc) + fmt.Println(br.ver.FormattedVersion) os.Exit(0) } else if *versionJSON { output := VersionJSONOutput{ - URL: br.URL, - Name: br.Name, - - Version: br.baseVersion, - IsRelease: br.Version == br.baseVersion, - Commit: br.commit, - FormattedVersion: br.Version, - BuildTime: br.BuildTime, + ProgramVersion: br.ver, OS: runtime.GOOS, Arch: runtime.GOARCH, @@ -243,8 +226,8 @@ func (br *BridgeMain) Init() { br.Log.Info(). Str("name", br.Name). - Str("version", br.Version). - Time("built_at", br.BuildTime). + Str("version", br.ver.FormattedVersion). + Time("built_at", br.ver.BuildTime). Str("go_version", runtime.Version()). Msg("Initializing bridge") @@ -258,7 +241,7 @@ func (br *BridgeMain) Init() { br.Matrix.AS.DoublePuppetValue = br.Name br.Bridge.Commands.(*commands.Processor).AddHandler(&commands.FullHandler{ Func: func(ce *commands.Event) { - ce.Reply("[%s](%s) %s (%s)", br.Name, br.URL, br.LinkifiedVersion, br.BuildTime.Format(time.RFC1123)) + ce.Reply(br.ver.MarkdownDescription()) }, Name: "version", Help: commands.HelpMeta{ @@ -427,21 +410,6 @@ func (br *BridgeMain) Stop() { br.Bridge.StopWithTimeout(5 * time.Second) } -func semverToCalver(semver string) string { - parts := strings.SplitN(semver, ".", 3) - if len(parts) < 2 { - panic(fmt.Errorf("invalid semver for calendar versioning: %s", semver)) - } - if len(parts[1]) != 4 { - panic(fmt.Errorf("invalid minor semver component for calendar versioning: %s", parts[1])) - } - calver := parts[1][:2] + "." + parts[1][2:] - if len(parts) == 3 { - calver += "." + parts[2] - } - return calver -} - // InitVersion formats the bridge version and build time nicely for things like // the `version` bridge command on Matrix and the `--version` CLI flag. // @@ -464,46 +432,12 @@ func semverToCalver(semver string) string { // // (to use both at the same time, simply merge the ldflags into one, `-ldflags "-X '...' -X ..."`) func (br *BridgeMain) InitVersion(tag, commit, rawBuildTime string) { - br.baseVersion = br.Version - rawTag := tag - if len(tag) > 0 && tag[0] == 'v' { - tag = tag[1:] - } - if br.SemCalVer && len(tag) > 0 { - tag = semverToCalver(tag) - } - if tag != br.Version { - suffix := "" - if !strings.HasSuffix(br.Version, "+dev") { - suffix = "+dev" - } - if len(commit) > 8 { - br.Version = fmt.Sprintf("%s%s.%s", br.Version, suffix, commit[:8]) - } else { - br.Version = fmt.Sprintf("%s%s.unknown", br.Version, suffix) - } - } - - br.LinkifiedVersion = fmt.Sprintf("v%s", br.Version) - if tag == br.Version { - br.LinkifiedVersion = fmt.Sprintf("[v%s](%s/releases/v%s)", br.Version, br.URL, rawTag) - } else if len(commit) > 8 { - br.LinkifiedVersion = strings.Replace(br.LinkifiedVersion, commit[:8], fmt.Sprintf("[%s](%s/commit/%s)", commit[:8], br.URL, commit), 1) - } - var buildTime time.Time - if rawBuildTime != "unknown" { - buildTime, _ = time.Parse(time.RFC3339, rawBuildTime) - } - var builtWith string - if buildTime.IsZero() { - rawBuildTime = "unknown" - builtWith = runtime.Version() - } else { - rawBuildTime = buildTime.Format(time.RFC1123) - builtWith = fmt.Sprintf("built at %s with %s", rawBuildTime, runtime.Version()) - } - mautrix.DefaultUserAgent = fmt.Sprintf("%s/%s %s", br.Name, br.Version, mautrix.DefaultUserAgent) - br.VersionDesc = fmt.Sprintf("%s %s (%s)", br.Name, br.Version, builtWith) - br.commit = commit - br.BuildTime = buildTime + br.ver = progver.ProgramVersion{ + Name: br.Name, + URL: br.URL, + BaseVersion: br.Version, + SemCalVer: br.SemCalVer, + }.Init(tag, commit, rawBuildTime) + mautrix.DefaultUserAgent = fmt.Sprintf("%s/%s %s", br.Name, br.ver.FormattedVersion, mautrix.DefaultUserAgent) + br.Version = br.ver.FormattedVersion } diff --git a/go.mod b/go.mod index 751e8015..c9d082a3 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.13 - go.mau.fi/util v0.9.1 + go.mau.fi/util v0.9.2-0.20250927140851-50bb0cc52015 go.mau.fi/zeroconfig v0.2.0 golang.org/x/crypto v0.42.0 golang.org/x/exp v0.0.0-20250911091902-df9299821621 diff --git a/go.sum b/go.sum index dafb9600..5133d5b6 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.1 h1:A+XKHRsjKkFi2qOm4RriR1HqY2hoOXNS3WFHaC89r2Y= -go.mau.fi/util v0.9.1/go.mod h1:M0bM9SyaOWJniaHs9hxEzz91r5ql6gYq6o1q5O1SsjQ= +go.mau.fi/util v0.9.2-0.20250927140851-50bb0cc52015 h1:aRnDwmJNAP+/EspXpo7MhSJxfS+g49MzGvnLkcNFUEc= +go.mau.fi/util v0.9.2-0.20250927140851-50bb0cc52015/go.mod h1:M0bM9SyaOWJniaHs9hxEzz91r5ql6gYq6o1q5O1SsjQ= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= From f2b77f04330c97262d7b049ed394634ce21de941 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 28 Sep 2025 20:33:20 +0300 Subject: [PATCH 346/581] version: find from build info if unset --- go.mod | 2 +- go.sum | 4 ++-- version.go | 12 ++++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c9d082a3..5001851f 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.13 - go.mau.fi/util v0.9.2-0.20250927140851-50bb0cc52015 + go.mau.fi/util v0.9.2-0.20250928173307-c0b5f4ee5899 go.mau.fi/zeroconfig v0.2.0 golang.org/x/crypto v0.42.0 golang.org/x/exp v0.0.0-20250911091902-df9299821621 diff --git a/go.sum b/go.sum index 5133d5b6..8d3fabfe 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.2-0.20250927140851-50bb0cc52015 h1:aRnDwmJNAP+/EspXpo7MhSJxfS+g49MzGvnLkcNFUEc= -go.mau.fi/util v0.9.2-0.20250927140851-50bb0cc52015/go.mod h1:M0bM9SyaOWJniaHs9hxEzz91r5ql6gYq6o1q5O1SsjQ= +go.mau.fi/util v0.9.2-0.20250928173307-c0b5f4ee5899 h1:GoPWdX45WrJG/NC+/6u4km9X9UvrzqGGG78z4VlXI7o= +go.mau.fi/util v0.9.2-0.20250928173307-c0b5f4ee5899/go.mod h1:M0bM9SyaOWJniaHs9hxEzz91r5ql6gYq6o1q5O1SsjQ= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= diff --git a/version.go b/version.go index 4821a354..fb812121 100644 --- a/version.go +++ b/version.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "runtime" + "runtime/debug" "strings" ) @@ -18,6 +19,17 @@ var DefaultUserAgent = "mautrix-go/" + Version + " go/" + strings.TrimPrefix(run var goModVersionRegex = regexp.MustCompile(`v.+\d{14}-([0-9a-f]{12})`) func init() { + if GoModVersion == "" { + info, _ := debug.ReadBuildInfo() + if info != nil { + for _, mod := range info.Deps { + if mod.Path == "maunium.net/go/mautrix" { + GoModVersion = mod.Version + break + } + } + } + } if GoModVersion != "" { match := goModVersionRegex.FindStringSubmatch(GoModVersion) if match != nil { From b597f149b71119750faa95a0a79d87461ed317d5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 28 Sep 2025 20:39:07 +0300 Subject: [PATCH 347/581] version: initialize go.mod version regex lazily --- version.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/version.go b/version.go index fb812121..b76a548c 100644 --- a/version.go +++ b/version.go @@ -16,8 +16,6 @@ var VersionWithCommit = Version var DefaultUserAgent = "mautrix-go/" + Version + " go/" + strings.TrimPrefix(runtime.Version(), "go") -var goModVersionRegex = regexp.MustCompile(`v.+\d{14}-([0-9a-f]{12})`) - func init() { if GoModVersion == "" { info, _ := debug.ReadBuildInfo() @@ -31,7 +29,7 @@ func init() { } } if GoModVersion != "" { - match := goModVersionRegex.FindStringSubmatch(GoModVersion) + match := regexp.MustCompile(`v.+\d{14}-([0-9a-f]{12})`).FindStringSubmatch(GoModVersion) if match != nil { Commit = match[1] } From 329da10584baea98df2a3695348bf63f484f54af Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 30 Sep 2025 15:35:25 +0300 Subject: [PATCH 348/581] bridgev2/database: fix split portal parent migration query --- bridgev2/database/portal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/database/portal.go b/bridgev2/database/portal.go index e02b9e44..97af4c4c 100644 --- a/bridgev2/database/portal.go +++ b/bridgev2/database/portal.go @@ -147,7 +147,7 @@ const ( ) ` fixParentsAfterSplitPortalMigrationQuery = ` - UPDATE portal SET parent_receiver=receiver WHERE parent_receiver='' AND receiver<>'' AND parent_id<>''; + UPDATE portal SET parent_receiver=receiver WHERE bridge_id=$1 AND parent_receiver='' AND receiver<>'' AND parent_id<>''; ` ) From 77682fb2920089fb9f89f3df303195eba261cffb Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 1 Oct 2025 14:48:11 +0300 Subject: [PATCH 349/581] bridgev2,error: use NonNilClone instead of creating map manually --- bridgev2/portal.go | 6 ++---- error.go | 12 +++--------- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 5db45268..3884303a 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -19,6 +19,7 @@ import ( "github.com/rs/zerolog" "go.mau.fi/util/exfmt" + "go.mau.fi/util/exmaps" "go.mau.fi/util/exslices" "go.mau.fi/util/exsync" "go.mau.fi/util/ptr" @@ -4076,10 +4077,7 @@ func (portal *Portal) syncParticipants( Displayname: currentMember.Displayname, AvatarURL: currentMember.AvatarURL, } - wrappedContent := &event.Content{Parsed: content, Raw: maps.Clone(member.MemberEventExtra)} - if wrappedContent.Raw == nil { - wrappedContent.Raw = make(map[string]any) - } + wrappedContent := &event.Content{Parsed: content, Raw: exmaps.NonNilClone(member.MemberEventExtra)} thisEvtSender := sender if member.Membership == event.MembershipJoin { content.Membership = event.MembershipInvite diff --git a/error.go b/error.go index bea4caae..b7c92a5f 100644 --- a/error.go +++ b/error.go @@ -13,6 +13,7 @@ import ( "net/http" "go.mau.fi/util/exhttp" + "go.mau.fi/util/exmaps" "golang.org/x/exp/maps" ) @@ -144,10 +145,7 @@ func (e *RespError) UnmarshalJSON(data []byte) error { } func (e *RespError) MarshalJSON() ([]byte, error) { - data := maps.Clone(e.ExtraData) - if data == nil { - data = make(map[string]any) - } + data := exmaps.NonNilClone(e.ExtraData) data["errcode"] = e.ErrCode data["error"] = e.Err return json.Marshal(data) @@ -178,11 +176,7 @@ func (e RespError) WithStatus(status int) RespError { } func (e RespError) WithExtraData(extraData map[string]any) RespError { - if e.ExtraData == nil { - e.ExtraData = make(map[string]any) - } else { - e.ExtraData = maps.Clone(e.ExtraData) - } + e.ExtraData = exmaps.NonNilClone(e.ExtraData) maps.Copy(e.ExtraData, extraData) return e } diff --git a/go.mod b/go.mod index 5001851f..70bf601e 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.13 - go.mau.fi/util v0.9.2-0.20250928173307-c0b5f4ee5899 + go.mau.fi/util v0.9.2-0.20251001114608-d99877b9cc10 go.mau.fi/zeroconfig v0.2.0 golang.org/x/crypto v0.42.0 golang.org/x/exp v0.0.0-20250911091902-df9299821621 diff --git a/go.sum b/go.sum index 8d3fabfe..639b30a2 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.2-0.20250928173307-c0b5f4ee5899 h1:GoPWdX45WrJG/NC+/6u4km9X9UvrzqGGG78z4VlXI7o= -go.mau.fi/util v0.9.2-0.20250928173307-c0b5f4ee5899/go.mod h1:M0bM9SyaOWJniaHs9hxEzz91r5ql6gYq6o1q5O1SsjQ= +go.mau.fi/util v0.9.2-0.20251001114608-d99877b9cc10 h1:EvX/di02gOriKN0xGDJuQ5mgiNdAF4LJc8moffI7Svo= +go.mau.fi/util v0.9.2-0.20251001114608-d99877b9cc10/go.mod h1:M0bM9SyaOWJniaHs9hxEzz91r5ql6gYq6o1q5O1SsjQ= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= From 9ee13d136394e07aa20a02f3a665850a59afc3e2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 1 Oct 2025 14:48:28 +0300 Subject: [PATCH 350/581] bridgev2/portal: add option to exclude member changes from timeline by default --- bridgev2/portal.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 3884303a..817b3144 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -3592,6 +3592,10 @@ type ChatMemberList struct { // Should the bridge call IsThisUser for every member in the list? // This should be used when SenderLogin can't be filled accurately. CheckAllLogins bool + // Should any changes have the `com.beeper.exclude_from_timeline` flag set by default? + // This is recommended for syncs with non-real-time changes. + // Real-time changes (e.g. a user joining) should not set this flag set. + ExcludeChangesFromTimeline bool // The total number of members in the chat, regardless of how many of those members are included in MemberMap. TotalMemberCount int @@ -4048,6 +4052,12 @@ func (portal *Portal) syncParticipants( } delete(currentMembers, portal.Bridge.Bot.GetMXID()) powerChanged := members.PowerLevels.Apply(portal.Bridge.Bot.GetMXID(), currentPower) + addExcludeFromTimeline := func(raw map[string]any) { + _, hasKey := raw["com.beeper.exclude_from_timeline"] + if !hasKey && members.ExcludeChangesFromTimeline { + raw["com.beeper.exclude_from_timeline"] = true + } + } syncUser := func(extraUserID id.UserID, member ChatMember, intent MatrixAPI) bool { if member.Membership == "" { member.Membership = event.MembershipJoin @@ -4078,6 +4088,7 @@ func (portal *Portal) syncParticipants( AvatarURL: currentMember.AvatarURL, } wrappedContent := &event.Content{Parsed: content, Raw: exmaps.NonNilClone(member.MemberEventExtra)} + addExcludeFromTimeline(wrappedContent.Raw) thisEvtSender := sender if member.Membership == event.MembershipJoin { content.Membership = event.MembershipInvite @@ -4122,7 +4133,8 @@ func (portal *Portal) syncParticipants( if intent != nil && content.Membership == event.MembershipInvite && member.Membership == event.MembershipJoin { content.Membership = event.MembershipJoin - wrappedJoinContent := &event.Content{Parsed: content, Raw: member.MemberEventExtra} + wrappedJoinContent := &event.Content{Parsed: content, Raw: exmaps.NonNilClone(member.MemberEventExtra)} + addExcludeFromTimeline(wrappedContent.Raw) _, err = intent.SendState(ctx, portal.MXID, event.StateMember, intent.GetMXID().String(), wrappedJoinContent, ts) if err != nil { addLogContext(log.Err(err)). From dd778ae0cdaf0c147dc106403de64d25780a0b60 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 1 Oct 2025 14:55:35 +0300 Subject: [PATCH 351/581] bridgev2/portal: add option to exclude metadata changes from timeline --- bridgev2/portal.go | 101 +++++++++++++++++++++++++------------ bridgev2/portalinternal.go | 24 ++++++--- 2 files changed, 86 insertions(+), 39 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 817b3144..51d6a294 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1537,7 +1537,7 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( return EventHandlingResultIgnored } if !sender.Client.GetCapabilities(ctx, portal).DisappearingTimer.Supports(typedContent) { - portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent()) + portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), false) return EventHandlingResultFailed.WithMSSError(ErrDisappearingTimerUnsupported) } } @@ -1561,7 +1561,7 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( if err != nil { log.Err(err).Msg("Failed to handle Matrix room metadata") if evt.Type == event.StateBeeperDisappearingTimer { - portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent()) + portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), false) } return EventHandlingResultFailed.WithMSSError(err) } @@ -3712,6 +3712,8 @@ type ChatInfo struct { CanBackfill bool + ExcludeChangesFromTimeline bool + ExtraUpdates ExtraUpdater[*Portal] } @@ -3744,25 +3746,35 @@ type UserLocalPortalInfo struct { Tag *event.RoomTag } -func (portal *Portal) updateName(ctx context.Context, name string, sender MatrixAPI, ts time.Time) bool { +func (portal *Portal) updateName( + ctx context.Context, name string, sender MatrixAPI, ts time.Time, excludeFromTimeline bool, +) bool { if portal.Name == name && (portal.NameSet || portal.MXID == "") { return false } portal.Name = name - portal.NameSet = portal.sendRoomMeta(ctx, sender, ts, event.StateRoomName, "", &event.RoomNameEventContent{Name: name}) + portal.NameSet = portal.sendRoomMeta( + ctx, sender, ts, event.StateRoomName, "", &event.RoomNameEventContent{Name: name}, excludeFromTimeline, + ) return true } -func (portal *Portal) updateTopic(ctx context.Context, topic string, sender MatrixAPI, ts time.Time) bool { +func (portal *Portal) updateTopic( + ctx context.Context, topic string, sender MatrixAPI, ts time.Time, excludeFromTimeline bool, +) bool { if portal.Topic == topic && (portal.TopicSet || portal.MXID == "") { return false } portal.Topic = topic - portal.TopicSet = portal.sendRoomMeta(ctx, sender, ts, event.StateTopic, "", &event.TopicEventContent{Topic: topic}) + portal.TopicSet = portal.sendRoomMeta( + ctx, sender, ts, event.StateTopic, "", &event.TopicEventContent{Topic: topic}, excludeFromTimeline, + ) return true } -func (portal *Portal) updateAvatar(ctx context.Context, avatar *Avatar, sender MatrixAPI, ts time.Time) bool { +func (portal *Portal) updateAvatar( + ctx context.Context, avatar *Avatar, sender MatrixAPI, ts time.Time, excludeFromTimeline bool, +) bool { if portal.AvatarID == avatar.ID && (avatar.Remove || portal.AvatarMXC != "") && (portal.AvatarSet || portal.MXID == "") { return false } @@ -3785,7 +3797,9 @@ func (portal *Portal) updateAvatar(ctx context.Context, avatar *Avatar, sender M portal.AvatarMXC = newMXC portal.AvatarHash = newHash } - portal.AvatarSet = portal.sendRoomMeta(ctx, sender, ts, event.StateRoomAvatar, "", &event.RoomAvatarEventContent{URL: portal.AvatarMXC}) + portal.AvatarSet = portal.sendRoomMeta( + ctx, sender, ts, event.StateRoomAvatar, "", &event.RoomAvatarEventContent{URL: portal.AvatarMXC}, excludeFromTimeline, + ) return true } @@ -3851,8 +3865,8 @@ func (portal *Portal) UpdateBridgeInfo(ctx context.Context) { return } stateKey, bridgeInfo := portal.getBridgeInfo() - portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBridge, stateKey, &bridgeInfo) - portal.sendRoomMeta(ctx, nil, time.Now(), event.StateHalfShotBridge, stateKey, &bridgeInfo) + portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBridge, stateKey, &bridgeInfo, false) + portal.sendRoomMeta(ctx, nil, time.Now(), event.StateHalfShotBridge, stateKey, &bridgeInfo, false) } func (portal *Portal) UpdateCapabilities(ctx context.Context, source *UserLogin, implicit bool) bool { @@ -3874,7 +3888,7 @@ func (portal *Portal) UpdateCapabilities(ctx context.Context, source *UserLogin, Str("old_id", portal.CapState.ID). Str("new_id", capID). Msg("Sending new room capability event") - success := portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperRoomFeatures, portal.getBridgeInfoStateKey(), caps) + success := portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperRoomFeatures, portal.getBridgeInfoStateKey(), caps, false) if !success { return false } @@ -3885,7 +3899,7 @@ func (portal *Portal) UpdateCapabilities(ctx context.Context, source *UserLogin, } if caps.DisappearingTimer != nil && !portal.CapState.Flags.Has(database.CapStateFlagDisappearingTimerSet) { zerolog.Ctx(ctx).Debug().Msg("Disappearing timer capability was added, sending disappearing timer state event") - success = portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent()) + success = portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), true) if !success { return false } @@ -3916,15 +3930,24 @@ func (portal *Portal) sendStateWithIntentOrBot(ctx context.Context, sender Matri return } -func (portal *Portal) sendRoomMeta(ctx context.Context, sender MatrixAPI, ts time.Time, eventType event.Type, stateKey string, content any) bool { +func (portal *Portal) sendRoomMeta( + ctx context.Context, + sender MatrixAPI, + ts time.Time, + eventType event.Type, + stateKey string, + content any, + excludeFromTimeline bool, +) bool { if portal.MXID == "" { return false } - var extra map[string]any + extra := make(map[string]any) + if excludeFromTimeline { + extra["com.beeper.exclude_from_timeline"] = true + } if !portal.NameIsCustom && (eventType == event.StateRoomName || eventType == event.StateRoomAvatar) { - extra = map[string]any{ - "fi.mau.implicit_name": true, - } + extra["fi.mau.implicit_name"] = true } _, err := portal.sendStateWithIntentOrBot(ctx, sender, eventType, stateKey, &event.Content{ Parsed: content, @@ -4281,9 +4304,15 @@ type UpdateDisappearingSettingOpts struct { Implicit bool Save bool SendNotice bool + + ExcludeFromTimeline bool } -func (portal *Portal) UpdateDisappearingSetting(ctx context.Context, setting database.DisappearingSetting, opts UpdateDisappearingSettingOpts) bool { +func (portal *Portal) UpdateDisappearingSetting( + ctx context.Context, + setting database.DisappearingSetting, + opts UpdateDisappearingSettingOpts, +) bool { setting = setting.Normalize() if portal.Disappear.Timer == setting.Timer && portal.Disappear.Type == setting.Type { return false @@ -4306,7 +4335,15 @@ func (portal *Portal) UpdateDisappearingSetting(ctx context.Context, setting dat if opts.Timestamp.IsZero() { opts.Timestamp = time.Now() } - portal.sendRoomMeta(ctx, opts.Sender, opts.Timestamp, event.StateBeeperDisappearingTimer, "", setting.ToEventContent()) + portal.sendRoomMeta( + ctx, + opts.Sender, + opts.Timestamp, + event.StateBeeperDisappearingTimer, + "", + setting.ToEventContent(), + opts.ExcludeFromTimeline, + ) if !opts.SendNotice { return true @@ -4390,13 +4427,13 @@ func (portal *Portal) UpdateInfoFromGhost(ctx context.Context, ghost *Ghost) (ch return } } - changed = portal.updateName(ctx, ghost.Name, nil, time.Time{}) || changed + changed = portal.updateName(ctx, ghost.Name, nil, time.Time{}, false) || changed changed = portal.updateAvatar(ctx, &Avatar{ ID: ghost.AvatarID, MXC: ghost.AvatarMXC, Hash: ghost.AvatarHash, Remove: ghost.AvatarID == "", - }, nil, time.Time{}) || changed + }, nil, time.Time{}, false) || changed return } @@ -4405,26 +4442,28 @@ func (portal *Portal) UpdateInfo(ctx context.Context, info *ChatInfo, source *Us if info.Name == DefaultChatName { if portal.NameIsCustom { portal.NameIsCustom = false - changed = portal.updateName(ctx, "", sender, ts) || changed + changed = portal.updateName(ctx, "", sender, ts, info.ExcludeChangesFromTimeline) || changed } } else if info.Name != nil { portal.NameIsCustom = true - changed = portal.updateName(ctx, *info.Name, sender, ts) || changed + changed = portal.updateName(ctx, *info.Name, sender, ts, info.ExcludeChangesFromTimeline) || changed } if info.Topic != nil { - changed = portal.updateTopic(ctx, *info.Topic, sender, ts) || changed + changed = portal.updateTopic(ctx, *info.Topic, sender, ts, info.ExcludeChangesFromTimeline) || changed } if info.Avatar != nil { portal.NameIsCustom = true - changed = portal.updateAvatar(ctx, info.Avatar, sender, ts) || changed + changed = portal.updateAvatar(ctx, info.Avatar, sender, ts, info.ExcludeChangesFromTimeline) || changed } if info.Disappear != nil { changed = portal.UpdateDisappearingSetting(ctx, *info.Disappear, UpdateDisappearingSettingOpts{ - Sender: sender, - Timestamp: ts, - Implicit: false, - Save: false, - SendNotice: true, + Sender: sender, + Timestamp: ts, + Implicit: false, + Save: false, + + SendNotice: !info.ExcludeChangesFromTimeline, + ExcludeFromTimeline: info.ExcludeChangesFromTimeline, }) || changed } if info.ParentID != nil { @@ -4432,7 +4471,7 @@ func (portal *Portal) UpdateInfo(ctx context.Context, info *ChatInfo, source *Us } if info.JoinRule != nil { // TODO change detection instead of spamming this every time? - portal.sendRoomMeta(ctx, sender, ts, event.StateJoinRules, "", info.JoinRule) + portal.sendRoomMeta(ctx, sender, ts, event.StateJoinRules, "", info.JoinRule, info.ExcludeChangesFromTimeline) } if info.Type != nil && portal.RoomType != *info.Type { if portal.MXID != "" && (*info.Type == database.RoomTypeSpace || portal.RoomType == database.RoomTypeSpace) { diff --git a/bridgev2/portalinternal.go b/bridgev2/portalinternal.go index ddbadc76..d9373eb6 100644 --- a/bridgev2/portalinternal.go +++ b/bridgev2/portalinternal.go @@ -121,6 +121,10 @@ func (portal *PortalInternals) GetTargetUser(ctx context.Context, userID id.User return (*Portal)(portal).getTargetUser(ctx, userID) } +func (portal *PortalInternals) HandleMatrixDeleteChat(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixDeleteChat(ctx, sender, origSender, evt) +} + func (portal *PortalInternals) HandleMatrixMembership(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { return (*Portal)(portal).handleMatrixMembership(ctx, sender, origSender, evt) } @@ -249,6 +253,10 @@ func (portal *PortalInternals) HandleRemoteChatResync(ctx context.Context, sourc return (*Portal)(portal).handleRemoteChatResync(ctx, source, evt) } +func (portal *PortalInternals) FindOtherLogins(ctx context.Context, source *UserLogin) (ownUP *database.UserPortal, others []*database.UserPortal, err error) { + return (*Portal)(portal).findOtherLogins(ctx, source) +} + func (portal *PortalInternals) HandleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) EventHandlingResult { return (*Portal)(portal).handleRemoteChatDelete(ctx, source, evt) } @@ -257,16 +265,16 @@ func (portal *PortalInternals) HandleRemoteBackfill(ctx context.Context, source return (*Portal)(portal).handleRemoteBackfill(ctx, source, backfill) } -func (portal *PortalInternals) UpdateName(ctx context.Context, name string, sender MatrixAPI, ts time.Time) bool { - return (*Portal)(portal).updateName(ctx, name, sender, ts) +func (portal *PortalInternals) UpdateName(ctx context.Context, name string, sender MatrixAPI, ts time.Time, excludeFromTimeline bool) bool { + return (*Portal)(portal).updateName(ctx, name, sender, ts, excludeFromTimeline) } -func (portal *PortalInternals) UpdateTopic(ctx context.Context, topic string, sender MatrixAPI, ts time.Time) bool { - return (*Portal)(portal).updateTopic(ctx, topic, sender, ts) +func (portal *PortalInternals) UpdateTopic(ctx context.Context, topic string, sender MatrixAPI, ts time.Time, excludeFromTimeline bool) bool { + return (*Portal)(portal).updateTopic(ctx, topic, sender, ts, excludeFromTimeline) } -func (portal *PortalInternals) UpdateAvatar(ctx context.Context, avatar *Avatar, sender MatrixAPI, ts time.Time) bool { - return (*Portal)(portal).updateAvatar(ctx, avatar, sender, ts) +func (portal *PortalInternals) UpdateAvatar(ctx context.Context, avatar *Avatar, sender MatrixAPI, ts time.Time, excludeFromTimeline bool) bool { + return (*Portal)(portal).updateAvatar(ctx, avatar, sender, ts, excludeFromTimeline) } func (portal *PortalInternals) GetBridgeInfoStateKey() string { @@ -281,8 +289,8 @@ func (portal *PortalInternals) SendStateWithIntentOrBot(ctx context.Context, sen return (*Portal)(portal).sendStateWithIntentOrBot(ctx, sender, eventType, stateKey, content, ts) } -func (portal *PortalInternals) SendRoomMeta(ctx context.Context, sender MatrixAPI, ts time.Time, eventType event.Type, stateKey string, content any) bool { - return (*Portal)(portal).sendRoomMeta(ctx, sender, ts, eventType, stateKey, content) +func (portal *PortalInternals) SendRoomMeta(ctx context.Context, sender MatrixAPI, ts time.Time, eventType event.Type, stateKey string, content any, excludeFromTimeline bool) bool { + return (*Portal)(portal).sendRoomMeta(ctx, sender, ts, eventType, stateKey, content, excludeFromTimeline) } func (portal *PortalInternals) GetInitialMemberList(ctx context.Context, members *ChatMemberList, source *UserLogin, pl *event.PowerLevelsEventContent) (invite, functional []id.UserID, err error) { From 97da8eb44dc993295c659c33eb7351a4ff36260f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 2 Oct 2025 14:45:46 +0300 Subject: [PATCH 352/581] event: add helper to get remaining mute duration --- event/accountdata.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/event/accountdata.go b/event/accountdata.go index 30ca35a2..223919a1 100644 --- a/event/accountdata.go +++ b/event/accountdata.go @@ -105,3 +105,15 @@ func (bmec *BeeperMuteEventContent) GetMutedUntilTime() time.Time { } return time.Time{} } + +func (bmec *BeeperMuteEventContent) GetMuteDuration() time.Duration { + ts := bmec.GetMutedUntilTime() + now := time.Now() + if ts.Before(now) { + return 0 + } else if ts == MutedForever { + return -1 + } else { + return ts.Sub(now) + } +} From 5d69963ab546c11fc0862f04f7c025d9d974fe57 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 2 Oct 2025 17:19:45 +0300 Subject: [PATCH 353/581] bridgev2/portal: add exclude from timeline flag for not in chat leaves --- bridgev2/portal.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 51d6a294..4d2e60a0 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4230,6 +4230,9 @@ func (portal *Portal) syncParticipants( Displayname: memberEvt.Displayname, Reason: "User is not in remote chat", }, + Raw: map[string]any{ + "com.beeper.exclude_from_timeline": members.ExcludeChangesFromTimeline, + }, }, time.Now()) if err != nil { zerolog.Ctx(ctx).Err(err). From 9fc5d987743a56e6c4e376e23260cfde02508904 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 2 Oct 2025 21:57:25 +0300 Subject: [PATCH 354/581] bridgev2/mxmain: fix --version flag output --- bridgev2/matrix/mxmain/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/matrix/mxmain/main.go b/bridgev2/matrix/mxmain/main.go index 9e409875..ca0ca5f7 100644 --- a/bridgev2/matrix/mxmain/main.go +++ b/bridgev2/matrix/mxmain/main.go @@ -140,7 +140,7 @@ func (br *BridgeMain) PreInit() { flag.PrintHelp() os.Exit(0) } else if *version { - fmt.Println(br.ver.FormattedVersion) + fmt.Println(br.ver.VersionDescription) os.Exit(0) } else if *versionJSON { output := VersionJSONOutput{ From 8e668586f9c23b80864a100bb1368be9e7d2d0cf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 2 Oct 2025 22:10:22 +0300 Subject: [PATCH 355/581] appservice/intent: add room ID to fake join response --- appservice/intent.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appservice/intent.go b/appservice/intent.go index fa9d9e7a..4635f59a 100644 --- a/appservice/intent.go +++ b/appservice/intent.go @@ -306,7 +306,7 @@ func (intent *IntentAPI) SendCustomMembershipEvent(ctx context.Context, roomID i func (intent *IntentAPI) JoinRoomByID(ctx context.Context, roomID id.RoomID, extraContent ...map[string]interface{}) (resp *mautrix.RespJoinRoom, err error) { if intent.IsCustomPuppet || len(extraContent) > 0 { _, err = intent.SendCustomMembershipEvent(ctx, roomID, intent.UserID, event.MembershipJoin, "", extraContent...) - return &mautrix.RespJoinRoom{}, err + return &mautrix.RespJoinRoom{RoomID: roomID}, err } return intent.Client.JoinRoomByID(ctx, roomID) } From ce667a65e5783aa047beb3e8ea739a078fa0d581 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 3 Oct 2025 03:10:29 +0300 Subject: [PATCH 356/581] bridgev2/simplevent: add simpler form of message event --- bridgev2/simplevent/message.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/bridgev2/simplevent/message.go b/bridgev2/simplevent/message.go index f648ab12..ac9f8d77 100644 --- a/bridgev2/simplevent/message.go +++ b/bridgev2/simplevent/message.go @@ -59,6 +59,31 @@ func (evt *Message[T]) GetTransactionID() networkid.TransactionID { return evt.TransactionID } +// PreConvertedMessage is a simple implementation of [bridgev2.RemoteMessage] with pre-converted data. +type PreConvertedMessage struct { + EventMeta + Data *bridgev2.ConvertedMessage + ID networkid.MessageID + TransactionID networkid.TransactionID +} + +var ( + _ bridgev2.RemoteMessage = (*PreConvertedMessage)(nil) + _ bridgev2.RemoteMessageWithTransactionID = (*PreConvertedMessage)(nil) +) + +func (evt *PreConvertedMessage) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) { + return evt.Data, nil +} + +func (evt *PreConvertedMessage) GetID() networkid.MessageID { + return evt.ID +} + +func (evt *PreConvertedMessage) GetTransactionID() networkid.TransactionID { + return evt.TransactionID +} + type MessageRemove struct { EventMeta From 4be60a002169a527ef6b1a751ea9bafe1b665b0f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 3 Oct 2025 03:14:51 +0300 Subject: [PATCH 357/581] bridgev2/simplevent: allow upserts with PreConvertedMessage --- bridgev2/simplevent/message.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bridgev2/simplevent/message.go b/bridgev2/simplevent/message.go index ac9f8d77..f8f8d7e1 100644 --- a/bridgev2/simplevent/message.go +++ b/bridgev2/simplevent/message.go @@ -65,10 +65,13 @@ type PreConvertedMessage struct { Data *bridgev2.ConvertedMessage ID networkid.MessageID TransactionID networkid.TransactionID + + HandleExistingFunc func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (bridgev2.UpsertResult, error) } var ( _ bridgev2.RemoteMessage = (*PreConvertedMessage)(nil) + _ bridgev2.RemoteMessageUpsert = (*PreConvertedMessage)(nil) _ bridgev2.RemoteMessageWithTransactionID = (*PreConvertedMessage)(nil) ) @@ -84,6 +87,13 @@ func (evt *PreConvertedMessage) GetTransactionID() networkid.TransactionID { return evt.TransactionID } +func (evt *PreConvertedMessage) HandleExisting(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (bridgev2.UpsertResult, error) { + if evt.HandleExistingFunc == nil { + return bridgev2.UpsertResult{}, nil + } + return evt.HandleExistingFunc(ctx, portal, intent, existing) +} + type MessageRemove struct { EventMeta From 8a72af9f6b368e72366d2a689d552abfeafd065c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 3 Oct 2025 22:51:05 +0300 Subject: [PATCH 358/581] federation/eventauth: require that join authorizer is in the room --- federation/eventauth/eventauth.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/federation/eventauth/eventauth.go b/federation/eventauth/eventauth.go index 7d73abcd..bd102213 100644 --- a/federation/eventauth/eventauth.go +++ b/federation/eventauth/eventauth.go @@ -79,6 +79,7 @@ var ( ErrCantJoinOtherUser = AuthFailError{Index: "5.3.2", Message: "can't send join event with different state key"} ErrCantJoinBanned = AuthFailError{Index: "5.3.3", Message: "user is banned from the room"} ErrAuthoriserCantInvite = AuthFailError{Index: "5.3.5.2", Message: "authoriser doesn't have sufficient power level to invite"} + ErrAuthoriserNotInRoom = AuthFailError{Index: "5.3.5.2", Message: "authoriser isn't a member of the room"} ErrCantJoinWithoutInvite = AuthFailError{Index: "5.3.7", Message: "can't join invite-only room without invite"} ErrInvalidJoinRule = AuthFailError{Index: "5.3.7", Message: "invalid join rule in room"} ErrThirdPartyInviteBanned = AuthFailError{Index: "5.4.1.1", Message: "third party invite target user is banned"} @@ -384,6 +385,10 @@ func authorizeMember(roomVersion id.RoomVersion, evt, createEvt *pdu.PDU, authEv // 5.3.5.2. If the join_authorised_via_users_server key in content is not a user with sufficient permission to invite other users, reject. return ErrAuthoriserCantInvite } + authorizerMembership := event.Membership(findEventAndReadString(authEvents, event.StateMember.Type, authorizedVia.String(), "membership", string(event.MembershipLeave))) + if authorizerMembership != event.MembershipJoin { + return ErrAuthoriserNotInRoom + } // 5.3.5.3. Otherwise, allow. return nil case event.JoinRulePublic: From 13f251fe607b6f9a4bbb87bc00995c518f1ba5af Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 5 Oct 2025 12:30:54 +0300 Subject: [PATCH 359/581] crypto/helper: don't block on decryption --- client.go | 1 + crypto/cryptohelper/cryptohelper.go | 44 ++++++++++++++++++----------- sync.go | 1 + 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/client.go b/client.go index 62843218..ec527dd0 100644 --- a/client.go +++ b/client.go @@ -323,6 +323,7 @@ const ( LogBodyContextKey contextKey = iota LogRequestIDContextKey MaxAttemptsContextKey + SyncTokenContextKey ) func (cli *Client) RequestStart(req *http.Request) { diff --git a/crypto/cryptohelper/cryptohelper.go b/crypto/cryptohelper/cryptohelper.go index 74710678..1939ea79 100644 --- a/crypto/cryptohelper/cryptohelper.go +++ b/crypto/cryptohelper/cryptohelper.go @@ -297,24 +297,14 @@ func (helper *CryptoHelper) HandleEncrypted(ctx context.Context, evt *event.Even ctx = log.WithContext(ctx) decrypted, err := helper.Decrypt(ctx, evt) - if errors.Is(err, NoSessionFound) { - log.Debug(). - Int("wait_seconds", int(initialSessionWaitTimeout.Seconds())). - Msg("Couldn't find session, waiting for keys to arrive...") - if helper.mach.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, initialSessionWaitTimeout) { - log.Debug().Msg("Got keys after waiting, trying to decrypt event again") - decrypted, err = helper.Decrypt(ctx, evt) - } else { - go helper.waitLongerForSession(ctx, log, evt) - return - } - } - if err != nil { + if errors.Is(err, NoSessionFound) && ctx.Value(mautrix.SyncTokenContextKey) != "" { + go helper.waitForSession(ctx, evt) + } else if err != nil { log.Warn().Err(err).Msg("Failed to decrypt event") helper.DecryptErrorCallback(evt, err) - return + } else { + helper.postDecrypt(ctx, decrypted) } - helper.postDecrypt(ctx, decrypted) } func (helper *CryptoHelper) postDecrypt(ctx context.Context, decrypted *event.Event) { @@ -355,7 +345,29 @@ func (helper *CryptoHelper) RequestSession(ctx context.Context, roomID id.RoomID } } -func (helper *CryptoHelper) waitLongerForSession(ctx context.Context, log zerolog.Logger, evt *event.Event) { +func (helper *CryptoHelper) waitForSession(ctx context.Context, evt *event.Event) { + log := zerolog.Ctx(ctx) + content := evt.Content.AsEncrypted() + + log.Debug(). + Int("wait_seconds", int(initialSessionWaitTimeout.Seconds())). + Msg("Couldn't find session, waiting for keys to arrive...") + if helper.mach.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, initialSessionWaitTimeout) { + log.Debug().Msg("Got keys after waiting, trying to decrypt event again") + decrypted, err := helper.Decrypt(ctx, evt) + if err != nil { + log.Warn().Err(err).Msg("Failed to decrypt event") + helper.DecryptErrorCallback(evt, err) + } else { + helper.postDecrypt(ctx, decrypted) + } + } else { + go helper.waitLongerForSession(ctx, evt) + } +} + +func (helper *CryptoHelper) waitLongerForSession(ctx context.Context, evt *event.Event) { + log := zerolog.Ctx(ctx) content := evt.Content.AsEncrypted() log.Debug().Int("wait_seconds", int(extendedSessionWaitTimeout.Seconds())).Msg("Couldn't find session, requesting keys and waiting longer...") diff --git a/sync.go b/sync.go index c52bd2f9..598df8e0 100644 --- a/sync.go +++ b/sync.go @@ -90,6 +90,7 @@ func (s *DefaultSyncer) ProcessResponse(ctx context.Context, res *RespSync, sinc err = fmt.Errorf("ProcessResponse panicked! since=%s panic=%s\n%s", since, r, debug.Stack()) } }() + ctx = context.WithValue(ctx, SyncTokenContextKey, since) for _, listener := range s.syncListeners { if !listener(ctx, res, since) { From 07bc756971535211771ab59fd543f7ff03e40652 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 6 Oct 2025 16:51:41 +0300 Subject: [PATCH 360/581] changelog: update --- CHANGELOG.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 831e3094..43332ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## unreleased +## v0.25.2 (unreleased) * *(crypto)* Added helper methods for generating and verifying with recovery keys. @@ -7,12 +7,17 @@ * *(bridgev2/matrix)* Added initial support for using appservice/MSC3202 mode for encryption with standard servers like Synapse. * *(bridgev2)* Added optional support for implicit read receipts. +* *(bridgev2)* Added interface for deleting chats on remote network. * *(bridgev2)* Extended event duration logging to log any event taking too long. +* *(event)* Added event type constant for poll end events. +* *(client)* Added wrapper for searching user directory. +* *(crypto/helper)* Changed default sync handling to not block on waiting for + decryption keys. On initial sync, keys won't be requested at all by default. +* *(crypto)* Fixed olm unwedging not working (regressed in v0.25.1). * *(bridgev2)* Fixed various bugs with migrating to split portals. * *(event)* Fixed poll start events having incorrect null `m.relates_to`. -* *(event)* Added event type constant for poll end events. * *(client)* Fixed `RespUserProfile` losing standard fields when re-marshaling. -* *(client)* Added wrapper for searching user directory. +* *(federation)* Fixed various bugs in event auth. ## v0.25.1 (2025-09-16) From 344b04c4075ef99be514e90400fceb270b41f45c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 6 Oct 2025 17:03:30 +0300 Subject: [PATCH 361/581] event: add Clone method for file features --- event/capabilities.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/event/capabilities.go b/event/capabilities.go index 31a6b7aa..bd0c3d27 100644 --- a/event/capabilities.go +++ b/event/capabilities.go @@ -18,6 +18,7 @@ import ( "go.mau.fi/util/exerrors" "go.mau.fi/util/jsontime" + "go.mau.fi/util/ptr" "golang.org/x/exp/constraints" "golang.org/x/exp/maps" ) @@ -70,6 +71,14 @@ type FormattingFeatureMap map[FormattingFeature]CapabilitySupportLevel type FileFeatureMap map[CapabilityMsgType]*FileFeatures +func (ffm FileFeatureMap) Clone() FileFeatureMap { + dup := maps.Clone(ffm) + for key, value := range dup { + dup[key] = value.Clone() + } + return dup +} + type DisappearingTimerCapability struct { Types []DisappearingType `json:"types"` Timers []jsontime.Milliseconds `json:"timers,omitempty"` @@ -296,3 +305,10 @@ func (ff *FileFeatures) Hash() []byte { hashBool(hasher, "view_once", ff.ViewOnce) return hasher.Sum(nil) } + +func (ff *FileFeatures) Clone() *FileFeatures { + clone := *ff + clone.MimeTypes = maps.Clone(clone.MimeTypes) + clone.MaxDuration = ptr.Clone(clone.MaxDuration) + return &clone +} From 548970fd0f3dd17b3933c14c9cf268dab13518e9 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 6 Oct 2025 17:05:46 +0300 Subject: [PATCH 362/581] event: add Clone for other capability types too --- event/capabilities.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/event/capabilities.go b/event/capabilities.go index bd0c3d27..42afe5b6 100644 --- a/event/capabilities.go +++ b/event/capabilities.go @@ -67,6 +67,20 @@ func (rf *RoomFeatures) GetID() string { return base64.RawURLEncoding.EncodeToString(rf.Hash()) } +func (rf *RoomFeatures) Clone() *RoomFeatures { + if rf == nil { + return nil + } + clone := *rf + clone.File = clone.File.Clone() + clone.Formatting = maps.Clone(clone.Formatting) + clone.EditMaxAge = ptr.Clone(clone.EditMaxAge) + clone.DeleteMaxAge = ptr.Clone(clone.DeleteMaxAge) + clone.DisappearingTimer = clone.DisappearingTimer.Clone() + clone.AllowedReactions = slices.Clone(clone.AllowedReactions) + return &clone +} + type FormattingFeatureMap map[FormattingFeature]CapabilitySupportLevel type FileFeatureMap map[CapabilityMsgType]*FileFeatures @@ -86,6 +100,16 @@ type DisappearingTimerCapability struct { OmitEmptyTimer bool `json:"omit_empty_timer,omitempty"` } +func (dtc *DisappearingTimerCapability) Clone() *DisappearingTimerCapability { + if dtc == nil { + return nil + } + clone := *dtc + clone.Types = slices.Clone(clone.Types) + clone.Timers = slices.Clone(clone.Timers) + return &clone +} + func (dtc *DisappearingTimerCapability) Supports(content *BeeperDisappearingTimer) bool { if dtc == nil || content == nil || content.Type == DisappearingTypeNone { return true @@ -307,6 +331,9 @@ func (ff *FileFeatures) Hash() []byte { } func (ff *FileFeatures) Clone() *FileFeatures { + if ff == nil { + return nil + } clone := *ff clone.MimeTypes = maps.Clone(clone.MimeTypes) clone.MaxDuration = ptr.Clone(clone.MaxDuration) From 51edfc27c097e99d1468c30a3a50d1e641ec059e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 6 Oct 2025 23:00:04 +0300 Subject: [PATCH 363/581] bridgev2: add omitempty for group create params struct --- bridgev2/networkinterface.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 8dffbb34..31647f63 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -815,19 +815,19 @@ type GroupFieldCapability struct { } type GroupCreateParams struct { - Type string `json:"type"` + Type string `json:"type,omitempty"` - Username string `json:"username"` - Participants []networkid.UserID `json:"participants"` - Parent *networkid.PortalKey `json:"parent"` + Username string `json:"username,omitempty"` + Participants []networkid.UserID `json:"participants,omitempty"` + Parent *networkid.PortalKey `json:"parent,omitempty"` - Name *event.RoomNameEventContent `json:"name"` - Avatar *event.RoomAvatarEventContent `json:"avatar"` - Topic *event.TopicEventContent `json:"topic"` - Disappear *event.BeeperDisappearingTimer `json:"disappear"` + Name *event.RoomNameEventContent `json:"name,omitempty"` + Avatar *event.RoomAvatarEventContent `json:"avatar,omitempty"` + Topic *event.TopicEventContent `json:"topic,omitempty"` + Disappear *event.BeeperDisappearingTimer `json:"disappear,omitempty"` // An existing room ID to bridge to. If unset, a new room will be created. - RoomID id.RoomID `json:"room_id"` + RoomID id.RoomID `json:"room_id,omitempty"` } type GroupCreatingNetworkAPI interface { From 3a300246ac3c7895cd6b8b66ee8bf2c1cdf3ebf6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 6 Oct 2025 23:10:04 +0300 Subject: [PATCH 364/581] id/userid: split validation into 2 functions --- id/userid.go | 20 ++++++++++++++------ id/userid_test.go | 18 +++++++++--------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/id/userid.go b/id/userid.go index 6d9f4080..859d2358 100644 --- a/id/userid.go +++ b/id/userid.go @@ -104,16 +104,24 @@ func ValidateUserLocalpart(localpart string) error { return nil } -// ParseAndValidate parses the user ID into the localpart and server name like Parse, -// and also validates that the localpart is allowed according to the user identifiers spec. -func (userID UserID) ParseAndValidate() (localpart, homeserver string, err error) { - localpart, homeserver, err = userID.Parse() +// ParseAndValidateStrict is a stricter version of ParseAndValidateRelaxed that checks the localpart to only allow non-historical localparts. +// This should be used with care: there are real users still using historical localparts. +func (userID UserID) ParseAndValidateStrict() (localpart, homeserver string, err error) { + localpart, homeserver, err = userID.ParseAndValidateRelaxed() if err == nil { err = ValidateUserLocalpart(localpart) } - if err == nil && len(userID) > UserIDMaxLength { + return +} + +// ParseAndValidateRelaxed parses the user ID into the localpart and server name like Parse, +// and also validates that the user ID is not too long and that the server name is valid. +func (userID UserID) ParseAndValidateRelaxed() (localpart, homeserver string, err error) { + if len(userID) > UserIDMaxLength { err = ErrUserIDTooLong + return } + localpart, homeserver, err = userID.Parse() if err == nil && !ValidateServerName(homeserver) { err = fmt.Errorf("%q %q", homeserver, ErrNoncompliantServerPart) } @@ -121,7 +129,7 @@ func (userID UserID) ParseAndValidate() (localpart, homeserver string, err error } func (userID UserID) ParseAndDecode() (localpart, homeserver string, err error) { - localpart, homeserver, err = userID.ParseAndValidate() + localpart, homeserver, err = userID.ParseAndValidateStrict() if err == nil { localpart, err = DecodeUserLocalpart(localpart) } diff --git a/id/userid_test.go b/id/userid_test.go index 359bc687..57a88066 100644 --- a/id/userid_test.go +++ b/id/userid_test.go @@ -38,30 +38,30 @@ func TestUserID_Parse_Invalid(t *testing.T) { assert.True(t, errors.Is(err, id.ErrInvalidUserID)) } -func TestUserID_ParseAndValidate_Invalid(t *testing.T) { +func TestUserID_ParseAndValidateStrict_Invalid(t *testing.T) { const inputUserID = "@s p a c e:maunium.net" - _, _, err := id.UserID(inputUserID).ParseAndValidate() + _, _, err := id.UserID(inputUserID).ParseAndValidateStrict() assert.Error(t, err) assert.True(t, errors.Is(err, id.ErrNoncompliantLocalpart)) } -func TestUserID_ParseAndValidate_Empty(t *testing.T) { +func TestUserID_ParseAndValidateStrict_Empty(t *testing.T) { const inputUserID = "@:ponies.im" - _, _, err := id.UserID(inputUserID).ParseAndValidate() + _, _, err := id.UserID(inputUserID).ParseAndValidateStrict() assert.Error(t, err) assert.True(t, errors.Is(err, id.ErrEmptyLocalpart)) } -func TestUserID_ParseAndValidate_Long(t *testing.T) { +func TestUserID_ParseAndValidateStrict_Long(t *testing.T) { const inputUserID = "@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:example.com" - _, _, err := id.UserID(inputUserID).ParseAndValidate() + _, _, err := id.UserID(inputUserID).ParseAndValidateStrict() assert.Error(t, err) assert.True(t, errors.Is(err, id.ErrUserIDTooLong)) } -func TestUserID_ParseAndValidate_NotLong(t *testing.T) { +func TestUserID_ParseAndValidateStrict_NotLong(t *testing.T) { const inputUserID = "@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:example.com" - _, _, err := id.UserID(inputUserID).ParseAndValidate() + _, _, err := id.UserID(inputUserID).ParseAndValidateStrict() assert.NoError(t, err) } @@ -70,7 +70,7 @@ func TestUserIDEncoding(t *testing.T) { const encodedLocalpart = "_this=20local+part=20contains=20_il_le_ga_l=20ch=c3=a4racters=20=f0=9f=9a=a8" const inputServerName = "example.com" userID := id.NewEncodedUserID(inputLocalpart, inputServerName) - parsedLocalpart, parsedServerName, err := userID.ParseAndValidate() + parsedLocalpart, parsedServerName, err := userID.ParseAndValidateStrict() assert.NoError(t, err) assert.Equal(t, encodedLocalpart, parsedLocalpart) assert.Equal(t, inputServerName, parsedServerName) From d18142c7946f6c3332b7791f96814e860729480d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 8 Oct 2025 18:33:57 +0300 Subject: [PATCH 365/581] bridgev2/errors: add reason for unsupported errors --- bridgev2/errors.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/bridgev2/errors.go b/bridgev2/errors.go index 694224f1..29bba71f 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -45,29 +45,29 @@ var ( ErrIgnoringReactionFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring reaction event from relayed user")).WithIsCertain(true).WithSendNotice(false) ErrIgnoringPollFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring poll event from relayed user")).WithIsCertain(true).WithSendNotice(false) ErrIgnoringDeleteChatRelayedUser error = WrapErrorInStatus(errors.New("ignoring delete chat event from relayed user")).WithIsCertain(true).WithSendNotice(false) - ErrEditsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support edits")).WithIsCertain(true).WithErrorAsMessage() - ErrEditsNotSupportedInPortal error = WrapErrorInStatus(errors.New("edits are not allowed in this chat")).WithIsCertain(true).WithErrorAsMessage() - ErrCaptionsNotAllowed error = WrapErrorInStatus(errors.New("captions are not supported here")).WithIsCertain(true).WithErrorAsMessage() - ErrLocationMessagesNotAllowed error = WrapErrorInStatus(errors.New("location messages are not supported here")).WithIsCertain(true).WithErrorAsMessage() - ErrEditTargetTooOld error = WrapErrorInStatus(errors.New("the message is too old to be edited")).WithIsCertain(true).WithErrorAsMessage() - ErrEditTargetTooManyEdits error = WrapErrorInStatus(errors.New("the message has been edited too many times")).WithIsCertain(true).WithErrorAsMessage() - ErrReactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support reactions")).WithIsCertain(true).WithErrorAsMessage() - ErrPollsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support polls")).WithIsCertain(true).WithErrorAsMessage() - ErrRoomMetadataNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing room metadata")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) - ErrRedactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting messages")).WithIsCertain(true).WithErrorAsMessage() + ErrEditsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support edits")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrEditsNotSupportedInPortal error = WrapErrorInStatus(errors.New("edits are not allowed in this chat")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrCaptionsNotAllowed error = WrapErrorInStatus(errors.New("captions are not supported here")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrLocationMessagesNotAllowed error = WrapErrorInStatus(errors.New("location messages are not supported here")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrEditTargetTooOld error = WrapErrorInStatus(errors.New("the message is too old to be edited")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrEditTargetTooManyEdits error = WrapErrorInStatus(errors.New("the message has been edited too many times")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrReactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support reactions")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrPollsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support polls")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrRoomMetadataNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing room metadata")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrRedactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting messages")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) ErrUnexpectedParsedContentType error = WrapErrorInStatus(errors.New("unexpected parsed content type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) ErrInvalidStateKey error = WrapErrorInStatus(errors.New("room metadata state key is unset or non-empty")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) ErrDatabaseError error = WrapErrorInStatus(errors.New("database error")).WithMessage("internal database error").WithIsCertain(true).WithSendNotice(true) ErrTargetMessageNotFound error = WrapErrorInStatus(errors.New("target message not found")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) - ErrUnsupportedMessageType error = WrapErrorInStatus(errors.New("unsupported message type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) - ErrUnsupportedMediaType error = WrapErrorInStatus(errors.New("unsupported media type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) + ErrUnsupportedMessageType error = WrapErrorInStatus(errors.New("unsupported message type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + ErrUnsupportedMediaType error = WrapErrorInStatus(errors.New("unsupported media type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) ErrIgnoringMNotice error = WrapErrorInStatus(errors.New("ignoring m.notice message")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) ErrMediaDownloadFailed error = WrapErrorInStatus(errors.New("failed to download media")).WithMessage("failed to download media").WithIsCertain(true).WithSendNotice(true) ErrMediaReuploadFailed error = WrapErrorInStatus(errors.New("failed to reupload media")).WithMessage("failed to reupload media").WithIsCertain(true).WithSendNotice(true) ErrMediaConvertFailed error = WrapErrorInStatus(errors.New("failed to convert media")).WithMessage("failed to convert media").WithIsCertain(true).WithSendNotice(true) - ErrMembershipNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group membership")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) - ErrDeleteChatNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting chats")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) - ErrPowerLevelsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group power levels")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) + ErrMembershipNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group membership")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrDeleteChatNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting chats")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrPowerLevelsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group power levels")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) ErrRemoteEchoTimeout = WrapErrorInStatus(errors.New("remote echo timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) ErrRemoteAckTimeout = WrapErrorInStatus(errors.New("remote ack timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) From 9654a0b01e754932913b59f3ca420f59a9c83076 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 8 Oct 2025 18:47:55 +0300 Subject: [PATCH 366/581] bridgev2/portal: enforce media duration and size limits --- bridgev2/errors.go | 2 ++ bridgev2/portal.go | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/bridgev2/errors.go b/bridgev2/errors.go index 29bba71f..cf27ac6f 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -61,6 +61,8 @@ var ( ErrTargetMessageNotFound error = WrapErrorInStatus(errors.New("target message not found")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) ErrUnsupportedMessageType error = WrapErrorInStatus(errors.New("unsupported message type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) ErrUnsupportedMediaType error = WrapErrorInStatus(errors.New("unsupported media type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + ErrMediaDurationTooLong error = WrapErrorInStatus(errors.New("media duration too long")).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + ErrMediaTooLarge error = WrapErrorInStatus(errors.New("media too large")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) ErrIgnoringMNotice error = WrapErrorInStatus(errors.New("ignoring m.notice message")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) ErrMediaDownloadFailed error = WrapErrorInStatus(errors.New("failed to download media")).WithMessage("failed to download media").WithIsCertain(true).WithSendNotice(true) ErrMediaReuploadFailed error = WrapErrorInStatus(errors.New("failed to reupload media")).WithMessage("failed to reupload media").WithIsCertain(true).WithSendNotice(true) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 4d2e60a0..327e9815 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -944,8 +944,15 @@ func (portal *Portal) checkMessageContentCaps(caps *event.RoomFeatures, content feat.Caption.Reject() { return ErrCaptionsNotAllowed } - if content.Info != nil && content.Info.MimeType != "" { - if feat.GetMimeSupport(content.Info.MimeType).Reject() { + if content.Info != nil { + dur := time.Duration(content.Info.Duration) * time.Millisecond + if feat.MaxDuration != nil && dur > feat.MaxDuration.Duration { + return fmt.Errorf("%w: %s is longer than the maximum of %s", ErrMediaDurationTooLong, exfmt.Duration(dur), exfmt.Duration(feat.MaxDuration.Duration)) + } + if feat.MaxSize != 0 && int64(content.Info.Size) > feat.MaxSize { + return fmt.Errorf("%w: %.1f MiB is larger than the maximum of %.1f MiB", ErrMediaTooLarge, float64(content.Info.Size)/1024/1024, float64(feat.MaxSize)/1024/1024) + } + if content.Info.MimeType != "" && feat.GetMimeSupport(content.Info.MimeType).Reject() { return fmt.Errorf("%w (%s in %s)", ErrUnsupportedMediaType, content.Info.MimeType, capMsgType) } } From 91ea77b4d4123c6efe781cb31b9259240414a87c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 8 Oct 2025 19:16:00 +0300 Subject: [PATCH 367/581] bridgev2/portal: don't send implicit read receipts for account data --- bridgev2/portal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 327e9815..067d92c2 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -665,7 +665,7 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * // Copy logger because many of the handlers will use UpdateContext ctx = log.With().Str("login_id", string(login.ID)).Logger().WithContext(ctx) - if origSender == nil && portal.Bridge.Network.GetCapabilities().ImplicitReadReceipts { + if origSender == nil && portal.Bridge.Network.GetCapabilities().ImplicitReadReceipts && !evt.Type.IsAccountData() { rrLog := log.With().Str("subaction", "implicit read receipt").Logger() rrCtx := rrLog.WithContext(ctx) rrLog.Debug().Msg("Sending implicit read receipt for event") From 5593d8afcd32c7f9faa0850a22ae7ff932cb329c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 13 Oct 2025 15:30:12 +0300 Subject: [PATCH 368/581] changelog: update --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43332ac8..20ffbd06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## v0.25.2 (unreleased) +* **Breaking change *(id)*** Split `UserID.ParseAndValidate` into + `ParseAndValidateRelaxed` and `ParseAndValidateStrict`. Strict is the old + behavior, but most users likely want the relaxed version, as there are real + users whose user IDs aren't valid under the strict rules. * *(crypto)* Added helper methods for generating and verifying with recovery keys. * *(bridgev2/matrix)* Added config option to automatically generate a recovery @@ -8,6 +12,7 @@ for encryption with standard servers like Synapse. * *(bridgev2)* Added optional support for implicit read receipts. * *(bridgev2)* Added interface for deleting chats on remote network. +* *(bridgev2)* Added local enforcement of media duration and size limits. * *(bridgev2)* Extended event duration logging to log any event taking too long. * *(event)* Added event type constant for poll end events. * *(client)* Added wrapper for searching user directory. From 097813c9b29bf81690fe3f6341eee7269c589b0a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 14 Oct 2025 00:19:57 +0300 Subject: [PATCH 369/581] bridgev2/provisionutil: validate user IDs in CreateGroup if network supports it --- bridgev2/provisionutil/creategroup.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bridgev2/provisionutil/creategroup.go b/bridgev2/provisionutil/creategroup.go index 891f9615..7a21f682 100644 --- a/bridgev2/provisionutil/creategroup.go +++ b/bridgev2/provisionutil/creategroup.go @@ -37,6 +37,14 @@ func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev if len(params.Participants) < typeSpec.Participants.MinLength { return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at least %d members", typeSpec.Participants.MinLength)) } + userIDValidatingNetwork, ok := login.Bridge.Network.(bridgev2.IdentifierValidatingNetwork) + if ok { + for _, participant := range params.Participants { + if !userIDValidatingNetwork.ValidateUserID(participant) { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("User ID %q is not valid on this network", participant)) + } + } + } if (params.Name == nil || params.Name.Name == "") && typeSpec.Name.Required { return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name is required")) } else if nameLen := len(ptr.Val(params.Name).Name); nameLen > 0 && nameLen < typeSpec.Name.MinLength { From ab4a7852d6e022c38eca586ea57dcfbb3b36a837 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 14 Oct 2025 13:01:21 +0300 Subject: [PATCH 370/581] bridgev2/provisionutil: don't allow self in create group participants --- bridgev2/provisionutil/creategroup.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bridgev2/provisionutil/creategroup.go b/bridgev2/provisionutil/creategroup.go index 7a21f682..f389ab42 100644 --- a/bridgev2/provisionutil/creategroup.go +++ b/bridgev2/provisionutil/creategroup.go @@ -37,12 +37,13 @@ func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev if len(params.Participants) < typeSpec.Participants.MinLength { return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at least %d members", typeSpec.Participants.MinLength)) } - userIDValidatingNetwork, ok := login.Bridge.Network.(bridgev2.IdentifierValidatingNetwork) - if ok { - for _, participant := range params.Participants { - if !userIDValidatingNetwork.ValidateUserID(participant) { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("User ID %q is not valid on this network", participant)) - } + userIDValidatingNetwork, uidValOK := login.Bridge.Network.(bridgev2.IdentifierValidatingNetwork) + for _, participant := range params.Participants { + if uidValOK && !userIDValidatingNetwork.ValidateUserID(participant) { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("User ID %q is not valid on this network", participant)) + } + if api.IsThisUser(ctx, participant) { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("You can't include yourself in the participants list", participant)) } } if (params.Name == nil || params.Name.Name == "") && typeSpec.Name.Required { From 080ad4c0a0e8b81d243f82ce206bf656cdf0a6fe Mon Sep 17 00:00:00 2001 From: Toni Spets Date: Tue, 14 Oct 2025 13:32:02 +0300 Subject: [PATCH 371/581] crypto: Allow decrypting message content without event id or ts Replay attack prevention shouldn't store empty event id or ts to database if we're decrypting without them. This may happen if we are looking into a future delayed event for example as it doesn't yet have those. We still prevent doing that if we already know them meaning we have gotten the actual event through sync as that's also when a delayed event would move from scheduled to finalised and then it also contains those fields. --- crypto/sql_store.go | 14 ++++++++++++++ crypto/store.go | 3 +++ crypto/store_test.go | 12 +++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crypto/sql_store.go b/crypto/sql_store.go index 13940d79..ca75b3f6 100644 --- a/crypto/sql_store.go +++ b/crypto/sql_store.go @@ -664,6 +664,20 @@ func (store *SQLCryptoStore) IsOutboundGroupSessionShared(ctx context.Context, u // ValidateMessageIndex returns whether the given event information match the ones stored in the database // for the given sender key, session ID and index. If the index hasn't been stored, this will store it. func (store *SQLCryptoStore) ValidateMessageIndex(ctx context.Context, senderKey id.SenderKey, sessionID id.SessionID, eventID id.EventID, index uint, timestamp int64) (bool, error) { + if eventID == "" && timestamp == 0 { + var notOK bool + const validateEmptyQuery = ` + SELECT EXISTS(SELECT 1 FROM crypto_message_index WHERE sender_key=$1 AND session_id=$2 AND "index"=$3) + ` + err := store.DB.QueryRow(ctx, validateEmptyQuery, senderKey, sessionID, index).Scan(¬OK) + if notOK { + zerolog.Ctx(ctx).Debug(). + Uint("message_index", index). + Msg("Rejecting event without event ID and timestamp due to already knowing them") + } + return !notOK, err + } + const validateQuery = ` INSERT INTO crypto_message_index (sender_key, session_id, "index", event_id, timestamp) VALUES ($1, $2, $3, $4, $5) diff --git a/crypto/store.go b/crypto/store.go index 8b7c0a96..7620cf35 100644 --- a/crypto/store.go +++ b/crypto/store.go @@ -525,6 +525,9 @@ func (gs *MemoryStore) ValidateMessageIndex(_ context.Context, senderKey id.Send } val, ok := gs.MessageIndices[key] if !ok { + if eventID == "" && timestamp == 0 { + return true, nil + } gs.MessageIndices[key] = messageIndexValue{ EventID: eventID, Timestamp: timestamp, diff --git a/crypto/store_test.go b/crypto/store_test.go index 8aeae7af..7a47243e 100644 --- a/crypto/store_test.go +++ b/crypto/store_test.go @@ -75,8 +75,13 @@ func TestValidateMessageIndex(t *testing.T) { t.Run(storeName, func(t *testing.T) { acc := NewOlmAccount() + // Validating without event ID and timestamp before we have them should work + ok, err := store.ValidateMessageIndex(context.TODO(), acc.IdentityKey(), "sess1", "", 0, 0) + require.NoError(t, err, "Error validating message index") + assert.True(t, ok, "First message validation should be valid") + // First message should validate successfully - ok, err := store.ValidateMessageIndex(context.TODO(), acc.IdentityKey(), "sess1", "event1", 0, 1000) + ok, err = store.ValidateMessageIndex(context.TODO(), acc.IdentityKey(), "sess1", "event1", 0, 1000) require.NoError(t, err, "Error validating message index") assert.True(t, ok, "First message validation should be valid") @@ -94,6 +99,11 @@ func TestValidateMessageIndex(t *testing.T) { ok, err = store.ValidateMessageIndex(context.TODO(), acc.IdentityKey(), "sess1", "event1", 0, 1000) require.NoError(t, err, "Error validating message index") assert.True(t, ok, "First message validation should be valid") + + // Validating without event ID and timestamp must fail if we already know them + ok, err = store.ValidateMessageIndex(context.TODO(), acc.IdentityKey(), "sess1", "", 0, 0) + require.NoError(t, err, "Error validating message index") + assert.False(t, ok, "First message validation should be invalid") }) } } From 22ea75db96fd968bbb7cd7938aa5b5c19628d945 Mon Sep 17 00:00:00 2001 From: Toni Spets Date: Fri, 10 Oct 2025 14:58:50 +0300 Subject: [PATCH 372/581] client,event: MSC4140: Delayed events Includes transparent migration from deprecated MSC fields still used in Synapse to later revision. --- client.go | 26 ++++++++++++++++++ event/delayed.go | 70 ++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +-- id/opaque.go | 3 +++ requests.go | 10 +++++-- responses.go | 13 ++++++++- 7 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 event/delayed.go diff --git a/client.go b/client.go index ec527dd0..95cbacb5 100644 --- a/client.go +++ b/client.go @@ -1313,6 +1313,32 @@ func (cli *Client) SendMassagedStateEvent(ctx context.Context, roomID id.RoomID, return } +func (cli *Client) DelayedEvents(ctx context.Context, req *ReqDelayedEvents) (resp *RespDelayedEvents, err error) { + query := map[string]string{} + if req.DelayID != "" { + query["delay_id"] = string(req.DelayID) + } + if req.Status != "" { + query["status"] = string(req.Status) + } + if req.NextBatch != "" { + query["next_batch"] = req.NextBatch + } + + urlPath := cli.BuildURLWithQuery(ClientURLPath{"unstable", "org.matrix.msc4140", "delayed_events"}, query) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, req, &resp) + + // Migration: merge old keys with new ones + if resp != nil { + resp.Scheduled = append(resp.Scheduled, resp.DelayedEvents...) + resp.DelayedEvents = nil + resp.Finalised = append(resp.Finalised, resp.FinalisedEvents...) + resp.FinalisedEvents = nil + } + + return +} + func (cli *Client) UpdateDelayedEvent(ctx context.Context, req *ReqUpdateDelayedEvent) (resp *RespUpdateDelayedEvent, err error) { urlPath := cli.BuildClientURL("unstable", "org.matrix.msc4140", "delayed_events", req.DelayID) _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) diff --git a/event/delayed.go b/event/delayed.go new file mode 100644 index 00000000..fefb62af --- /dev/null +++ b/event/delayed.go @@ -0,0 +1,70 @@ +package event + +import ( + "encoding/json" + + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix/id" +) + +type ScheduledDelayedEvent struct { + DelayID id.DelayID `json:"delay_id"` + RoomID id.RoomID `json:"room_id"` + Type Type `json:"type"` + StateKey *string `json:"state_key,omitempty"` + Delay int64 `json:"delay"` + RunningSince jsontime.UnixMilli `json:"running_since"` + Content Content `json:"content"` +} + +func (e ScheduledDelayedEvent) AsEvent(eventID id.EventID, ts jsontime.UnixMilli) (*Event, error) { + evt := &Event{ + ID: eventID, + RoomID: e.RoomID, + Type: e.Type, + StateKey: e.StateKey, + Content: e.Content, + Timestamp: ts.UnixMilli(), + } + return evt, evt.Content.ParseRaw(evt.Type) +} + +type FinalisedDelayedEvent struct { + DelayedEvent *ScheduledDelayedEvent `json:"scheduled_event"` + Outcome DelayOutcome `json:"outcome"` + Reason DelayReason `json:"reason"` + Error json.RawMessage `json:"error,omitempty"` + EventID id.EventID `json:"event_id,omitempty"` + Timestamp jsontime.UnixMilli `json:"origin_server_ts"` +} + +type DelayStatus string + +var ( + DelayStatusScheduled DelayStatus = "scheduled" + DelayStatusFinalised DelayStatus = "finalised" +) + +type DelayAction string + +var ( + DelayActionSend DelayAction = "send" + DelayActionCancel DelayAction = "cancel" + DelayActionRestart DelayAction = "restart" +) + +type DelayOutcome string + +var ( + DelayOutcomeSend DelayOutcome = "send" + DelayOutcomeCancel DelayOutcome = "cancel" +) + +type DelayReason string + +var ( + DelayReasonAction DelayReason = "action" + DelayReasonError DelayReason = "error" + DelayReasonDelay DelayReason = "delay" +) diff --git a/go.mod b/go.mod index 70bf601e..d77428d8 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.13 - go.mau.fi/util v0.9.2-0.20251001114608-d99877b9cc10 + go.mau.fi/util v0.9.2-0.20251014102252-c9ee13b043c8 go.mau.fi/zeroconfig v0.2.0 golang.org/x/crypto v0.42.0 golang.org/x/exp v0.0.0-20250911091902-df9299821621 diff --git a/go.sum b/go.sum index 639b30a2..dee6616c 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.2-0.20251001114608-d99877b9cc10 h1:EvX/di02gOriKN0xGDJuQ5mgiNdAF4LJc8moffI7Svo= -go.mau.fi/util v0.9.2-0.20251001114608-d99877b9cc10/go.mod h1:M0bM9SyaOWJniaHs9hxEzz91r5ql6gYq6o1q5O1SsjQ= +go.mau.fi/util v0.9.2-0.20251014102252-c9ee13b043c8 h1:36oe41yPjz7QLjJWb72qHi82IOINqgp06eHIVRdalGs= +go.mau.fi/util v0.9.2-0.20251014102252-c9ee13b043c8/go.mod h1:M0bM9SyaOWJniaHs9hxEzz91r5ql6gYq6o1q5O1SsjQ= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= diff --git a/id/opaque.go b/id/opaque.go index 1d9f0dcf..c1ad4988 100644 --- a/id/opaque.go +++ b/id/opaque.go @@ -32,6 +32,9 @@ type EventID string // https://github.com/matrix-org/matrix-doc/pull/2716 type BatchID string +// A DelayID is a string identifying a delayed event. +type DelayID string + func (roomID RoomID) String() string { return string(roomID) } diff --git a/requests.go b/requests.go index 9dfe09ab..f0287b3c 100644 --- a/requests.go +++ b/requests.go @@ -376,9 +376,15 @@ type ReqSendEvent struct { MeowEventID id.EventID } +type ReqDelayedEvents struct { + DelayID id.DelayID `json:"-"` + Status event.DelayStatus `json:"-"` + NextBatch string `json:"-"` +} + type ReqUpdateDelayedEvent struct { - DelayID string `json:"-"` - Action string `json:"action"` // TODO use enum + DelayID id.DelayID `json:"-"` + Action event.DelayAction `json:"action"` } // ReqDeviceInfo is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3devicesdeviceid diff --git a/responses.go b/responses.go index a79be28b..3484c134 100644 --- a/responses.go +++ b/responses.go @@ -104,11 +104,22 @@ type RespContext struct { type RespSendEvent struct { EventID id.EventID `json:"event_id"` - UnstableDelayID string `json:"delay_id,omitempty"` + UnstableDelayID id.DelayID `json:"delay_id,omitempty"` } type RespUpdateDelayedEvent struct{} +type RespDelayedEvents struct { + Scheduled []*event.ScheduledDelayedEvent `json:"scheduled,omitempty"` + Finalised []*event.FinalisedDelayedEvent `json:"finalised,omitempty"` + NextBatch string `json:"next_batch,omitempty"` + + // Deprecated: Synapse implementation still returns this + DelayedEvents []*event.ScheduledDelayedEvent `json:"delayed_events,omitempty"` + // Deprecated: Synapse implementation still returns this + FinalisedEvents []*event.FinalisedDelayedEvent `json:"finalised_events,omitempty"` +} + type RespRedactUserEvents struct { IsMoreEvents bool `json:"is_more_events"` RedactedEvents struct { From 50a49e01f3ac07665309d0dc13b1349e004d2658 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 16 Oct 2025 11:26:46 +0200 Subject: [PATCH 373/581] Bump version to v0.25.2 --- CHANGELOG.md | 4 +++- go.mod | 14 +++++++------- go.sum | 24 ++++++++++++------------ version.go | 2 +- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ffbd06..f59e6853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## v0.25.2 (unreleased) +## v0.25.2 (2025-10-16) * **Breaking change *(id)*** Split `UserID.ParseAndValidate` into `ParseAndValidateRelaxed` and `ParseAndValidateStrict`. Strict is the old @@ -14,8 +14,10 @@ * *(bridgev2)* Added interface for deleting chats on remote network. * *(bridgev2)* Added local enforcement of media duration and size limits. * *(bridgev2)* Extended event duration logging to log any event taking too long. +* *(bridgev2)* Improved validation in group creation provisioning API. * *(event)* Added event type constant for poll end events. * *(client)* Added wrapper for searching user directory. +* *(client)* Improved support for managing [MSC4140] delayed events. * *(crypto/helper)* Changed default sync handling to not block on waiting for decryption keys. On initial sync, keys won't be requested at all by default. * *(crypto)* Fixed olm unwedging not working (regressed in v0.25.1). diff --git a/go.mod b/go.mod index d77428d8..fb63cf59 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module maunium.net/go/mautrix go 1.24.0 -toolchain go1.25.1 +toolchain go1.25.3 require ( filippo.io/edwards25519 v1.1.0 @@ -17,11 +17,11 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.13 - go.mau.fi/util v0.9.2-0.20251014102252-c9ee13b043c8 + go.mau.fi/util v0.9.2 go.mau.fi/zeroconfig v0.2.0 - golang.org/x/crypto v0.42.0 - golang.org/x/exp v0.0.0-20250911091902-df9299821621 - golang.org/x/net v0.44.0 + golang.org/x/crypto v0.43.0 + golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b + golang.org/x/net v0.46.0 golang.org/x/sync v0.17.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 @@ -36,7 +36,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index dee6616c..faa4ef4c 100644 --- a/go.sum +++ b/go.sum @@ -51,26 +51,26 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.2-0.20251014102252-c9ee13b043c8 h1:36oe41yPjz7QLjJWb72qHi82IOINqgp06eHIVRdalGs= -go.mau.fi/util v0.9.2-0.20251014102252-c9ee13b043c8/go.mod h1:M0bM9SyaOWJniaHs9hxEzz91r5ql6gYq6o1q5O1SsjQ= +go.mau.fi/util v0.9.2 h1:+S4Z03iCsGqU2WY8X2gySFsFjaLlUHFRDVCYvVwynKM= +go.mau.fi/util v0.9.2/go.mod h1:055elBBCJSdhRsmub7ci9hXZPgGr1U6dYg44cSgRgoU= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= -golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= -golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b h1:18qgiDvlvH7kk8Ioa8Ov+K6xCi0GMvmGfGW0sgd/SYA= +golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= diff --git a/version.go b/version.go index b76a548c..7b4eea41 100644 --- a/version.go +++ b/version.go @@ -8,7 +8,7 @@ import ( "strings" ) -const Version = "v0.25.1" +const Version = "v0.25.2" var GoModVersion = "" var Commit = "" From 572a704b04da6e82354ca8448c4019760a5aca50 Mon Sep 17 00:00:00 2001 From: Brad Murray Date: Sat, 18 Oct 2025 05:42:01 -0400 Subject: [PATCH 374/581] errors: Add M_WRONG_ROOM_KEYS_VERSION (#419) --- error.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/error.go b/error.go index b7c92a5f..59e574d7 100644 --- a/error.go +++ b/error.go @@ -67,6 +67,8 @@ var ( MIncompatibleRoomVersion = RespError{ErrCode: "M_INCOMPATIBLE_ROOM_VERSION"} // The client specified a parameter that has the wrong value. MInvalidParam = RespError{ErrCode: "M_INVALID_PARAM", StatusCode: http.StatusBadRequest} + // The client specified a room key backup version that is not the current room key backup version for the user. + MWrongRoomKeysVersion = RespError{ErrCode: "M_WRONG_ROOM_KEYS_VERSION", StatusCode: http.StatusForbidden} MURLNotSet = RespError{ErrCode: "M_URL_NOT_SET"} MBadStatus = RespError{ErrCode: "M_BAD_STATUS"} From a214af5bab636f1203e60c41155e971a926a8efa Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 16 Oct 2025 11:35:28 +0200 Subject: [PATCH 375/581] federation: fix server key query test --- federation/serverauth_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/federation/serverauth_test.go b/federation/serverauth_test.go index 9fa15459..633a0f66 100644 --- a/federation/serverauth_test.go +++ b/federation/serverauth_test.go @@ -21,7 +21,7 @@ func TestServerKeyResponse_VerifySelfSignature(t *testing.T) { ctx := context.Background() for _, name := range []string{"matrix.org", "maunium.net", "continuwuity.org"} { t.Run(name, func(t *testing.T) { - resp, err := cli.ServerKeys(ctx, "matrix.org") + resp, err := cli.ServerKeys(ctx, name) require.NoError(t, err) assert.NoError(t, resp.VerifySelfSignature()) }) From df957301be579fb0f6eb8b4b2644ce84db2df334 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 18 Oct 2025 13:29:16 +0200 Subject: [PATCH 376/581] federation: don't allow redirects --- federation/client.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/federation/client.go b/federation/client.go index 8f454516..5c316e56 100644 --- a/federation/client.go +++ b/federation/client.go @@ -37,6 +37,10 @@ func NewClient(serverName string, key *SigningKey, cache ResolutionCache) *Clien HTTP: &http.Client{ Transport: NewServerResolvingTransport(cache), Timeout: 120 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // Federation requests do not allow redirects. + return http.ErrUseLastResponse + }, }, UserAgent: mautrix.DefaultUserAgent, ServerName: serverName, @@ -310,7 +314,7 @@ func (c *Client) MakeFullRequest(ctx context.Context, params RequestParams) ([]b _ = resp.Body.Close() }() var body []byte - if resp.StatusCode >= 400 { + if resp.StatusCode >= 300 { body, err = mautrix.ParseErrorResponse(req, resp) return body, resp, err } else if params.ResponseJSON != nil || !params.DontReadBody { From 827bb4c6212ca273bf14e84dbe045132e755e44e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 18 Oct 2025 13:33:30 +0200 Subject: [PATCH 377/581] federation: add response size limit --- federation/client.go | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/federation/client.go b/federation/client.go index 5c316e56..c84b437a 100644 --- a/federation/client.go +++ b/federation/client.go @@ -30,6 +30,8 @@ type Client struct { ServerName string UserAgent string Key *SigningKey + + ResponseSizeLimit int64 } func NewClient(serverName string, key *SigningKey, cache ResolutionCache) *Client { @@ -45,6 +47,8 @@ func NewClient(serverName string, key *SigningKey, cache ResolutionCache) *Clien UserAgent: mautrix.DefaultUserAgent, ServerName: serverName, Key: key, + + ResponseSizeLimit: 128 * 1024 * 1024, } } @@ -318,7 +322,16 @@ func (c *Client) MakeFullRequest(ctx context.Context, params RequestParams) ([]b body, err = mautrix.ParseErrorResponse(req, resp) return body, resp, err } else if params.ResponseJSON != nil || !params.DontReadBody { - body, err = io.ReadAll(resp.Body) + if resp.ContentLength > c.ResponseSizeLimit { + return body, resp, mautrix.HTTPError{ + Request: req, + Response: resp, + + Message: "response body too long", + WrappedError: fmt.Errorf("%.2f MiB", float64(resp.ContentLength)/1024/1024), + } + } + body, err = io.ReadAll(io.LimitReader(resp.Body, c.ResponseSizeLimit+1)) if err != nil { return body, resp, mautrix.HTTPError{ Request: req, @@ -328,6 +341,15 @@ func (c *Client) MakeFullRequest(ctx context.Context, params RequestParams) ([]b WrappedError: err, } } + if len(body) > int(c.ResponseSizeLimit) { + return body, resp, mautrix.HTTPError{ + Request: req, + Response: resp, + + Message: "failed to read response body", + WrappedError: fmt.Errorf("exceeded read limit"), + } + } if params.ResponseJSON != nil { err = json.Unmarshal(body, params.ResponseJSON) if err != nil { From c50460cd6e3e70245026ca6f020c00e68fd3f0a0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 18 Oct 2025 13:37:19 +0200 Subject: [PATCH 378/581] client: add response size limits --- client.go | 145 +++++++++++++++++++++++++++++---------- error.go | 3 + federation/client.go | 18 ++--- federation/resolution.go | 6 +- 4 files changed, 124 insertions(+), 48 deletions(-) diff --git a/client.go b/client.go index 95cbacb5..85a4603e 100644 --- a/client.go +++ b/client.go @@ -111,6 +111,8 @@ type Client struct { // Set to true to disable automatically sleeping on 429 errors. IgnoreRateLimit bool + ResponseSizeLimit int64 + txnID int32 // Should the ?user_id= query parameter be set in requests? @@ -143,6 +145,8 @@ func DiscoverClientAPI(ctx context.Context, serverName string) (*ClientWellKnown return DiscoverClientAPIWithClient(ctx, &http.Client{Timeout: 30 * time.Second}, serverName) } +const WellKnownMaxSize = 64 * 1024 + func DiscoverClientAPIWithClient(ctx context.Context, client *http.Client, serverName string) (*ClientWellKnown, error) { wellKnownURL := url.URL{ Scheme: "https", @@ -168,11 +172,15 @@ func DiscoverClientAPIWithClient(ctx context.Context, client *http.Client, serve if resp.StatusCode == http.StatusNotFound { return nil, nil + } else if resp.ContentLength > WellKnownMaxSize { + return nil, errors.New(".well-known response too large") } - data, err := io.ReadAll(resp.Body) + data, err := io.ReadAll(io.LimitReader(resp.Body, WellKnownMaxSize)) if err != nil { return nil, err + } else if len(data) >= WellKnownMaxSize { + return nil, errors.New(".well-known response too large") } var wellKnown ClientWellKnown @@ -395,24 +403,25 @@ func (cli *Client) MakeRequest(ctx context.Context, method string, httpURL strin return cli.MakeFullRequest(ctx, FullRequest{Method: method, URL: httpURL, RequestJSON: reqBody, ResponseJSON: resBody}) } -type ClientResponseHandler = func(req *http.Request, res *http.Response, responseJSON interface{}) ([]byte, error) +type ClientResponseHandler = func(req *http.Request, res *http.Response, responseJSON any, sizeLimit int64) ([]byte, error) type FullRequest struct { - Method string - URL string - Headers http.Header - RequestJSON interface{} - RequestBytes []byte - RequestBody io.Reader - RequestLength int64 - ResponseJSON interface{} - MaxAttempts int - BackoffDuration time.Duration - SensitiveContent bool - Handler ClientResponseHandler - DontReadResponse bool - Logger *zerolog.Logger - Client *http.Client + Method string + URL string + Headers http.Header + RequestJSON interface{} + RequestBytes []byte + RequestBody io.Reader + RequestLength int64 + ResponseJSON interface{} + MaxAttempts int + BackoffDuration time.Duration + SensitiveContent bool + Handler ClientResponseHandler + DontReadResponse bool + ResponseSizeLimit int64 + Logger *zerolog.Logger + Client *http.Client } var requestID int32 @@ -537,10 +546,25 @@ func (cli *Client) MakeFullRequestWithResp(ctx context.Context, params FullReque if len(cli.AccessToken) > 0 { req.Header.Set("Authorization", "Bearer "+cli.AccessToken) } + if params.ResponseSizeLimit == 0 { + params.ResponseSizeLimit = cli.ResponseSizeLimit + } + if params.ResponseSizeLimit == 0 { + params.ResponseSizeLimit = DefaultResponseSizeLimit + } if params.Client == nil { params.Client = cli.Client } - return cli.executeCompiledRequest(req, params.MaxAttempts-1, params.BackoffDuration, params.ResponseJSON, params.Handler, params.DontReadResponse, params.Client) + return cli.executeCompiledRequest( + req, + params.MaxAttempts-1, + params.BackoffDuration, + params.ResponseJSON, + params.Handler, + params.DontReadResponse, + params.ResponseSizeLimit, + params.Client, + ) } func (cli *Client) cliOrContextLog(ctx context.Context) *zerolog.Logger { @@ -551,7 +575,17 @@ func (cli *Client) cliOrContextLog(ctx context.Context) *zerolog.Logger { return log } -func (cli *Client) doRetry(req *http.Request, cause error, retries int, backoff time.Duration, responseJSON any, handler ClientResponseHandler, dontReadResponse bool, client *http.Client) ([]byte, *http.Response, error) { +func (cli *Client) doRetry( + req *http.Request, + cause error, + retries int, + backoff time.Duration, + responseJSON any, + handler ClientResponseHandler, + dontReadResponse bool, + sizeLimit int64, + client *http.Client, +) ([]byte, *http.Response, error) { log := zerolog.Ctx(req.Context()) if req.Body != nil { var err error @@ -585,11 +619,23 @@ func (cli *Client) doRetry(req *http.Request, cause error, retries int, backoff if cli.UpdateRequestOnRetry != nil { req = cli.UpdateRequestOnRetry(req, cause) } - return cli.executeCompiledRequest(req, retries-1, backoff*2, responseJSON, handler, dontReadResponse, client) + return cli.executeCompiledRequest(req, retries-1, backoff*2, responseJSON, handler, dontReadResponse, sizeLimit, client) } -func readResponseBody(req *http.Request, res *http.Response) ([]byte, error) { - contents, err := io.ReadAll(res.Body) +func readResponseBody(req *http.Request, res *http.Response, limit int64) ([]byte, error) { + if res.ContentLength > limit { + return nil, HTTPError{ + Request: req, + Response: res, + + Message: "not reading response", + WrappedError: fmt.Errorf("%w (%.2f MiB)", ErrResponseTooLong, float64(res.ContentLength)/1024/1024), + } + } + contents, err := io.ReadAll(io.LimitReader(res.Body, limit+1)) + if err == nil && len(contents) > int(limit) { + err = ErrBodyReadReachedLimit + } if err != nil { return nil, HTTPError{ Request: req, @@ -610,17 +656,20 @@ func closeTemp(log *zerolog.Logger, file *os.File) { } } -func streamResponse(req *http.Request, res *http.Response, responseJSON interface{}) ([]byte, error) { +func streamResponse(req *http.Request, res *http.Response, responseJSON any, limit int64) ([]byte, error) { log := zerolog.Ctx(req.Context()) file, err := os.CreateTemp("", "mautrix-response-") if err != nil { log.Warn().Err(err).Msg("Failed to create temporary file for streaming response") - _, err = handleNormalResponse(req, res, responseJSON) + _, err = handleNormalResponse(req, res, responseJSON, limit) return nil, err } defer closeTemp(log, file) - if _, err = io.Copy(file, res.Body); err != nil { + var n int64 + if n, err = io.Copy(file, io.LimitReader(res.Body, limit+1)); err != nil { return nil, fmt.Errorf("failed to copy response to file: %w", err) + } else if n > limit { + return nil, ErrBodyReadReachedLimit } else if _, err = file.Seek(0, 0); err != nil { return nil, fmt.Errorf("failed to seek to beginning of response file: %w", err) } else if err = json.NewDecoder(file).Decode(responseJSON); err != nil { @@ -630,12 +679,12 @@ func streamResponse(req *http.Request, res *http.Response, responseJSON interfac } } -func noopHandleResponse(req *http.Request, res *http.Response, responseJSON interface{}) ([]byte, error) { +func noopHandleResponse(req *http.Request, res *http.Response, responseJSON any, limit int64) ([]byte, error) { return nil, nil } -func handleNormalResponse(req *http.Request, res *http.Response, responseJSON interface{}) ([]byte, error) { - if contents, err := readResponseBody(req, res); err != nil { +func handleNormalResponse(req *http.Request, res *http.Response, responseJSON any, limit int64) ([]byte, error) { + if contents, err := readResponseBody(req, res, limit); err != nil { return nil, err } else if responseJSON == nil { return contents, nil @@ -653,8 +702,12 @@ func handleNormalResponse(req *http.Request, res *http.Response, responseJSON in } } +const ErrorResponseSizeLimit = 512 * 1024 + +var DefaultResponseSizeLimit int64 = 512 * 1024 * 1024 + func ParseErrorResponse(req *http.Request, res *http.Response) ([]byte, error) { - contents, err := readResponseBody(req, res) + contents, err := readResponseBody(req, res, ErrorResponseSizeLimit) if err != nil { return contents, err } @@ -673,7 +726,16 @@ func ParseErrorResponse(req *http.Request, res *http.Response) ([]byte, error) { } } -func (cli *Client) executeCompiledRequest(req *http.Request, retries int, backoff time.Duration, responseJSON any, handler ClientResponseHandler, dontReadResponse bool, client *http.Client) ([]byte, *http.Response, error) { +func (cli *Client) executeCompiledRequest( + req *http.Request, + retries int, + backoff time.Duration, + responseJSON any, + handler ClientResponseHandler, + dontReadResponse bool, + sizeLimit int64, + client *http.Client, +) ([]byte, *http.Response, error) { cli.RequestStart(req) startTime := time.Now() res, err := client.Do(req) @@ -683,7 +745,9 @@ func (cli *Client) executeCompiledRequest(req *http.Request, retries int, backof } if err != nil { if retries > 0 && !errors.Is(err, context.Canceled) { - return cli.doRetry(req, err, retries, backoff, responseJSON, handler, dontReadResponse, client) + return cli.doRetry( + req, err, retries, backoff, responseJSON, handler, dontReadResponse, sizeLimit, client, + ) } err = HTTPError{ Request: req, @@ -698,7 +762,9 @@ func (cli *Client) executeCompiledRequest(req *http.Request, retries int, backof if retries > 0 && retryafter.Should(res.StatusCode, !cli.IgnoreRateLimit) { backoff = retryafter.Parse(res.Header.Get("Retry-After"), backoff) - return cli.doRetry(req, fmt.Errorf("HTTP %d", res.StatusCode), retries, backoff, responseJSON, handler, dontReadResponse, client) + return cli.doRetry( + req, fmt.Errorf("HTTP %d", res.StatusCode), retries, backoff, responseJSON, handler, dontReadResponse, sizeLimit, client, + ) } var body []byte @@ -706,7 +772,7 @@ func (cli *Client) executeCompiledRequest(req *http.Request, retries int, backof body, err = ParseErrorResponse(req, res) cli.LogRequestDone(req, res, nil, nil, len(body), duration) } else { - body, err = handler(req, res, responseJSON) + body, err = handler(req, res, responseJSON, sizeLimit) cli.LogRequestDone(req, res, nil, err, len(body), duration) } return body, res, err @@ -1628,11 +1694,20 @@ func (cli *Client) FullStateEvent(ctx context.Context, roomID id.RoomID, eventTy } // parseRoomStateArray parses a JSON array as a stream and stores the events inside it in a room state map. -func parseRoomStateArray(_ *http.Request, res *http.Response, responseJSON interface{}) ([]byte, error) { +func parseRoomStateArray(req *http.Request, res *http.Response, responseJSON any, limit int64) ([]byte, error) { + if res.ContentLength > limit { + return nil, HTTPError{ + Request: req, + Response: res, + + Message: "not reading response", + WrappedError: fmt.Errorf("%w (%.2f MiB)", ErrResponseTooLong, float64(res.ContentLength)/1024/1024), + } + } response := make(RoomStateMap) responsePtr := responseJSON.(*map[event.Type]map[string]*event.Event) *responsePtr = response - dec := json.NewDecoder(res.Body) + dec := json.NewDecoder(io.LimitReader(res.Body, limit)) arrayStart, err := dec.Token() if err != nil { diff --git a/error.go b/error.go index 59e574d7..826af179 100644 --- a/error.go +++ b/error.go @@ -82,6 +82,9 @@ var ( var ( ErrClientIsNil = errors.New("client is nil") ErrClientHasNoHomeserver = errors.New("client has no homeserver set") + + ErrResponseTooLong = errors.New("response content length too long") + ErrBodyReadReachedLimit = errors.New("reached response size limit while reading body") ) // HTTPError An HTTP Error response, which may wrap an underlying native Go Error. diff --git a/federation/client.go b/federation/client.go index c84b437a..f3163f3a 100644 --- a/federation/client.go +++ b/federation/client.go @@ -48,7 +48,7 @@ func NewClient(serverName string, key *SigningKey, cache ResolutionCache) *Clien ServerName: serverName, Key: key, - ResponseSizeLimit: 128 * 1024 * 1024, + ResponseSizeLimit: mautrix.DefaultResponseSizeLimit, } } @@ -327,11 +327,14 @@ func (c *Client) MakeFullRequest(ctx context.Context, params RequestParams) ([]b Request: req, Response: resp, - Message: "response body too long", - WrappedError: fmt.Errorf("%.2f MiB", float64(resp.ContentLength)/1024/1024), + Message: "not reading response", + WrappedError: fmt.Errorf("%w (%.2f MiB)", mautrix.ErrResponseTooLong, float64(resp.ContentLength)/1024/1024), } } body, err = io.ReadAll(io.LimitReader(resp.Body, c.ResponseSizeLimit+1)) + if err == nil && len(body) > int(c.ResponseSizeLimit) { + err = mautrix.ErrBodyReadReachedLimit + } if err != nil { return body, resp, mautrix.HTTPError{ Request: req, @@ -341,15 +344,6 @@ func (c *Client) MakeFullRequest(ctx context.Context, params RequestParams) ([]b WrappedError: err, } } - if len(body) > int(c.ResponseSizeLimit) { - return body, resp, mautrix.HTTPError{ - Request: req, - Response: resp, - - Message: "failed to read response body", - WrappedError: fmt.Errorf("exceeded read limit"), - } - } if params.ResponseJSON != nil { err = json.Unmarshal(body, params.ResponseJSON) if err != nil { diff --git a/federation/resolution.go b/federation/resolution.go index 69d4d3bf..81e19cfb 100644 --- a/federation/resolution.go +++ b/federation/resolution.go @@ -20,6 +20,8 @@ import ( "time" "github.com/rs/zerolog" + + "maunium.net/go/mautrix" ) type ResolvedServerName struct { @@ -171,9 +173,11 @@ func RequestWellKnown(ctx context.Context, cli *http.Client, hostname string) (* defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, time.Time{}, fmt.Errorf("unexpected status code %d", resp.StatusCode) + } else if resp.ContentLength > mautrix.WellKnownMaxSize { + return nil, time.Time{}, fmt.Errorf("response too large: %d bytes", resp.ContentLength) } var respData RespWellKnown - err = json.NewDecoder(io.LimitReader(resp.Body, 50*1024)).Decode(&respData) + err = json.NewDecoder(io.LimitReader(resp.Body, mautrix.WellKnownMaxSize)).Decode(&respData) if err != nil { return nil, time.Time{}, fmt.Errorf("failed to decode response: %w", err) } else if respData.Server == "" { From e61c7b3f1e847d94de222998e22d74a46c6118ce Mon Sep 17 00:00:00 2001 From: timedout Date: Sat, 18 Oct 2025 20:30:43 +0100 Subject: [PATCH 379/581] client: Add AdminWhoIs func (#411) --- client.go | 9 +++++++++ responses.go | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/client.go b/client.go index 85a4603e..d8bd5b80 100644 --- a/client.go +++ b/client.go @@ -2689,6 +2689,15 @@ func (cli *Client) ReportRoom(ctx context.Context, roomID id.RoomID, reason stri return err } +// AdminWhoIs fetches session information belonging to a specific user. Typically requires being a server admin. +// +// https://spec.matrix.org/v1.15/client-server-api/#get_matrixclientv3adminwhoisuserid +func (cli *Client) AdminWhoIs(ctx context.Context, userID id.UserID) (resp RespWhoIs, err error) { + urlPath := cli.BuildClientURL("v3", "admin", "whois", userID) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + // UnstableGetSuspendedStatus uses MSC4323 to check if a user is suspended. func (cli *Client) UnstableGetSuspendedStatus(ctx context.Context, userID id.UserID) (res *RespSuspended, err error) { urlPath := cli.BuildClientURL("unstable", "uk.timedout.msc4323", "admin", "suspend", userID) diff --git a/responses.go b/responses.go index 3484c134..943ea511 100644 --- a/responses.go +++ b/responses.go @@ -755,3 +755,23 @@ type RespSuspended struct { type RespLocked struct { Locked bool `json:"locked"` } + +type ConnectionInfo struct { + IP string `json:"ip,omitempty"` + LastSeen jsontime.UnixMilli `json:"last_seen,omitempty"` + UserAgent string `json:"user_agent,omitempty"` +} + +type SessionInfo struct { + Connections []ConnectionInfo `json:"connections,omitempty"` +} + +type DeviceInfo struct { + Sessions []SessionInfo `json:"sessions,omitempty"` +} + +// RespWhoIs is the response body for https://spec.matrix.org/v1.15/client-server-api/#get_matrixclientv3adminwhoisuserid +type RespWhoIs struct { + UserID id.UserID `json:"user_id,omitempty"` + Devices map[id.Device]DeviceInfo `json:"devices,omitempty"` +} From 2fd9e799d29ed3748c9454add53d13bcbd50b23e Mon Sep 17 00:00:00 2001 From: timedout Date: Sat, 18 Oct 2025 21:27:08 +0100 Subject: [PATCH 380/581] synapseadmin: Add force_purge option (#420) --- synapseadmin/roomapi.go | 1 + 1 file changed, 1 insertion(+) diff --git a/synapseadmin/roomapi.go b/synapseadmin/roomapi.go index a09ba174..c360acab 100644 --- a/synapseadmin/roomapi.go +++ b/synapseadmin/roomapi.go @@ -117,6 +117,7 @@ func (cli *Client) RoomMessages(ctx context.Context, roomID id.RoomID, from, to type ReqDeleteRoom struct { Purge bool `json:"purge,omitempty"` + ForcePurge bool `json:"force_purge,omitempty"` Block bool `json:"block,omitempty"` Message string `json:"message,omitempty"` RoomName string `json:"room_name,omitempty"` From a661641bcb630585f96609697a13ea53ebd77eda Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 19 Oct 2025 19:53:10 +0300 Subject: [PATCH 381/581] bridgev2/matrix: don't sleep after registering bot on versions error --- bridgev2/matrix/connector.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 3dd9ae1a..64b5d6c7 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -337,16 +337,18 @@ func (br *Connector) logInitialRequestError(err error, defaultMessage string) { } func (br *Connector) ensureConnection(ctx context.Context) { + triedToRegister := false for { versions, err := br.Bot.Versions(ctx) if err != nil { - if errors.Is(err, mautrix.MForbidden) { + if errors.Is(err, mautrix.MForbidden) && !triedToRegister { br.Log.Debug().Msg("M_FORBIDDEN in /versions, trying to register before retrying") err = br.Bot.EnsureRegistered(ctx) if err != nil { br.logInitialRequestError(err, "Failed to register after /versions failed with M_FORBIDDEN") os.Exit(16) } + triedToRegister = true } else if errors.Is(err, mautrix.MUnknownToken) || errors.Is(err, mautrix.MExclusive) { br.logInitialRequestError(err, "/versions request failed with auth error") os.Exit(16) From 7b70ec6d523e6b032886a3aee243431bba4316a5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 20 Oct 2025 11:38:21 +0300 Subject: [PATCH 382/581] bridgev2/bridgestate: send transient disconnect notices if they persist --- bridgev2/bridgestate.go | 63 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/bridgev2/bridgestate.go b/bridgev2/bridgestate.go index f31d4e92..612f228c 100644 --- a/bridgev2/bridgestate.go +++ b/bridgev2/bridgestate.go @@ -15,6 +15,7 @@ import ( "time" "github.com/rs/zerolog" + "go.mau.fi/util/exfmt" "maunium.net/go/mautrix/bridgev2/status" "maunium.net/go/mautrix/event" @@ -29,6 +30,9 @@ type BridgeStateQueue struct { bridge *Bridge login *UserLogin + firstTransientDisconnect time.Time + cancelScheduledNotice atomic.Pointer[context.CancelFunc] + stopChan chan struct{} stopReconnect atomic.Pointer[context.CancelFunc] } @@ -74,6 +78,9 @@ func (bsq *BridgeStateQueue) StopUnknownErrorReconnect() { if cancelFn := bsq.stopReconnect.Swap(nil); cancelFn != nil { (*cancelFn)() } + if cancelFn := bsq.cancelScheduledNotice.Swap(nil); cancelFn != nil { + (*cancelFn)() + } } func (bsq *BridgeStateQueue) loop() { @@ -91,14 +98,41 @@ func (bsq *BridgeStateQueue) loop() { } } -func (bsq *BridgeStateQueue) sendNotice(ctx context.Context, state status.BridgeState) { +func (bsq *BridgeStateQueue) scheduleNotice(ctx context.Context, triggeredBy status.BridgeState) { + log := bsq.login.Log.With().Str("action", "transient disconnect notice").Logger() + ctx = log.WithContext(bsq.bridge.BackgroundCtx) + if !bsq.waitForTransientDisconnectReconnect(ctx) { + return + } + prevUnsent := bsq.GetPrevUnsent() + prev := bsq.GetPrev() + if triggeredBy.Timestamp != prev.Timestamp || len(bsq.ch) > 0 || bsq.errorSent || + prevUnsent.StateEvent != status.StateTransientDisconnect || prev.StateEvent != status.StateTransientDisconnect { + log.Trace().Any("triggered_by", triggeredBy).Msg("Not sending delayed transient disconnect notice") + return + } + log.Debug().Any("triggered_by", triggeredBy).Msg("Sending delayed transient disconnect notice") + bsq.sendNotice(ctx, triggeredBy, true) +} + +func (bsq *BridgeStateQueue) sendNotice(ctx context.Context, state status.BridgeState, isDelayed bool) { noticeConfig := bsq.bridge.Config.BridgeStatusNotices isError := state.StateEvent == status.StateBadCredentials || state.StateEvent == status.StateUnknownError || - state.UserAction == status.UserActionOpenNative + state.UserAction == status.UserActionOpenNative || + (isDelayed && state.StateEvent == status.StateTransientDisconnect) sendNotice := noticeConfig == "all" || (noticeConfig == "errors" && (isError || (bsq.errorSent && state.StateEvent == status.StateConnected))) + if state.StateEvent != status.StateTransientDisconnect && state.StateEvent != status.StateUnknownError { + bsq.firstTransientDisconnect = time.Time{} + } if !sendNotice { + if !isDelayed && noticeConfig == "errors" && state.StateEvent == status.StateTransientDisconnect { + if bsq.firstTransientDisconnect.IsZero() { + bsq.firstTransientDisconnect = time.Now() + } + go bsq.scheduleNotice(ctx, state) + } return } managementRoom, err := bsq.login.User.GetManagementRoom(ctx) @@ -114,6 +148,9 @@ func (bsq *BridgeStateQueue) sendNotice(ctx context.Context, state status.Bridge if state.Error != "" { message += fmt.Sprintf(" (`%s`)", state.Error) } + if isDelayed { + message += fmt.Sprintf(" not resolved after waiting %s", exfmt.Duration(TransientDisconnectNoticeDelay)) + } if state.Message != "" { message += fmt.Sprintf(": %s", state.Message) } @@ -171,14 +208,30 @@ func (bsq *BridgeStateQueue) waitForUnknownErrorReconnect(ctx context.Context) b return false } reconnectIn += time.Duration(rand.Int64N(int64(float64(reconnectIn)*0.4)) - int64(float64(reconnectIn)*0.2)) + return bsq.waitForReconnect(ctx, reconnectIn, &bsq.stopReconnect) +} + +const TransientDisconnectNoticeDelay = 3 * time.Minute + +func (bsq *BridgeStateQueue) waitForTransientDisconnectReconnect(ctx context.Context) bool { + timeUntilSchedule := time.Until(bsq.firstTransientDisconnect.Add(TransientDisconnectNoticeDelay)) + zerolog.Ctx(ctx).Trace(). + Stringer("duration", timeUntilSchedule). + Msg("Waiting before sending notice about transient disconnect") + return bsq.waitForReconnect(ctx, timeUntilSchedule, &bsq.cancelScheduledNotice) +} + +func (bsq *BridgeStateQueue) waitForReconnect( + ctx context.Context, reconnectIn time.Duration, ptr *atomic.Pointer[context.CancelFunc], +) bool { cancelCtx, cancel := context.WithCancel(ctx) defer cancel() - if oldCancel := bsq.stopReconnect.Swap(&cancel); oldCancel != nil { + if oldCancel := ptr.Swap(&cancel); oldCancel != nil { (*oldCancel)() } select { case <-time.After(reconnectIn): - return bsq.stopReconnect.CompareAndSwap(&cancel, nil) + return ptr.CompareAndSwap(&cancel, nil) case <-cancelCtx.Done(): return false case <-bsq.stopChan: @@ -198,7 +251,7 @@ func (bsq *BridgeStateQueue) immediateSendBridgeState(state status.BridgeState) } ctx := bsq.login.Log.WithContext(context.Background()) - bsq.sendNotice(ctx, state) + bsq.sendNotice(ctx, state, false) retryIn := 2 for { From 56b182f85d04c2102ec07ec307c0f2e5d8e478d2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 20 Oct 2025 11:48:45 +0300 Subject: [PATCH 383/581] bridgev2/bridgestate: only send one delayed transient disconnect notice --- bridgev2/bridgestate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/bridgestate.go b/bridgev2/bridgestate.go index 612f228c..63d5876b 100644 --- a/bridgev2/bridgestate.go +++ b/bridgev2/bridgestate.go @@ -127,7 +127,7 @@ func (bsq *BridgeStateQueue) sendNotice(ctx context.Context, state status.Bridge bsq.firstTransientDisconnect = time.Time{} } if !sendNotice { - if !isDelayed && noticeConfig == "errors" && state.StateEvent == status.StateTransientDisconnect { + if !bsq.errorSent && !isDelayed && noticeConfig == "errors" && state.StateEvent == status.StateTransientDisconnect { if bsq.firstTransientDisconnect.IsZero() { bsq.firstTransientDisconnect = time.Now() } From 36edccf61ab81edb1ce938934e9a61ba5f2232e1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 21 Oct 2025 16:59:18 +0300 Subject: [PATCH 384/581] bridgev2/provisionutil: allow mxids as participants in CreateGroup --- bridgev2/networkinterface.go | 3 ++- bridgev2/provisionutil/creategroup.go | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 31647f63..bf2d60ee 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -817,7 +817,8 @@ type GroupFieldCapability struct { type GroupCreateParams struct { Type string `json:"type,omitempty"` - Username string `json:"username,omitempty"` + Username string `json:"username,omitempty"` + // Clients may also provide MXIDs here, but provisionutil will normalize them, so bridges only need to handle network IDs Participants []networkid.UserID `json:"participants,omitempty"` Parent *networkid.PortalKey `json:"parent,omitempty"` diff --git a/bridgev2/provisionutil/creategroup.go b/bridgev2/provisionutil/creategroup.go index f389ab42..acae9360 100644 --- a/bridgev2/provisionutil/creategroup.go +++ b/bridgev2/provisionutil/creategroup.go @@ -38,7 +38,12 @@ func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at least %d members", typeSpec.Participants.MinLength)) } userIDValidatingNetwork, uidValOK := login.Bridge.Network.(bridgev2.IdentifierValidatingNetwork) - for _, participant := range params.Participants { + for i, participant := range params.Participants { + parsedParticipant, ok := login.Bridge.Matrix.ParseGhostMXID(id.UserID(participant)) + if ok { + participant = parsedParticipant + params.Participants[i] = participant + } if uidValOK && !userIDValidatingNetwork.ValidateUserID(participant) { return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("User ID %q is not valid on this network", participant)) } From 8ee8fb1a200f7ae1320306534bdd6b8e56b2625c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 21 Oct 2025 17:20:22 +0300 Subject: [PATCH 385/581] bridgev2/provisioning: allow group creation to signal failed participants --- bridgev2/networkinterface.go | 7 +++++++ bridgev2/provisionutil/creategroup.go | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index bf2d60ee..d1d4215d 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -732,6 +732,13 @@ type CreateChatResponse struct { // If a start DM request (CreateChatWithGhost or ResolveIdentifier) returns the DM to a different user, // this field should have the user ID of said different user. DMRedirectedTo networkid.UserID + + FailedParticipants map[networkid.UserID]CreateChatFailedParticipant +} + +type CreateChatFailedParticipant struct { + Reason string `json:"reason"` + InviteContent *event.Content `json:"invite_content,omitempty"` } // IdentifierResolvingNetworkAPI is an optional interface that network connectors can implement to support starting new direct chats. diff --git a/bridgev2/provisionutil/creategroup.go b/bridgev2/provisionutil/creategroup.go index acae9360..602ea9f8 100644 --- a/bridgev2/provisionutil/creategroup.go +++ b/bridgev2/provisionutil/creategroup.go @@ -22,6 +22,8 @@ type RespCreateGroup struct { ID networkid.PortalID `json:"id"` MXID id.RoomID `json:"mxid"` Portal *bridgev2.Portal `json:"-"` + + FailedParticipants map[networkid.UserID]bridgev2.CreateChatFailedParticipant `json:"failed_participants,omitempty"` } func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev2.GroupCreateParams) (*RespCreateGroup, error) { @@ -109,5 +111,7 @@ func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev ID: resp.Portal.ID, MXID: resp.Portal.MXID, Portal: resp.Portal, + + FailedParticipants: resp.FailedParticipants, }, nil } From 1aacf6e987b187507ba0cf0155bcb11d57529490 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 21 Oct 2025 17:39:57 +0300 Subject: [PATCH 386/581] bridgev2/commands: include failed participants in group create response --- bridgev2/commands/startchat.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/bridgev2/commands/startchat.go b/bridgev2/commands/startchat.go index 7b755064..7abcddd1 100644 --- a/bridgev2/commands/startchat.go +++ b/bridgev2/commands/startchat.go @@ -20,6 +20,7 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/provisionutil" "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" ) @@ -195,7 +196,17 @@ func fnCreateGroup(ce *Event) { ce.Reply("Failed to create group: %v", err) return } - ce.Reply("Successfully created group `%s`", resp.ID) + var postfix string + if len(resp.FailedParticipants) > 0 { + failedParticipantsStrings := make([]string, len(resp.FailedParticipants)) + i := 0 + for participantID, meta := range resp.FailedParticipants { + failedParticipantsStrings[i] = fmt.Sprintf("* %s: %s", format.SafeMarkdownCode(participantID), meta.Reason) + i++ + } + postfix += "\n\nFailed to add some participants:\n" + strings.Join(failedParticipantsStrings, "\n") + } + ce.Reply("Successfully created group `%s`%s", resp.ID, postfix) } var CommandSearch = &FullHandler{ From ef31dae082e55562ab1222eb8237dd4f2809ae52 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 21 Oct 2025 18:55:49 +0300 Subject: [PATCH 387/581] bridgev2/provisioning: include user and DM room MXID in failed participants --- bridgev2/database/portal.go | 5 +++++ bridgev2/networkinterface.go | 10 +++++++--- bridgev2/portal.go | 20 ++++++++++++++++++++ bridgev2/provisionutil/creategroup.go | 24 +++++++++++++++++++++++- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/bridgev2/database/portal.go b/bridgev2/database/portal.go index 97af4c4c..a230df19 100644 --- a/bridgev2/database/portal.go +++ b/bridgev2/database/portal.go @@ -90,6 +90,7 @@ const ( getAllPortalsWithMXIDQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND mxid IS NOT NULL` getAllPortalsWithoutReceiver = getPortalBaseQuery + `WHERE bridge_id=$1 AND receiver=''` getAllDMPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND room_type='dm' AND other_user_id=$2` + getDMPortalQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND room_type='dm' AND receiver=$2 AND other_user_id=$3` getAllPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1` getChildPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND parent_id=$2 AND parent_receiver=$3` @@ -187,6 +188,10 @@ func (pq *PortalQuery) GetAllDMsWith(ctx context.Context, otherUserID networkid. return pq.QueryMany(ctx, getAllDMPortalsQuery, pq.BridgeID, otherUserID) } +func (pq *PortalQuery) GetDM(ctx context.Context, receiver networkid.UserLoginID, otherUserID networkid.UserID) (*Portal, error) { + return pq.QueryOne(ctx, getDMPortalQuery, pq.BridgeID, receiver, otherUserID) +} + func (pq *PortalQuery) GetChildren(ctx context.Context, parentKey networkid.PortalKey) ([]*Portal, error) { return pq.QueryMany(ctx, getChildPortalsQuery, pq.BridgeID, parentKey.ID, parentKey.Receiver) } diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index d1d4215d..4d2f2edf 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -733,12 +733,16 @@ type CreateChatResponse struct { // this field should have the user ID of said different user. DMRedirectedTo networkid.UserID - FailedParticipants map[networkid.UserID]CreateChatFailedParticipant + FailedParticipants map[networkid.UserID]*CreateChatFailedParticipant } type CreateChatFailedParticipant struct { - Reason string `json:"reason"` - InviteContent *event.Content `json:"invite_content,omitempty"` + Reason string `json:"reason"` + InviteEventType string `json:"invite_event_type,omitempty"` + InviteContent *event.Content `json:"invite_content,omitempty"` + + UserMXID id.UserID `json:"user_mxid,omitempty"` + DMRoomMXID id.RoomID `json:"dm_room_mxid,omitempty"` } // IdentifierResolvingNetworkAPI is an optional interface that network connectors can implement to support starting new direct chats. diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 067d92c2..44e83133 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -185,6 +185,16 @@ func (br *Bridge) loadManyPortals(ctx context.Context, portals []*database.Porta return output, nil } +func (br *Bridge) loadPortalWithCacheCheck(ctx context.Context, dbPortal *database.Portal) (*Portal, error) { + if dbPortal == nil { + return nil, nil + } else if cached, ok := br.portalsByKey[dbPortal.PortalKey]; ok { + return cached, nil + } else { + return br.loadPortal(ctx, dbPortal, nil, nil) + } +} + func (br *Bridge) UnlockedGetPortalByKey(ctx context.Context, key networkid.PortalKey, onlyIfExists bool) (*Portal, error) { if br.Config.SplitPortals && key.Receiver == "" { return nil, fmt.Errorf("receiver must always be set when split portals is enabled") @@ -274,6 +284,16 @@ func (br *Bridge) GetDMPortalsWith(ctx context.Context, otherUserID networkid.Us return br.loadManyPortals(ctx, rows) } +func (br *Bridge) GetDMPortal(ctx context.Context, receiver networkid.UserLoginID, otherUserID networkid.UserID) (*Portal, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + dbPortal, err := br.DB.Portal.GetDM(ctx, receiver, otherUserID) + if err != nil { + return nil, err + } + return br.loadPortalWithCacheCheck(ctx, dbPortal) +} + func (br *Bridge) GetPortalByKey(ctx context.Context, key networkid.PortalKey) (*Portal, error) { br.cacheLock.Lock() defer br.cacheLock.Unlock() diff --git a/bridgev2/provisionutil/creategroup.go b/bridgev2/provisionutil/creategroup.go index 602ea9f8..0df09ff5 100644 --- a/bridgev2/provisionutil/creategroup.go +++ b/bridgev2/provisionutil/creategroup.go @@ -15,6 +15,7 @@ import ( "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -23,7 +24,7 @@ type RespCreateGroup struct { MXID id.RoomID `json:"mxid"` Portal *bridgev2.Portal `json:"-"` - FailedParticipants map[networkid.UserID]bridgev2.CreateChatFailedParticipant `json:"failed_participants,omitempty"` + FailedParticipants map[networkid.UserID]*bridgev2.CreateChatFailedParticipant `json:"failed_participants,omitempty"` } func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev2.GroupCreateParams) (*RespCreateGroup, error) { @@ -107,6 +108,27 @@ func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to create portal room")) } } + for key, fp := range resp.FailedParticipants { + if fp.InviteEventType == "" { + fp.InviteEventType = event.EventMessage.Type + } + if fp.UserMXID == "" { + ghost, err := login.Bridge.GetGhostByID(ctx, key) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost for failed participant") + } else if ghost != nil { + fp.UserMXID = ghost.Intent.GetMXID() + } + } + if fp.DMRoomMXID == "" { + portal, err := login.Bridge.GetDMPortal(ctx, login.ID, key) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get DM portal for failed participant") + } else if portal != nil { + fp.DMRoomMXID = portal.MXID + } + } + } return &RespCreateGroup{ ID: resp.Portal.ID, MXID: resp.Portal.MXID, From 237499fdf5d0cc04b7d8546128ee2188213ab706 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 21 Oct 2025 22:53:18 +0300 Subject: [PATCH 388/581] client: fix admin whois response body --- responses.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/responses.go b/responses.go index 943ea511..1a221f8a 100644 --- a/responses.go +++ b/responses.go @@ -772,6 +772,6 @@ type DeviceInfo struct { // RespWhoIs is the response body for https://spec.matrix.org/v1.15/client-server-api/#get_matrixclientv3adminwhoisuserid type RespWhoIs struct { - UserID id.UserID `json:"user_id,omitempty"` - Devices map[id.Device]DeviceInfo `json:"devices,omitempty"` + UserID id.UserID `json:"user_id,omitempty"` + Devices map[id.DeviceID]DeviceInfo `json:"devices,omitempty"` } From e805815e41204f8765daf90ecafc66d17a818925 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 22 Oct 2025 13:03:32 +0300 Subject: [PATCH 389/581] bridgev2/commands: add account data debug command --- bridgev2/commands/debug.go | 42 ++++++++++++++++++++++++++++++++++ bridgev2/commands/processor.go | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/bridgev2/commands/debug.go b/bridgev2/commands/debug.go index 4c93dbd4..ad773ac8 100644 --- a/bridgev2/commands/debug.go +++ b/bridgev2/commands/debug.go @@ -7,10 +7,13 @@ package commands import ( + "encoding/json" "strings" + "time" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" ) var CommandRegisterPush = &FullHandler{ @@ -59,3 +62,42 @@ var CommandRegisterPush = &FullHandler{ RequiresLogin: true, NetworkAPI: NetworkAPIImplements[bridgev2.PushableNetworkAPI], } + +var CommandSendAccountData = &FullHandler{ + Func: func(ce *Event) { + if len(ce.Args) < 2 { + ce.Reply("Usage: `$cmdprefix debug-account-data ") + return + } + var content event.Content + evtType := event.Type{Type: ce.Args[0], Class: event.AccountDataEventType} + ce.RawArgs = strings.TrimSpace(strings.Trim(ce.RawArgs, ce.Args[0])) + err := json.Unmarshal([]byte(ce.RawArgs), &content) + if err != nil { + ce.Reply("Failed to parse JSON: %v", err) + return + } + err = content.ParseRaw(evtType) + if err != nil { + ce.Reply("Failed to deserialize content: %v", err) + return + } + res := ce.Bridge.QueueMatrixEvent(ce.Ctx, &event.Event{ + Sender: ce.User.MXID, + Type: evtType, + Timestamp: time.Now().UnixMilli(), + RoomID: ce.RoomID, + Content: content, + }) + ce.Reply("Result: %+v", res) + }, + Name: "debug-account-data", + Help: HelpMeta{ + Section: HelpSectionAdmin, + Description: "Send a room account data event to the bridge", + Args: "<_type_> <_content_>", + }, + RequiresAdmin: true, + RequiresPortal: true, + RequiresLogin: true, +} diff --git a/bridgev2/commands/processor.go b/bridgev2/commands/processor.go index c28e3a32..290d4196 100644 --- a/bridgev2/commands/processor.go +++ b/bridgev2/commands/processor.go @@ -41,7 +41,7 @@ func NewProcessor(bridge *bridgev2.Bridge) bridgev2.CommandProcessor { } proc.AddHandlers( CommandHelp, CommandCancel, - CommandRegisterPush, CommandDeletePortal, CommandDeleteAllPortals, CommandSetManagementRoom, + CommandRegisterPush, CommandSendAccountData, CommandDeletePortal, CommandDeleteAllPortals, CommandSetManagementRoom, CommandLogin, CommandRelogin, CommandListLogins, CommandLogout, CommandSetPreferredLogin, CommandSetRelay, CommandUnsetRelay, CommandResolveIdentifier, CommandStartChat, CommandSearch, From 1cd285dee0d19f48ec55143d7c37cca43dbdf075 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 22 Oct 2025 15:51:35 +0300 Subject: [PATCH 390/581] bridgev2/matrixinvite: allow redirecting created DM to no ghost --- bridgev2/matrixinvite.go | 11 +++++++---- bridgev2/networkinterface.go | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/bridgev2/matrixinvite.go b/bridgev2/matrixinvite.go index 2c14cc7f..05479a3c 100644 --- a/bridgev2/matrixinvite.go +++ b/bridgev2/matrixinvite.go @@ -221,6 +221,7 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen rejectInvite(ctx, evt, br.Bot, "") return EventHandlingResultSuccess } + overrideIntent := invitedGhost.Intent if resp.DMRedirectedTo != "" && resp.DMRedirectedTo != invitedGhost.ID { log.Debug(). Str("dm_redirected_to_id", string(resp.DMRedirectedTo)). @@ -234,11 +235,13 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen if err != nil { log.Err(err).Msg("Failed to make incorrect ghost leave new DM room") } - otherUserGhost, err := br.GetGhostByID(ctx, resp.DMRedirectedTo) - if err != nil { + if resp.DMRedirectedTo != SpecialValueDMRedirectedToBot { + overrideIntent = br.Bot + } else if otherUserGhost, err := br.GetGhostByID(ctx, resp.DMRedirectedTo); err != nil { log.Err(err).Msg("Failed to get ghost of real portal other user ID") } else { invitedGhost = otherUserGhost + overrideIntent = otherUserGhost.Intent } } err = portal.UpdateMatrixRoomID(ctx, evt.RoomID, UpdateMatrixRoomIDParams{ @@ -251,7 +254,7 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen }) if err != nil { log.Err(err).Msg("Failed to update Matrix room ID for new DM portal") - sendNotice(ctx, evt, invitedGhost.Intent, "Failed to finish configuring portal. The chat may or may not work") + sendNotice(ctx, evt, overrideIntent, "Failed to finish configuring portal. The chat may or may not work") return EventHandlingResultSuccess } message := "Private chat portal created" @@ -263,7 +266,7 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen message += fmt.Sprintf("\n\nWarning: %s", err.Error()) } } - sendNotice(ctx, evt, invitedGhost.Intent, message) + sendNotice(ctx, evt, overrideIntent, message) return EventHandlingResultSuccess } diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 4d2f2edf..9ca2dc43 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -16,6 +16,7 @@ import ( "github.com/rs/zerolog" "go.mau.fi/util/configupgrade" "go.mau.fi/util/ptr" + "go.mau.fi/util/random" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" @@ -724,6 +725,8 @@ type ResolveIdentifierResponse struct { Chat *CreateChatResponse } +var SpecialValueDMRedirectedToBot = networkid.UserID("__fi.mau.bridgev2.dm_redirected_to_bot::" + random.String(10)) + type CreateChatResponse struct { PortalKey networkid.PortalKey // Portal and PortalInfo are not required, the caller will fetch them automatically based on PortalKey if necessary. From 2a015350302b07e71abc4b4e8635663fbb190a87 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 22 Oct 2025 16:50:27 +0300 Subject: [PATCH 391/581] bridgev2/portal: add helpers for chat member map --- bridgev2/portal.go | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 44e83133..566847fb 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -3604,12 +3604,42 @@ type PortalInfo = ChatInfo type ChatMember struct { EventSender Membership event.Membership - Nickname *string + // Per-room nickname for the user. Not yet used. + Nickname *string + // The power level to set for the user when syncing power levels. PowerLevel *int - UserInfo *UserInfo - + // Optional user info to sync the ghost user while updating membership. + UserInfo *UserInfo + // The user who sent the membership change (user who invited/kicked/banned this user). + // Not yet used. Not applicable if Membership is join or knock. + MemberSender EventSender + // Extra fields to include in the member event. MemberEventExtra map[string]any - PrevMembership event.Membership + // The expected previous membership. If this doesn't match, the change is ignored. + PrevMembership event.Membership +} + +type ChatMemberMap map[networkid.UserID]ChatMember + +// Set adds the given entry to this map, overwriting any existing entry with the same Sender field. +func (cmm ChatMemberMap) Set(member ChatMember) { + if member.Sender == "" && member.SenderLogin == "" && !member.IsFromMe { + return + } + cmm[member.Sender] = member +} + +// Add adds the given entry to this map, but will ignore it if an entry with the same Sender field already exists. +// It returns true if the entry was added, false otherwise. +func (cmm ChatMemberMap) Add(member ChatMember) bool { + if member.Sender == "" && member.SenderLogin == "" && !member.IsFromMe { + return false + } + if _, exists := cmm[member.Sender]; exists { + return false + } + cmm[member.Sender] = member + return true } type ChatMemberList struct { @@ -3633,7 +3663,7 @@ type ChatMemberList struct { // Deprecated: Use MemberMap instead to avoid duplicate entries Members []ChatMember - MemberMap map[networkid.UserID]ChatMember + MemberMap ChatMemberMap PowerLevels *PowerLevelOverrides } From 7f0f51ecf3afd15fbacd912cccc6756d79a08682 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 22 Oct 2025 18:13:21 +0300 Subject: [PATCH 392/581] bridgev2/commands: add command to sync single chat --- bridgev2/commands/processor.go | 2 +- bridgev2/commands/startchat.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/bridgev2/commands/processor.go b/bridgev2/commands/processor.go index 290d4196..6062a39a 100644 --- a/bridgev2/commands/processor.go +++ b/bridgev2/commands/processor.go @@ -44,7 +44,7 @@ func NewProcessor(bridge *bridgev2.Bridge) bridgev2.CommandProcessor { CommandRegisterPush, CommandSendAccountData, CommandDeletePortal, CommandDeleteAllPortals, CommandSetManagementRoom, CommandLogin, CommandRelogin, CommandListLogins, CommandLogout, CommandSetPreferredLogin, CommandSetRelay, CommandUnsetRelay, - CommandResolveIdentifier, CommandStartChat, CommandSearch, + CommandResolveIdentifier, CommandStartChat, CommandSearch, CommandSyncChat, CommandSudo, CommandDoIn, ) return proc diff --git a/bridgev2/commands/startchat.go b/bridgev2/commands/startchat.go index 7abcddd1..b94236df 100644 --- a/bridgev2/commands/startchat.go +++ b/bridgev2/commands/startchat.go @@ -13,6 +13,7 @@ import ( "maps" "slices" "strings" + "time" "github.com/rs/zerolog" @@ -36,6 +37,35 @@ var CommandResolveIdentifier = &FullHandler{ NetworkAPI: NetworkAPIImplements[bridgev2.IdentifierResolvingNetworkAPI], } +var CommandSyncChat = &FullHandler{ + Func: func(ce *Event) { + login, _, err := ce.Portal.FindPreferredLogin(ce.Ctx, ce.User, false) + if err != nil { + ce.Log.Err(err).Msg("Failed to find login for sync") + ce.Reply("Failed to find login: %v", err) + return + } else if login == nil { + ce.Reply("No login found for sync") + return + } + info, err := login.Client.GetChatInfo(ce.Ctx, ce.Portal) + if err != nil { + ce.Log.Err(err).Msg("Failed to get chat info for sync") + ce.Reply("Failed to get chat info: %v", err) + return + } + ce.Portal.UpdateInfo(ce.Ctx, info, login, nil, time.Time{}) + ce.React("✅️") + }, + Name: "sync-portal", + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Sync the current portal room", + }, + RequiresPortal: true, + RequiresLogin: true, +} + var CommandStartChat = &FullHandler{ Func: fnResolveIdentifier, Name: "start-chat", From 9fd1e0f87cefddbd0e8b3c9db0073d2bb0a38048 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 22 Oct 2025 18:56:41 +0300 Subject: [PATCH 393/581] bridgev2/networkinterface: allow deleting children in chat delete event --- bridgev2/networkinterface.go | 5 ++++ bridgev2/portal.go | 49 ++++++++++++++++++++++++++++++++++++ bridgev2/simplevent/chat.go | 7 +++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 9ca2dc43..da505435 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -1135,6 +1135,11 @@ type RemoteChatDelete interface { RemoteDeleteOnlyForMe } +type RemoteChatDeleteWithChildren interface { + RemoteChatDelete + DeleteChildren() bool +} + type RemoteEventThatMayCreatePortal interface { RemoteEvent ShouldCreatePortal() bool diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 566847fb..0bd23b9e 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -284,6 +284,16 @@ func (br *Bridge) GetDMPortalsWith(ctx context.Context, otherUserID networkid.Us return br.loadManyPortals(ctx, rows) } +func (br *Bridge) GetChildPortals(ctx context.Context, parent networkid.PortalKey) ([]*Portal, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + rows, err := br.DB.Portal.GetChildren(ctx, parent) + if err != nil { + return nil, err + } + return br.loadManyPortals(ctx, rows) +} + func (br *Bridge) GetDMPortal(ctx context.Context, receiver networkid.UserLoginID, otherUserID networkid.UserID) (*Portal, error) { br.cacheLock.Lock() defer br.cacheLock.Unlock() @@ -3514,6 +3524,20 @@ func (portal *Portal) findOtherLogins(ctx context.Context, source *UserLogin) (o return } +type childDeleteProxy struct { + RemoteChatDeleteWithChildren + child networkid.PortalKey + done func() +} + +func (cdp *childDeleteProxy) AddLogContext(c zerolog.Context) zerolog.Context { + return cdp.RemoteChatDeleteWithChildren.AddLogContext(c).Str("subaction", "delete children") +} +func (cdp *childDeleteProxy) GetPortalKey() networkid.PortalKey { return cdp.child } +func (cdp *childDeleteProxy) ShouldCreatePortal() bool { return false } +func (cdp *childDeleteProxy) PreHandle(ctx context.Context, portal *Portal) {} +func (cdp *childDeleteProxy) PostHandle(ctx context.Context, portal *Portal) { cdp.done() } + func (portal *Portal) handleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) EventHandlingResult { log := zerolog.Ctx(ctx) if portal.Receiver == "" && evt.DeleteOnlyForMe() { @@ -3549,6 +3573,31 @@ func (portal *Portal) handleRemoteChatDelete(ctx context.Context, source *UserLo } } } + if childDeleter, ok := evt.(RemoteChatDeleteWithChildren); ok && childDeleter.DeleteChildren() && portal.RoomType == database.RoomTypeSpace { + children, err := portal.Bridge.GetChildPortals(ctx, portal.PortalKey) + if err != nil { + log.Err(err).Msg("Failed to fetch children to delete") + return EventHandlingResultFailed.WithError(err) + } + log.Debug(). + Int("portal_count", len(children)). + Msg("Deleting child portals before remote chat delete") + var wg sync.WaitGroup + wg.Add(len(children)) + for _, child := range children { + child.queueEvent(ctx, &portalRemoteEvent{ + evt: &childDeleteProxy{ + RemoteChatDeleteWithChildren: childDeleter, + child: child.PortalKey, + done: wg.Done, + }, + source: source, + evtType: RemoteEventChatDelete, + }) + } + wg.Wait() + log.Debug().Msg("Finished deleting child portals") + } err := portal.Delete(ctx) if err != nil { log.Err(err).Msg("Failed to delete portal from database") diff --git a/bridgev2/simplevent/chat.go b/bridgev2/simplevent/chat.go index c725141b..56e3a6b1 100644 --- a/bridgev2/simplevent/chat.go +++ b/bridgev2/simplevent/chat.go @@ -65,14 +65,19 @@ func (evt *ChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) type ChatDelete struct { EventMeta OnlyForMe bool + Children bool } -var _ bridgev2.RemoteChatDelete = (*ChatDelete)(nil) +var _ bridgev2.RemoteChatDeleteWithChildren = (*ChatDelete)(nil) func (evt *ChatDelete) DeleteOnlyForMe() bool { return evt.OnlyForMe } +func (evt *ChatDelete) DeleteChildren() bool { + return evt.Children +} + // ChatInfoChange is a simple implementation of [bridgev2.RemoteChatInfoChange]. type ChatInfoChange struct { EventMeta From bae61f955f55e087fe15242d19cbb516020f487e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 22 Oct 2025 20:54:53 +0300 Subject: [PATCH 394/581] bridgev2/matrixinvite: fix bugs in DM creation --- bridgev2/matrixinvite.go | 4 ++-- bridgev2/portal.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bridgev2/matrixinvite.go b/bridgev2/matrixinvite.go index 05479a3c..b8a5aec6 100644 --- a/bridgev2/matrixinvite.go +++ b/bridgev2/matrixinvite.go @@ -226,7 +226,7 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen log.Debug(). Str("dm_redirected_to_id", string(resp.DMRedirectedTo)). Msg("Created DM was redirected to another user ID") - _, err = invitedGhost.Intent.SendState(ctx, portal.MXID, event.StateMember, invitedGhost.Intent.GetMXID().String(), &event.Content{ + _, err = invitedGhost.Intent.SendState(ctx, evt.RoomID, event.StateMember, invitedGhost.Intent.GetMXID().String(), &event.Content{ Parsed: &event.MemberEventContent{ Membership: event.MembershipLeave, Reason: "Direct chat redirected to another internal user ID", @@ -235,7 +235,7 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen if err != nil { log.Err(err).Msg("Failed to make incorrect ghost leave new DM room") } - if resp.DMRedirectedTo != SpecialValueDMRedirectedToBot { + if resp.DMRedirectedTo == SpecialValueDMRedirectedToBot { overrideIntent = br.Bot } else if otherUserGhost, err := br.GetGhostByID(ctx, resp.DMRedirectedTo); err != nil { log.Err(err).Msg("Failed to get ghost of real portal other user ID") diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 0bd23b9e..8fd29bb3 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4885,7 +4885,7 @@ func (portal *Portal) addToUserSpaces(ctx context.Context) { if portal.Receiver != "" { login := portal.Bridge.GetCachedUserLoginByID(portal.Receiver) if login != nil { - up, err := portal.Bridge.DB.UserPortal.Get(ctx, login.UserLogin, portal.PortalKey) + up, err := portal.Bridge.DB.UserPortal.GetOrCreate(ctx, login.UserLogin, portal.PortalKey) if err != nil { log.Err(err).Msg("Failed to get user portal to add portal to spaces") } else { From 34a65d3087f280c28cd6c91e3fd6b462830a348d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 22 Oct 2025 21:24:14 +0300 Subject: [PATCH 395/581] bridgev2/commands: enable create group command --- bridgev2/commands/processor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/commands/processor.go b/bridgev2/commands/processor.go index 6062a39a..13a35687 100644 --- a/bridgev2/commands/processor.go +++ b/bridgev2/commands/processor.go @@ -44,7 +44,7 @@ func NewProcessor(bridge *bridgev2.Bridge) bridgev2.CommandProcessor { CommandRegisterPush, CommandSendAccountData, CommandDeletePortal, CommandDeleteAllPortals, CommandSetManagementRoom, CommandLogin, CommandRelogin, CommandListLogins, CommandLogout, CommandSetPreferredLogin, CommandSetRelay, CommandUnsetRelay, - CommandResolveIdentifier, CommandStartChat, CommandSearch, CommandSyncChat, + CommandResolveIdentifier, CommandStartChat, CommandCreateGroup, CommandSearch, CommandSyncChat, CommandSudo, CommandDoIn, ) return proc From 33d8d658fe9825db0b97053900e159587aea6559 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 22 Oct 2025 21:25:46 +0300 Subject: [PATCH 396/581] bridgev2/commands: fix panic when creating group with no arguments --- bridgev2/commands/startchat.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bridgev2/commands/startchat.go b/bridgev2/commands/startchat.go index b94236df..99924851 100644 --- a/bridgev2/commands/startchat.go +++ b/bridgev2/commands/startchat.go @@ -80,8 +80,14 @@ var CommandStartChat = &FullHandler{ } func getClientForStartingChat[T bridgev2.IdentifierResolvingNetworkAPI](ce *Event, thing string) (*bridgev2.UserLogin, T, []string) { - remainingArgs := ce.Args[1:] - login := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0])) + var remainingArgs []string + if len(ce.Args) > 1 { + remainingArgs = ce.Args[1:] + } + var login *bridgev2.UserLogin + if len(ce.Args) > 0 { + login = ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0])) + } if login == nil || login.UserMXID != ce.User.MXID { remainingArgs = ce.Args login = ce.User.GetDefaultLogin() From 756196ad4fd89989fdf5c0a30f130a0ded269390 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 23 Oct 2025 15:12:42 +0300 Subject: [PATCH 397/581] bridgev2/disappear: only start timers for read messages rather than all pending ones (#415) --- bridgev2/database/disappear.go | 27 ++++++++++--------- bridgev2/database/upgrades/00-latest.sql | 3 ++- .../upgrades/23-disappearing-timer-ts.sql | 2 ++ bridgev2/disappear.go | 4 +-- bridgev2/portal.go | 10 +++++-- bridgev2/portalbackfill.go | 1 + 6 files changed, 30 insertions(+), 17 deletions(-) create mode 100644 bridgev2/database/upgrades/23-disappearing-timer-ts.sql diff --git a/bridgev2/database/disappear.go b/bridgev2/database/disappear.go index 9874e472..c2d7d56c 100644 --- a/bridgev2/database/disappear.go +++ b/bridgev2/database/disappear.go @@ -67,26 +67,27 @@ type DisappearingMessageQuery struct { } type DisappearingMessage struct { - BridgeID networkid.BridgeID - RoomID id.RoomID - EventID id.EventID + BridgeID networkid.BridgeID + RoomID id.RoomID + EventID id.EventID + Timestamp time.Time DisappearingSetting } const ( upsertDisappearingMessageQuery = ` - INSERT INTO disappearing_message (bridge_id, mx_room, mxid, type, timer, disappear_at) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO disappearing_message (bridge_id, mx_room, mxid, timestamp, type, timer, disappear_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (bridge_id, mxid) DO UPDATE SET timer=excluded.timer, disappear_at=excluded.disappear_at ` startDisappearingMessagesQuery = ` UPDATE disappearing_message SET disappear_at=$1 + timer - WHERE bridge_id=$2 AND mx_room=$3 AND disappear_at IS NULL AND type='after_read' - RETURNING bridge_id, mx_room, mxid, type, timer, disappear_at + WHERE bridge_id=$2 AND mx_room=$3 AND disappear_at IS NULL AND type='after_read' AND timestamp<=$4 + RETURNING bridge_id, mx_room, mxid, timestamp, type, timer, disappear_at ` getUpcomingDisappearingMessagesQuery = ` - SELECT bridge_id, mx_room, mxid, type, timer, disappear_at + SELECT bridge_id, mx_room, mxid, timestamp, type, timer, disappear_at FROM disappearing_message WHERE bridge_id = $1 AND disappear_at IS NOT NULL AND disappear_at < $2 ORDER BY disappear_at LIMIT $3 ` @@ -100,8 +101,8 @@ func (dmq *DisappearingMessageQuery) Put(ctx context.Context, dm *DisappearingMe return dmq.Exec(ctx, upsertDisappearingMessageQuery, dm.sqlVariables()...) } -func (dmq *DisappearingMessageQuery) StartAll(ctx context.Context, roomID id.RoomID) ([]*DisappearingMessage, error) { - return dmq.QueryMany(ctx, startDisappearingMessagesQuery, time.Now().UnixNano(), dmq.BridgeID, roomID) +func (dmq *DisappearingMessageQuery) StartAllBefore(ctx context.Context, roomID id.RoomID, beforeTS time.Time) ([]*DisappearingMessage, error) { + return dmq.QueryMany(ctx, startDisappearingMessagesQuery, time.Now().UnixNano(), dmq.BridgeID, roomID, beforeTS.UnixNano()) } func (dmq *DisappearingMessageQuery) GetUpcoming(ctx context.Context, duration time.Duration, limit int) ([]*DisappearingMessage, error) { @@ -113,17 +114,19 @@ func (dmq *DisappearingMessageQuery) Delete(ctx context.Context, eventID id.Even } func (d *DisappearingMessage) Scan(row dbutil.Scannable) (*DisappearingMessage, error) { + var timestamp int64 var disappearAt sql.NullInt64 - err := row.Scan(&d.BridgeID, &d.RoomID, &d.EventID, &d.Type, &d.Timer, &disappearAt) + err := row.Scan(&d.BridgeID, &d.RoomID, &d.EventID, ×tamp, &d.Type, &d.Timer, &disappearAt) if err != nil { return nil, err } if disappearAt.Valid { d.DisappearAt = time.Unix(0, disappearAt.Int64) } + d.Timestamp = time.Unix(0, timestamp) return d, nil } func (d *DisappearingMessage) sqlVariables() []any { - return []any{d.BridgeID, d.RoomID, d.EventID, d.Type, d.Timer, dbutil.ConvertedPtr(d.DisappearAt, time.Time.UnixNano)} + return []any{d.BridgeID, d.RoomID, d.EventID, d.Timestamp.UnixNano(), d.Type, d.Timer, dbutil.ConvertedPtr(d.DisappearAt, time.Time.UnixNano)} } diff --git a/bridgev2/database/upgrades/00-latest.sql b/bridgev2/database/upgrades/00-latest.sql index 4eea05bb..a8bb5c64 100644 --- a/bridgev2/database/upgrades/00-latest.sql +++ b/bridgev2/database/upgrades/00-latest.sql @@ -1,4 +1,4 @@ --- v0 -> v22 (compatible with v9+): Latest revision +-- v0 -> v23 (compatible with v9+): Latest revision CREATE TABLE "user" ( bridge_id TEXT NOT NULL, mxid TEXT NOT NULL, @@ -127,6 +127,7 @@ CREATE TABLE disappearing_message ( bridge_id TEXT NOT NULL, mx_room TEXT NOT NULL, mxid TEXT NOT NULL, + timestamp BIGINT NOT NULL DEFAULT 0, type TEXT NOT NULL, timer BIGINT NOT NULL, disappear_at BIGINT, diff --git a/bridgev2/database/upgrades/23-disappearing-timer-ts.sql b/bridgev2/database/upgrades/23-disappearing-timer-ts.sql new file mode 100644 index 00000000..ecd00b8d --- /dev/null +++ b/bridgev2/database/upgrades/23-disappearing-timer-ts.sql @@ -0,0 +1,2 @@ +-- v23 (compatible with v9+): Add event timestamp for disappearing messages +ALTER TABLE disappearing_message ADD COLUMN timestamp BIGINT NOT NULL DEFAULT 0; diff --git a/bridgev2/disappear.go b/bridgev2/disappear.go index f072c01f..b5c37e8f 100644 --- a/bridgev2/disappear.go +++ b/bridgev2/disappear.go @@ -86,8 +86,8 @@ func (dl *DisappearLoop) Stop() { } } -func (dl *DisappearLoop) StartAll(ctx context.Context, roomID id.RoomID) { - startedMessages, err := dl.br.DB.DisappearingMessage.StartAll(ctx, roomID) +func (dl *DisappearLoop) StartAllBefore(ctx context.Context, roomID id.RoomID, beforeTS time.Time) { + startedMessages, err := dl.br.DB.DisappearingMessage.StartAllBefore(ctx, roomID, beforeTS) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to start disappearing messages") return diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 8fd29bb3..e87ce9d5 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -845,7 +845,7 @@ func (portal *Portal) callReadReceiptHandler( if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to save user portal metadata") } - portal.Bridge.DisappearLoop.StartAll(ctx, portal.MXID) + portal.Bridge.DisappearLoop.StartAllBefore(ctx, portal.MXID, evt.ReadUpTo) } func (portal *Portal) handleMatrixTyping(ctx context.Context, evt *event.Event) EventHandlingResult { @@ -1193,6 +1193,7 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin go portal.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{ RoomID: portal.MXID, EventID: message.MXID, + Timestamp: message.Timestamp, DisappearingSetting: portal.Disappear.StartingAt(message.Timestamp), }) } @@ -2588,6 +2589,7 @@ func (portal *Portal) sendConvertedMessage( portal.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{ RoomID: portal.MXID, EventID: dbMessage.MXID, + Timestamp: dbMessage.Timestamp, DisappearingSetting: converted.Disappear, }) } @@ -3374,11 +3376,15 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL return evt.Int64("target_stream_order", targetStreamOrder) } err = soIntent.MarkStreamOrderRead(ctx, portal.MXID, targetStreamOrder, getEventTS(evt)) + if readUpTo.IsZero() { + readUpTo = getEventTS(evt) + } } else { addTargetLog = func(evt *zerolog.Event) *zerolog.Event { return evt.Stringer("target_mxid", lastTarget.MXID) } err = intent.MarkRead(ctx, portal.MXID, lastTarget.MXID, getEventTS(evt)) + readUpTo = lastTarget.Timestamp } if err != nil { addTargetLog(log.Err(err)).Msg("Failed to bridge read receipt") @@ -3387,7 +3393,7 @@ func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserL addTargetLog(log.Debug()).Msg("Bridged read receipt") } if sender.IsFromMe { - portal.Bridge.DisappearLoop.StartAll(ctx, portal.MXID) + portal.Bridge.DisappearLoop.StartAllBefore(ctx, portal.MXID, readUpTo) } return EventHandlingResultSuccess } diff --git a/bridgev2/portalbackfill.go b/bridgev2/portalbackfill.go index f7819968..cbbce596 100644 --- a/bridgev2/portalbackfill.go +++ b/bridgev2/portalbackfill.go @@ -387,6 +387,7 @@ func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin out.Disappear = append(out.Disappear, &database.DisappearingMessage{ RoomID: portal.MXID, EventID: evtID, + Timestamp: msg.Timestamp, DisappearingSetting: msg.Disappear, }) } From 1be49d53e4f3fe27dbb7912f4ffbdbb6de986b1c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 23 Oct 2025 15:46:57 +0300 Subject: [PATCH 398/581] bridgev2/config: add option to limit maximum number of logins --- bridgev2/bridgeconfig/permissions.go | 1 + bridgev2/commands/login.go | 9 +++++++++ bridgev2/matrix/provisioning.go | 12 +++++++----- bridgev2/user.go | 4 ++++ 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/bridgev2/bridgeconfig/permissions.go b/bridgev2/bridgeconfig/permissions.go index 610051e0..898bf58a 100644 --- a/bridgev2/bridgeconfig/permissions.go +++ b/bridgev2/bridgeconfig/permissions.go @@ -24,6 +24,7 @@ type Permissions struct { DoublePuppet bool `yaml:"double_puppet"` Admin bool `yaml:"admin"` ManageRelay bool `yaml:"manage_relay"` + MaxLogins int `yaml:"max_logins"` } type PermissionConfig map[string]*Permissions diff --git a/bridgev2/commands/login.go b/bridgev2/commands/login.go index a18564c2..0f7bd821 100644 --- a/bridgev2/commands/login.go +++ b/bridgev2/commands/login.go @@ -70,6 +70,15 @@ func fnLogin(ce *Event) { } ce.Args = ce.Args[1:] } + if reauth == nil && ce.User.HasTooManyLogins() { + ce.Reply( + "You have reached the maximum number of logins (%d). "+ + "Please logout from an existing login before creating a new one. "+ + "If you want to re-authenticate an existing login, use the `$cmdprefix relogin` command.", + ce.User.Permissions.MaxLogins, + ) + return + } flows := ce.Bridge.Network.GetLoginFlows() var chosenFlowID string if len(ce.Args) > 0 { diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 61aad869..43d19380 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -367,17 +367,19 @@ func (prov *ProvisioningAPI) GetCapabilities(w http.ResponseWriter, r *http.Requ } var ErrNilStep = errors.New("bridge returned nil step with no error") +var ErrTooManyLogins = bridgev2.RespError{ErrCode: "FI.MAU.BRIDGE.TOO_MANY_LOGINS", Err: "Maximum number of logins exceeded"} func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Request) { overrideLogin, failed := prov.GetExplicitLoginForRequest(w, r) if failed { return } - login, err := prov.net.CreateLogin( - r.Context(), - prov.GetUser(r), - r.PathValue("flowID"), - ) + user := prov.GetUser(r) + if overrideLogin == nil && user.HasTooManyLogins() { + ErrTooManyLogins.AppendMessage(" (%d)", user.Permissions.MaxLogins).Write(w) + return + } + login, err := prov.net.CreateLogin(r.Context(), user, r.PathValue("flowID")) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to create login process") RespondWithError(w, err, "Internal error creating login process") diff --git a/bridgev2/user.go b/bridgev2/user.go index 87ced1d7..af9e9694 100644 --- a/bridgev2/user.go +++ b/bridgev2/user.go @@ -176,6 +176,10 @@ func (user *User) GetUserLogins() []*UserLogin { return maps.Values(user.logins) } +func (user *User) HasTooManyLogins() bool { + return user.Permissions.MaxLogins > 0 && len(user.GetUserLoginIDs()) >= user.Permissions.MaxLogins +} + func (user *User) GetFormattedUserLogins() string { user.Bridge.cacheLock.Lock() logins := make([]string, len(user.logins)) From 75ad1961d570d7321ef69a821929aa59bad76ecc Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 23 Oct 2025 17:35:08 +0300 Subject: [PATCH 399/581] bridgev2/errors: add special-cased message for too long voice messages --- bridgev2/errors.go | 1 + bridgev2/portal.go | 3 +++ 2 files changed, 4 insertions(+) diff --git a/bridgev2/errors.go b/bridgev2/errors.go index cf27ac6f..76668a99 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -62,6 +62,7 @@ var ( ErrUnsupportedMessageType error = WrapErrorInStatus(errors.New("unsupported message type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) ErrUnsupportedMediaType error = WrapErrorInStatus(errors.New("unsupported media type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) ErrMediaDurationTooLong error = WrapErrorInStatus(errors.New("media duration too long")).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + ErrVoiceMessageDurationTooLong error = WrapErrorInStatus(errors.New("voice message too long")).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) ErrMediaTooLarge error = WrapErrorInStatus(errors.New("media too large")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) ErrIgnoringMNotice error = WrapErrorInStatus(errors.New("ignoring m.notice message")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) ErrMediaDownloadFailed error = WrapErrorInStatus(errors.New("failed to download media")).WithMessage("failed to download media").WithIsCertain(true).WithSendNotice(true) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index e87ce9d5..edc12fcc 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -977,6 +977,9 @@ func (portal *Portal) checkMessageContentCaps(caps *event.RoomFeatures, content if content.Info != nil { dur := time.Duration(content.Info.Duration) * time.Millisecond if feat.MaxDuration != nil && dur > feat.MaxDuration.Duration { + if capMsgType == event.CapMsgVoice { + return fmt.Errorf("%w: %s supports voice messages up to %s long", ErrVoiceMessageDurationTooLong, portal.Bridge.Network.GetName().DisplayName, exfmt.Duration(feat.MaxDuration.Duration)) + } return fmt.Errorf("%w: %s is longer than the maximum of %s", ErrMediaDurationTooLong, exfmt.Duration(dur), exfmt.Duration(feat.MaxDuration.Duration)) } if feat.MaxSize != 0 && int64(content.Info.Size) > feat.MaxSize { From 5d87d14b885818b8bb33f2b9350f362d00b00034 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 24 Oct 2025 12:41:20 +0300 Subject: [PATCH 400/581] event/powerlevels: fix some set user level calls in v12 rooms --- event/powerlevels.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/event/powerlevels.go b/event/powerlevels.go index 50df2c1f..50fe82df 100644 --- a/event/powerlevels.go +++ b/event/powerlevels.go @@ -135,6 +135,12 @@ func (pl *PowerLevelsEventContent) GetUserLevel(userID id.UserID) int { func (pl *PowerLevelsEventContent) SetUserLevel(userID id.UserID, level int) { pl.usersLock.Lock() defer pl.usersLock.Unlock() + if pl.isCreator(userID) { + return + } + if level == math.MaxInt { + level = 1<<53 - 1 + } if level == pl.UsersDefault { delete(pl.Users, userID) } else { From ee1e05c3e8b51fca93c8cf5514d45817a708b71f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 24 Oct 2025 12:56:53 +0300 Subject: [PATCH 401/581] event: fix 32-bit compatibility --- event/powerlevels.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/event/powerlevels.go b/event/powerlevels.go index 50fe82df..708721f9 100644 --- a/event/powerlevels.go +++ b/event/powerlevels.go @@ -132,14 +132,18 @@ func (pl *PowerLevelsEventContent) GetUserLevel(userID id.UserID) int { return level } +const maxPL = 1<<53 - 1 + func (pl *PowerLevelsEventContent) SetUserLevel(userID id.UserID, level int) { pl.usersLock.Lock() defer pl.usersLock.Unlock() if pl.isCreator(userID) { return } - if level == math.MaxInt { - level = 1<<53 - 1 + if level == math.MaxInt && maxPL < math.MaxInt { + // Hack to avoid breaking on 32-bit systems (they're only slightly supported) + x := int64(maxPL) + level = int(x) } if level == pl.UsersDefault { delete(pl.Users, userID) From 02a0aad583ed11275659e1e974f42ee32f1806f5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 24 Oct 2025 15:14:31 +0300 Subject: [PATCH 402/581] bridgev2/portal: add event for waiting for room creation --- bridgev2/portal.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index edc12fcc..50cd8d32 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -87,6 +87,7 @@ type Portal struct { lastCapUpdate time.Time roomCreateLock sync.Mutex + RoomCreated *exsync.Event functionalMembersLock sync.Mutex functionalMembersCache *event.ElementFunctionalMembersContent @@ -124,6 +125,11 @@ func (br *Bridge) loadPortal(ctx context.Context, dbPortal *database.Portal, que currentlyTypingLogins: make(map[id.UserID]*UserLogin), currentlyTypingGhosts: exsync.NewSet[id.UserID](), outgoingMessages: make(map[networkid.TransactionID]*outgoingMessage), + + RoomCreated: exsync.NewEvent(), + } + if portal.MXID != "" { + portal.RoomCreated.Set() } // Putting the portal in the cache before it's fully initialized is mildly dangerous, // but loading the relay user login may depend on it. @@ -2043,6 +2049,7 @@ func (portal *Portal) UpdateMatrixRoomID( } else if alreadyExists { log.Debug().Msg("Replacement room is already a portal, overwriting") existingPortal.MXID = "" + existingPortal.RoomCreated.Clear() err := existingPortal.Save(ctx) if err != nil { return fmt.Errorf("failed to clear mxid of existing portal: %w", err) @@ -2050,6 +2057,7 @@ func (portal *Portal) UpdateMatrixRoomID( delete(portal.Bridge.portalsByMXID, portal.MXID) } portal.MXID = newRoomID + portal.RoomCreated.Set() portal.Bridge.portalsByMXID[portal.MXID] = portal portal.NameSet = false portal.AvatarSet = false @@ -4832,6 +4840,7 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo portal.TopicSet = true portal.NameSet = true portal.MXID = roomID + portal.RoomCreated.Set() portal.Bridge.cacheLock.Lock() portal.Bridge.portalsByMXID[roomID] = portal portal.Bridge.cacheLock.Unlock() @@ -4935,6 +4944,7 @@ func (portal *Portal) RemoveMXID(ctx context.Context) error { return nil } portal.MXID = "" + portal.RoomCreated.Clear() err := portal.Save(ctx) if err != nil { return err From 364ae39fefd435bcff1456830eb8f813d5b5e523 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 25 Oct 2025 15:34:48 +0300 Subject: [PATCH 403/581] responses: add Equal method for LazyLoadSummary --- responses.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/responses.go b/responses.go index 1a221f8a..e7b6b75e 100644 --- a/responses.go +++ b/responses.go @@ -6,12 +6,14 @@ import ( "fmt" "maps" "reflect" + "slices" "strconv" "strings" "github.com/tidwall/gjson" "github.com/tidwall/sjson" "go.mau.fi/util/jsontime" + "go.mau.fi/util/ptr" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -342,6 +344,17 @@ type LazyLoadSummary struct { InvitedMemberCount *int `json:"m.invited_member_count,omitempty"` } +func (lls *LazyLoadSummary) Equal(other *LazyLoadSummary) bool { + if lls == other { + return true + } else if lls == nil || other == nil { + return false + } + return ptr.Val(lls.JoinedMemberCount) == ptr.Val(other.JoinedMemberCount) && + ptr.Val(lls.InvitedMemberCount) == ptr.Val(other.InvitedMemberCount) && + slices.Equal(lls.Heroes, other.Heroes) +} + type SyncEventsList struct { Events []*event.Event `json:"events,omitempty"` } From d486dba9271c441532338ba3b5fdf12cd8a22623 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 25 Oct 2025 16:59:36 +0300 Subject: [PATCH 404/581] event: add some getters for state content --- event/state.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/event/state.go b/event/state.go index ed5434c9..db09db8e 100644 --- a/event/state.go +++ b/event/state.go @@ -96,6 +96,13 @@ type TombstoneEventContent struct { ReplacementRoom id.RoomID `json:"replacement_room"` } +func (tec *TombstoneEventContent) GetReplacementRoom() id.RoomID { + if tec == nil { + return "" + } + return tec.ReplacementRoom +} + type Predecessor struct { RoomID id.RoomID `json:"room_id"` EventID id.EventID `json:"event_id"` @@ -135,6 +142,13 @@ type CreateEventContent struct { Creator id.UserID `json:"creator,omitempty"` } +func (cec *CreateEventContent) GetPredecessor() (p Predecessor) { + if cec != nil && cec.Predecessor != nil { + p = *cec.Predecessor + } + return +} + func (cec *CreateEventContent) SupportsCreatorPower() bool { if cec == nil { return false From adc035b6a5551b3dc8dea19529dd4309a1c642e2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 27 Oct 2025 18:39:10 +0200 Subject: [PATCH 405/581] event: add state and member action maps to room features (#424) --- event/capabilities.d.ts | 17 ++++++++++++++ event/capabilities.go | 52 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/event/capabilities.d.ts b/event/capabilities.d.ts index 37848575..2d95cd50 100644 --- a/event/capabilities.d.ts +++ b/event/capabilities.d.ts @@ -16,6 +16,8 @@ export interface RoomFeatures { * If a message type isn't listed here, it should be treated as support level -2 (will be rejected). */ file?: Record + state?: Record + member_actions?: Record /** Maximum length of normal text messages. */ max_text_length?: integer @@ -72,6 +74,21 @@ declare type MIMETypeOrPattern = | `${MIMEClass}/${string}` | `${MIMEClass}/${string}; ${string}` +export enum MemberAction { + Ban = "ban", + Kick = "kick", + Leave = "leave", + RevokeInvite = "revoke_invite", + Invite = "invite", +} + +declare type EventType = string + +// This is an object for future extensibility (e.g. max name/topic length) +export interface StateFeatures { + level: CapabilitySupportLevel +} + export enum CapabilityMsgType { // Real message types used in the `msgtype` field Image = "m.image", diff --git a/event/capabilities.go b/event/capabilities.go index 42afe5b6..5ecea4a2 100644 --- a/event/capabilities.go +++ b/event/capabilities.go @@ -28,8 +28,10 @@ type RoomFeatures struct { // N.B. New fields need to be added to the Hash function to be included in the deduplication hash. - Formatting FormattingFeatureMap `json:"formatting,omitempty"` - File FileFeatureMap `json:"file,omitempty"` + Formatting FormattingFeatureMap `json:"formatting,omitempty"` + File FileFeatureMap `json:"file,omitempty"` + State StateFeatureMap `json:"state,omitempty"` + MemberActions MemberFeatureMap `json:"member_actions,omitempty"` MaxTextLength int `json:"max_text_length,omitempty"` @@ -74,6 +76,8 @@ func (rf *RoomFeatures) Clone() *RoomFeatures { clone := *rf clone.File = clone.File.Clone() clone.Formatting = maps.Clone(clone.Formatting) + clone.State = clone.State.Clone() + clone.MemberActions = clone.MemberActions.Clone() clone.EditMaxAge = ptr.Clone(clone.EditMaxAge) clone.DeleteMaxAge = ptr.Clone(clone.DeleteMaxAge) clone.DisappearingTimer = clone.DisappearingTimer.Clone() @@ -81,6 +85,48 @@ func (rf *RoomFeatures) Clone() *RoomFeatures { return &clone } +type MemberFeatureMap map[MemberAction]CapabilitySupportLevel + +func (mfm MemberFeatureMap) Clone() MemberFeatureMap { + return maps.Clone(mfm) +} + +type MemberAction string + +const ( + MemberActionBan MemberAction = "ban" + MemberActionKick MemberAction = "kick" + MemberActionLeave MemberAction = "leave" + MemberActionRevokeInvite MemberAction = "revoke_invite" + MemberActionInvite MemberAction = "invite" +) + +type StateFeatureMap map[string]*StateFeatures + +func (sfm StateFeatureMap) Clone() StateFeatureMap { + dup := maps.Clone(sfm) + for key, value := range dup { + dup[key] = value.Clone() + } + return dup +} + +type StateFeatures struct { + Level CapabilitySupportLevel `json:"level"` +} + +func (sf *StateFeatures) Clone() *StateFeatures { + if sf == nil { + return nil + } + clone := *sf + return &clone +} + +func (sf *StateFeatures) Hash() []byte { + return sf.Level.Hash() +} + type FormattingFeatureMap map[FormattingFeature]CapabilitySupportLevel type FileFeatureMap map[CapabilityMsgType]*FileFeatures @@ -266,6 +312,8 @@ func (rf *RoomFeatures) Hash() []byte { hashMap(hasher, "formatting", rf.Formatting) hashMap(hasher, "file", rf.File) + hashMap(hasher, "state", rf.State) + hashMap(hasher, "member_actions", rf.MemberActions) hashInt(hasher, "max_text_length", rf.MaxTextLength) From bea28c1381cd2a0047b5bcbdb763109c1247fe5d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 28 Oct 2025 14:48:06 +0200 Subject: [PATCH 406/581] bridgev2/portal: log mismatching disappearing timers in events --- bridgev2/database/disappear.go | 10 ++++++++++ bridgev2/portal.go | 24 ++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/bridgev2/database/disappear.go b/bridgev2/database/disappear.go index c2d7d56c..df36b205 100644 --- a/bridgev2/database/disappear.go +++ b/bridgev2/database/disappear.go @@ -37,6 +37,16 @@ type DisappearingSetting struct { DisappearAt time.Time } +func DisappearingSettingFromEvent(evt *event.BeeperDisappearingTimer) DisappearingSetting { + if evt == nil || evt.Type == event.DisappearingTypeNone { + return DisappearingSetting{} + } + return DisappearingSetting{ + Type: evt.Type, + Timer: evt.Timer.Duration, + } +} + func (ds DisappearingSetting) Normalize() DisappearingSetting { if ds.Type == event.DisappearingTypeNone { ds.Timer = 0 diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 50cd8d32..ed6756c9 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1122,6 +1122,16 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin } } } + var messageTimer *event.BeeperDisappearingTimer + if msgContent != nil { + messageTimer = msgContent.BeeperDisappearingTimer + } + if messageTimer != nil && *portal.Disappear.ToEventContent() != *messageTimer { + log.Warn(). + Any("event_timer", messageTimer). + Any("portal_timer", portal.Disappear.ToEventContent()). + Msg("Mismatching disappearing timer in event") + } wrappedMsgEvt := &MatrixMessage{ MatrixEventBase: MatrixEventBase[*event.MessageEventContent]{ @@ -1198,12 +1208,16 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin } portal.sendSuccessStatus(ctx, evt, resp.StreamOrder, message.MXID) } - if portal.Disappear.Type != event.DisappearingTypeNone { + ds := portal.Disappear + if messageTimer != nil { + ds = database.DisappearingSettingFromEvent(messageTimer) + } + if ds.Type != event.DisappearingTypeNone { go portal.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{ RoomID: portal.MXID, EventID: message.MXID, Timestamp: message.Timestamp, - DisappearingSetting: portal.Disappear.StartingAt(message.Timestamp), + DisappearingSetting: ds.StartingAt(message.Timestamp), }) } if resp.Pending { @@ -4082,6 +4096,12 @@ func (portal *Portal) sendRoomMeta( Msg("Failed to set room metadata") return false } + if eventType == event.StateBeeperDisappearingTimer { + // TODO remove this debug log at some point + zerolog.Ctx(ctx).Debug(). + Any("content", content). + Msg("Sent new disappearing timer event") + } return true } From 76cb8ee7d3c9d78945a648d00b535df129a0c0d1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 28 Oct 2025 22:46:29 +0200 Subject: [PATCH 407/581] bridgev2/provisioning: add option to skip identifier validation in create group --- bridgev2/networkinterface.go | 4 ++++ bridgev2/provisionutil/creategroup.go | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index da505435..8a39c7f8 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -826,6 +826,10 @@ type GroupFieldCapability struct { // Only for the disappear field: allowed disappearing settings DisappearSettings *event.DisappearingTimerCapability `json:"settings,omitempty"` + + // This can be used to tell provisionutil not to call ValidateUserID on each participant. + // It only meant to allow hacks where ResolveIdentifier returns a fake ID that isn't actually valid for MXIDs. + SkipIdentifierValidation bool `json:"-"` } type GroupCreateParams struct { diff --git a/bridgev2/provisionutil/creategroup.go b/bridgev2/provisionutil/creategroup.go index 0df09ff5..55a21b1a 100644 --- a/bridgev2/provisionutil/creategroup.go +++ b/bridgev2/provisionutil/creategroup.go @@ -47,8 +47,10 @@ func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev participant = parsedParticipant params.Participants[i] = participant } - if uidValOK && !userIDValidatingNetwork.ValidateUserID(participant) { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("User ID %q is not valid on this network", participant)) + if !typeSpec.Participants.SkipIdentifierValidation { + if uidValOK && !userIDValidatingNetwork.ValidateUserID(participant) { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("User ID %q is not valid on this network", participant)) + } } if api.IsThisUser(ctx, participant) { return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("You can't include yourself in the participants list", participant)) From 1edfccb4e2a941d6120bd57ba617e6566d916e0d Mon Sep 17 00:00:00 2001 From: timedout Date: Wed, 29 Oct 2025 17:55:12 +0000 Subject: [PATCH 408/581] federation/client: Use PUT instead of POST to send transactions (#426) --- federation/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/federation/client.go b/federation/client.go index f3163f3a..b20af4ab 100644 --- a/federation/client.go +++ b/federation/client.go @@ -89,7 +89,7 @@ type RespSendTransaction struct { } func (c *Client) SendTransaction(ctx context.Context, req *ReqSendTransaction) (resp *RespSendTransaction, err error) { - err = c.MakeRequest(ctx, req.Destination, true, http.MethodPost, URLPath{"v1", "send", req.TxnID}, req, &resp) + err = c.MakeRequest(ctx, req.Destination, true, http.MethodPut, URLPath{"v1", "send", req.TxnID}, req, &resp) return } From 0da017515743847bb4e5225a5f8ea7fcb4ae53b7 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 29 Oct 2025 20:52:27 +0200 Subject: [PATCH 409/581] bridgev2: add new flag for slack remote ID migration --- bridgev2/portal.go | 1 + event/state.go | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index ed6756c9..4943ab00 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -3980,6 +3980,7 @@ func (portal *Portal) getBridgeInfo() (string, event.BridgeEventContent) { } if bridgeInfo.Protocol.ID == "slackgo" { bridgeInfo.TempSlackRemoteIDMigratedFlag = true + bridgeInfo.TempSlackRemoteIDMigratedFlag2 = true } parent := portal.GetTopLevelParent() if parent != nil { diff --git a/event/state.go b/event/state.go index db09db8e..6df3b143 100644 --- a/event/state.go +++ b/event/state.go @@ -246,7 +246,8 @@ type BridgeEventContent struct { BeeperRoomType string `json:"com.beeper.room_type,omitempty"` BeeperRoomTypeV2 string `json:"com.beeper.room_type.v2,omitempty"` - TempSlackRemoteIDMigratedFlag bool `json:"com.beeper.slack_remote_id_migrated,omitempty"` + TempSlackRemoteIDMigratedFlag bool `json:"com.beeper.slack_remote_id_migrated,omitempty"` + TempSlackRemoteIDMigratedFlag2 bool `json:"com.beeper.slack_remote_id_really_migrated,omitempty"` } // DisappearingType represents the type of a disappearing message timer. From be9bbf8d098f8fa0f9f7f4d3da968c1efe94f83a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 29 Oct 2025 22:50:02 +0200 Subject: [PATCH 410/581] bridgev2/provisioning: fix max length checks in group creation --- bridgev2/provisionutil/creategroup.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bridgev2/provisionutil/creategroup.go b/bridgev2/provisionutil/creategroup.go index 55a21b1a..fbe0a513 100644 --- a/bridgev2/provisionutil/creategroup.go +++ b/bridgev2/provisionutil/creategroup.go @@ -39,6 +39,8 @@ func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev } if len(params.Participants) < typeSpec.Participants.MinLength { return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at least %d members", typeSpec.Participants.MinLength)) + } else if typeSpec.Participants.MaxLength > 0 && len(params.Participants) > typeSpec.Participants.MaxLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at most %d members", typeSpec.Participants.MaxLength)) } userIDValidatingNetwork, uidValOK := login.Bridge.Network.(bridgev2.IdentifierValidatingNetwork) for i, participant := range params.Participants { @@ -60,7 +62,7 @@ func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name is required")) } else if nameLen := len(ptr.Val(params.Name).Name); nameLen > 0 && nameLen < typeSpec.Name.MinLength { return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name must be at least %d characters", typeSpec.Name.MinLength)) - } else if nameLen > typeSpec.Name.MaxLength { + } else if typeSpec.Name.MaxLength > 0 && nameLen > typeSpec.Name.MaxLength { return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name must be at most %d characters", typeSpec.Name.MaxLength)) } if (params.Avatar == nil || params.Avatar.URL == "") && typeSpec.Avatar.Required { @@ -70,7 +72,7 @@ func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic is required")) } else if topicLen := len(ptr.Val(params.Topic).Topic); topicLen > 0 && topicLen < typeSpec.Topic.MinLength { return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic must be at least %d characters", typeSpec.Topic.MinLength)) - } else if topicLen > typeSpec.Topic.MaxLength { + } else if typeSpec.Topic.MaxLength > 0 && topicLen > typeSpec.Topic.MaxLength { return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic must be at most %d characters", typeSpec.Topic.MaxLength)) } if (params.Disappear == nil || params.Disappear.Timer.Duration == 0) && typeSpec.Disappear.Required { @@ -82,7 +84,7 @@ func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username is required")) } else if len(params.Username) > 0 && len(params.Username) < typeSpec.Username.MinLength { return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username must be at least %d characters", typeSpec.Username.MinLength)) - } else if len(params.Username) > typeSpec.Username.MaxLength { + } else if typeSpec.Username.MaxLength > 0 && len(params.Username) > typeSpec.Username.MaxLength { return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username must be at most %d characters", typeSpec.Username.MaxLength)) } if params.Parent == nil && typeSpec.Parent.Required { From 2ece053b2bfae533e0c6368c2118d7101b8ae932 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 31 Oct 2025 00:07:24 +0200 Subject: [PATCH 411/581] bridgev2: roll back failed room metadata changes (#425) --- bridgev2/bridgeconfig/config.go | 1 + bridgev2/bridgeconfig/upgrade.go | 1 + bridgev2/errors.go | 1 + bridgev2/matrix/connector.go | 1 + bridgev2/matrix/intent.go | 4 +- bridgev2/matrix/mxmain/example-config.yaml | 2 + bridgev2/matrixinterface.go | 5 +- bridgev2/portal.go | 75 ++++++++++++++++++---- bridgev2/portalinternal.go | 8 ++- versions.go | 15 +++-- 10 files changed, 86 insertions(+), 27 deletions(-) diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index 13ec738c..01819945 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -77,6 +77,7 @@ type BridgeConfig struct { DeduplicateMatrixMessages bool `yaml:"deduplicate_matrix_messages"` CrossRoomReplies bool `yaml:"cross_room_replies"` OutgoingMessageReID bool `yaml:"outgoing_message_re_id"` + RevertFailedStateChanges bool `yaml:"revert_failed_state_changes"` CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"` Relay RelayConfig `yaml:"relay"` Permissions PermissionConfig `yaml:"permissions"` diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index 6533338f..be8a8f96 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -40,6 +40,7 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.Bool, "bridge", "mute_only_on_create") helper.Copy(up.Bool, "bridge", "deduplicate_matrix_messages") helper.Copy(up.Bool, "bridge", "cross_room_replies") + helper.Copy(up.Bool, "bridge", "revert_failed_state_changes") helper.Copy(up.Bool, "bridge", "cleanup_on_logout", "enabled") helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "private") helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "relayed") diff --git a/bridgev2/errors.go b/bridgev2/errors.go index 76668a99..a06f30ed 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -54,6 +54,7 @@ var ( ErrReactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support reactions")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) ErrPollsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support polls")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) ErrRoomMetadataNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing room metadata")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrRoomMetadataNotAllowed error = WrapErrorInStatus(errors.New("changes are not allowed here")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) ErrRedactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting messages")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) ErrUnexpectedParsedContentType error = WrapErrorInStatus(errors.New("unexpected parsed content type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) ErrInvalidStateKey error = WrapErrorInStatus(errors.New("room metadata state key is unset or non-empty")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 64b5d6c7..edd98045 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -361,6 +361,7 @@ func (br *Connector) ensureConnection(ctx context.Context) { *br.AS.SpecVersions = *versions br.Capabilities.AutoJoinInvites = br.SpecVersions.Supports(mautrix.BeeperFeatureAutojoinInvites) br.Capabilities.BatchSending = br.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) + br.Capabilities.ArbitraryMemberChange = br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryMemberChange) break } } diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index ab59a582..27892fb6 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -90,8 +90,8 @@ func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType } func (as *ASIntent) fillMemberEvent(ctx context.Context, roomID id.RoomID, userID id.UserID, content *event.Content) { - targetContent := content.Parsed.(*event.MemberEventContent) - if targetContent.Displayname != "" || targetContent.AvatarURL != "" { + targetContent, ok := content.Parsed.(*event.MemberEventContent) + if !ok || targetContent.Displayname != "" || targetContent.AvatarURL != "" { return } memberContent, err := as.Matrix.StateStore.TryGetMember(ctx, roomID, userID) diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index d8634028..aeb5b7db 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -47,6 +47,8 @@ bridge: # Should cross-room reply metadata be bridged? # Most Matrix clients don't support this and servers may reject such messages too. cross_room_replies: false + # If a state event fails to bridge, should the bridge revert any state changes made by that event? + revert_failed_state_changes: false # What should be done to portal rooms when a user logs out or is logged out? # Permitted values: diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index 6fa5360c..e8489dc1 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -23,8 +23,9 @@ import ( ) type MatrixCapabilities struct { - AutoJoinInvites bool - BatchSending bool + AutoJoinInvites bool + BatchSending bool + ArbitraryMemberChange bool } type MatrixConnector interface { diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 4943ab00..67199ada 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -520,6 +520,9 @@ func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCal portal.sendSuccessStatus(ctx, evt.evt, 0, "") } } + if res.Error != nil && evt.evt.StateKey != nil { + portal.revertRoomMeta(ctx, evt.evt) + } case *portalRemoteEvent: res = portal.handleRemoteEvent(ctx, evt.source, evt.evtType, evt.evt) case *portalCreateEvent: @@ -1562,9 +1565,13 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( if evt.StateKey == nil || *evt.StateKey != "" { return EventHandlingResultFailed.WithMSSError(ErrInvalidStateKey) } + //caps := sender.Client.GetCapabilities(ctx, portal) + //if stateCap, ok := caps.State[evt.Type.Type]; !ok || stateCap.Level <= event.CapLevelUnsupported { + // return EventHandlingResultIgnored.WithMSSError(fmt.Errorf("%s %w", evt.Type.Type, ErrRoomMetadataNotAllowed)) + //} api, ok := sender.Client.(APIType) if !ok { - return EventHandlingResultIgnored.WithMSSError(ErrRoomMetadataNotSupported) + return EventHandlingResultIgnored.WithMSSError(fmt.Errorf("%w of type %s", ErrRoomMetadataNotSupported, evt.Type)) } log := zerolog.Ctx(ctx) content, ok := evt.Content.Parsed.(ContentType) @@ -1598,7 +1605,6 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( return EventHandlingResultIgnored } if !sender.Client.GetCapabilities(ctx, portal).DisappearingTimer.Supports(typedContent) { - portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), false) return EventHandlingResultFailed.WithMSSError(ErrDisappearingTimerUnsupported) } } @@ -1621,9 +1627,6 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( }) if err != nil { log.Err(err).Msg("Failed to handle Matrix room metadata") - if evt.Type == event.StateBeeperDisappearingTimer { - portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), false) - } return EventHandlingResultFailed.WithMSSError(err) } if changed { @@ -3891,7 +3894,7 @@ func (portal *Portal) updateName( } portal.Name = name portal.NameSet = portal.sendRoomMeta( - ctx, sender, ts, event.StateRoomName, "", &event.RoomNameEventContent{Name: name}, excludeFromTimeline, + ctx, sender, ts, event.StateRoomName, "", &event.RoomNameEventContent{Name: name}, excludeFromTimeline, nil, ) return true } @@ -3904,7 +3907,7 @@ func (portal *Portal) updateTopic( } portal.Topic = topic portal.TopicSet = portal.sendRoomMeta( - ctx, sender, ts, event.StateTopic, "", &event.TopicEventContent{Topic: topic}, excludeFromTimeline, + ctx, sender, ts, event.StateTopic, "", &event.TopicEventContent{Topic: topic}, excludeFromTimeline, nil, ) return true } @@ -3935,7 +3938,7 @@ func (portal *Portal) updateAvatar( portal.AvatarHash = newHash } portal.AvatarSet = portal.sendRoomMeta( - ctx, sender, ts, event.StateRoomAvatar, "", &event.RoomAvatarEventContent{URL: portal.AvatarMXC}, excludeFromTimeline, + ctx, sender, ts, event.StateRoomAvatar, "", &event.RoomAvatarEventContent{URL: portal.AvatarMXC}, excludeFromTimeline, nil, ) return true } @@ -4003,8 +4006,8 @@ func (portal *Portal) UpdateBridgeInfo(ctx context.Context) { return } stateKey, bridgeInfo := portal.getBridgeInfo() - portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBridge, stateKey, &bridgeInfo, false) - portal.sendRoomMeta(ctx, nil, time.Now(), event.StateHalfShotBridge, stateKey, &bridgeInfo, false) + portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBridge, stateKey, &bridgeInfo, false, nil) + portal.sendRoomMeta(ctx, nil, time.Now(), event.StateHalfShotBridge, stateKey, &bridgeInfo, false, nil) } func (portal *Portal) UpdateCapabilities(ctx context.Context, source *UserLogin, implicit bool) bool { @@ -4026,7 +4029,7 @@ func (portal *Portal) UpdateCapabilities(ctx context.Context, source *UserLogin, Str("old_id", portal.CapState.ID). Str("new_id", capID). Msg("Sending new room capability event") - success := portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperRoomFeatures, portal.getBridgeInfoStateKey(), caps, false) + success := portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperRoomFeatures, portal.getBridgeInfoStateKey(), caps, false, nil) if !success { return false } @@ -4037,7 +4040,7 @@ func (portal *Portal) UpdateCapabilities(ctx context.Context, source *UserLogin, } if caps.DisappearingTimer != nil && !portal.CapState.Flags.Has(database.CapStateFlagDisappearingTimerSet) { zerolog.Ctx(ctx).Debug().Msg("Disappearing timer capability was added, sending disappearing timer state event") - success = portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), true) + success = portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), true, nil) if !success { return false } @@ -4076,11 +4079,14 @@ func (portal *Portal) sendRoomMeta( stateKey string, content any, excludeFromTimeline bool, + extra map[string]any, ) bool { if portal.MXID == "" { return false } - extra := make(map[string]any) + if extra == nil { + extra = make(map[string]any) + } if excludeFromTimeline { extra["com.beeper.exclude_from_timeline"] = true } @@ -4106,6 +4112,46 @@ func (portal *Portal) sendRoomMeta( return true } +func (portal *Portal) revertRoomMeta(ctx context.Context, evt *event.Event) { + if !portal.Bridge.Config.RevertFailedStateChanges { + return + } + if evt.GetStateKey() != "" && evt.Type != event.StateMember { + return + } + switch evt.Type { + case event.StateRoomName: + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateRoomName, "", &event.RoomNameEventContent{Name: portal.Name}, true, nil) + case event.StateRoomAvatar: + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateRoomAvatar, "", &event.RoomAvatarEventContent{URL: portal.AvatarMXC}, true, nil) + case event.StateTopic: + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateTopic, "", &event.TopicEventContent{Topic: portal.Topic}, true, nil) + case event.StateBeeperDisappearingTimer: + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), true, nil) + case event.StateMember: + var prevContent *event.MemberEventContent + var extra map[string]any + if evt.Unsigned.PrevContent != nil { + _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) + prevContent = evt.Unsigned.PrevContent.AsMember() + newContent := evt.Content.AsMember() + if prevContent.Membership == newContent.Membership { + return + } + extra = evt.Unsigned.PrevContent.Raw + } else { + prevContent = &event.MemberEventContent{Membership: event.MembershipLeave} + } + if portal.Bridge.Matrix.GetCapabilities().ArbitraryMemberChange { + if extra == nil { + extra = make(map[string]any) + } + extra["com.beeper.member_rollback"] = true + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateMember, evt.GetStateKey(), prevContent, true, extra) + } + } +} + func (portal *Portal) getInitialMemberList(ctx context.Context, members *ChatMemberList, source *UserLogin, pl *event.PowerLevelsEventContent) (invite, functional []id.UserID, err error) { if members == nil { invite = []id.UserID{source.UserMXID} @@ -4490,6 +4536,7 @@ func (portal *Portal) UpdateDisappearingSetting( "", setting.ToEventContent(), opts.ExcludeFromTimeline, + nil, ) if !opts.SendNotice { @@ -4618,7 +4665,7 @@ func (portal *Portal) UpdateInfo(ctx context.Context, info *ChatInfo, source *Us } if info.JoinRule != nil { // TODO change detection instead of spamming this every time? - portal.sendRoomMeta(ctx, sender, ts, event.StateJoinRules, "", info.JoinRule, info.ExcludeChangesFromTimeline) + portal.sendRoomMeta(ctx, sender, ts, event.StateJoinRules, "", info.JoinRule, info.ExcludeChangesFromTimeline, nil) } if info.Type != nil && portal.RoomType != *info.Type { if portal.MXID != "" && (*info.Type == database.RoomTypeSpace || portal.RoomType == database.RoomTypeSpace) { diff --git a/bridgev2/portalinternal.go b/bridgev2/portalinternal.go index d9373eb6..749ee389 100644 --- a/bridgev2/portalinternal.go +++ b/bridgev2/portalinternal.go @@ -289,8 +289,12 @@ func (portal *PortalInternals) SendStateWithIntentOrBot(ctx context.Context, sen return (*Portal)(portal).sendStateWithIntentOrBot(ctx, sender, eventType, stateKey, content, ts) } -func (portal *PortalInternals) SendRoomMeta(ctx context.Context, sender MatrixAPI, ts time.Time, eventType event.Type, stateKey string, content any, excludeFromTimeline bool) bool { - return (*Portal)(portal).sendRoomMeta(ctx, sender, ts, eventType, stateKey, content, excludeFromTimeline) +func (portal *PortalInternals) SendRoomMeta(ctx context.Context, sender MatrixAPI, ts time.Time, eventType event.Type, stateKey string, content any, excludeFromTimeline bool, extra map[string]any) bool { + return (*Portal)(portal).sendRoomMeta(ctx, sender, ts, eventType, stateKey, content, excludeFromTimeline, extra) +} + +func (portal *PortalInternals) RevertRoomMeta(ctx context.Context, evt *event.Event) { + (*Portal)(portal).revertRoomMeta(ctx, evt) } func (portal *PortalInternals) GetInitialMemberList(ctx context.Context, members *ChatMemberList, source *UserLogin, pl *event.PowerLevelsEventContent) (invite, functional []id.UserID, err error) { diff --git a/versions.go b/versions.go index 0392532e..8c1c49aa 100644 --- a/versions.go +++ b/versions.go @@ -70,13 +70,14 @@ var ( FeatureUnstableProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133"} FeatureArbitraryProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133.stable", SpecVersion: SpecV116} - BeeperFeatureHungry = UnstableFeature{UnstableFlag: "com.beeper.hungry"} - BeeperFeatureBatchSending = UnstableFeature{UnstableFlag: "com.beeper.batch_sending"} - BeeperFeatureRoomYeeting = UnstableFeature{UnstableFlag: "com.beeper.room_yeeting"} - BeeperFeatureAutojoinInvites = UnstableFeature{UnstableFlag: "com.beeper.room_create_autojoin_invites"} - BeeperFeatureArbitraryProfileMeta = UnstableFeature{UnstableFlag: "com.beeper.arbitrary_profile_meta"} - BeeperFeatureAccountDataMute = UnstableFeature{UnstableFlag: "com.beeper.account_data_mute"} - BeeperFeatureInboxState = UnstableFeature{UnstableFlag: "com.beeper.inbox_state"} + BeeperFeatureHungry = UnstableFeature{UnstableFlag: "com.beeper.hungry"} + BeeperFeatureBatchSending = UnstableFeature{UnstableFlag: "com.beeper.batch_sending"} + BeeperFeatureRoomYeeting = UnstableFeature{UnstableFlag: "com.beeper.room_yeeting"} + BeeperFeatureAutojoinInvites = UnstableFeature{UnstableFlag: "com.beeper.room_create_autojoin_invites"} + BeeperFeatureArbitraryProfileMeta = UnstableFeature{UnstableFlag: "com.beeper.arbitrary_profile_meta"} + BeeperFeatureAccountDataMute = UnstableFeature{UnstableFlag: "com.beeper.account_data_mute"} + BeeperFeatureInboxState = UnstableFeature{UnstableFlag: "com.beeper.inbox_state"} + BeeperFeatureArbitraryMemberChange = UnstableFeature{UnstableFlag: "com.beeper.arbitrary_member_change"} ) func (versions *RespVersions) Supports(feature UnstableFeature) bool { From 8e23192a7d6664beb9d0da1a40843bcbb2aaaf1c Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Fri, 31 Oct 2025 10:01:49 +0000 Subject: [PATCH 412/581] client: support sending custom txn ID query param with state events --- client.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client.go b/client.go index d8bd5b80..dcc3fe5e 100644 --- a/client.go +++ b/client.go @@ -1353,6 +1353,9 @@ func (cli *Client) SendStateEvent(ctx context.Context, roomID id.RoomID, eventTy if req.MeowEventID != "" { queryParams["fi.mau.event_id"] = req.MeowEventID.String() } + if req.TransactionID != "" { + queryParams["fi.mau.transaction_id"] = req.TransactionID + } if req.UnstableDelay > 0 { queryParams["org.matrix.msc4140.delay"] = strconv.FormatInt(req.UnstableDelay.Milliseconds(), 10) } From 175f5a1c61df3c4dd7907712193f4dcb3cefff88 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 31 Oct 2025 21:11:24 +0100 Subject: [PATCH 413/581] federation/serverauth: fix request uri --- federation/serverauth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/federation/serverauth.go b/federation/serverauth.go index f46c7991..cd300341 100644 --- a/federation/serverauth.go +++ b/federation/serverauth.go @@ -231,7 +231,7 @@ func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.Res } err = (&signableRequest{ Method: r.Method, - URI: r.URL.EscapedPath(), + URI: r.URL.RequestURI(), Origin: parsed.Origin, Destination: destination, Content: reqBody, From 4ec3fbb4ab40dc77317205bac7b2898a6ed5f4e8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 1 Nov 2025 22:10:43 +0100 Subject: [PATCH 414/581] crypto/goolm: fix var bytes read overflow --- crypto/goolm/message/decoder.go | 5 +++++ crypto/goolm/message/group_message.go | 5 +++++ crypto/goolm/message/message.go | 5 +++++ crypto/goolm/message/prekey_message.go | 9 +++++++++ 4 files changed, 24 insertions(+) diff --git a/crypto/goolm/message/decoder.go b/crypto/goolm/message/decoder.go index a71cf302..b06756a9 100644 --- a/crypto/goolm/message/decoder.go +++ b/crypto/goolm/message/decoder.go @@ -3,6 +3,9 @@ package message import ( "bytes" "encoding/binary" + "fmt" + + "maunium.net/go/mautrix/crypto/olm" ) type Decoder struct { @@ -20,6 +23,8 @@ func (d *Decoder) ReadVarInt() (uint64, error) { func (d *Decoder) ReadVarBytes() ([]byte, error) { if n, err := d.ReadVarInt(); err != nil { return nil, err + } else if n > uint64(d.Len()) { + return nil, fmt.Errorf("%w: var bytes length says %d, but only %d bytes left", olm.ErrInputToSmall, n, d.Available()) } else { out := make([]byte, n) _, err = d.Read(out) diff --git a/crypto/goolm/message/group_message.go b/crypto/goolm/message/group_message.go index c2a43b1f..f3d22500 100644 --- a/crypto/goolm/message/group_message.go +++ b/crypto/goolm/message/group_message.go @@ -2,10 +2,12 @@ package message import ( "bytes" + "fmt" "io" "maunium.net/go/mautrix/crypto/goolm/aessha2" "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/olm" ) const ( @@ -36,6 +38,9 @@ func (r *GroupMessage) Decode(input []byte) (err error) { if err != nil { return } + if r.Version != protocolVersion { + return fmt.Errorf("GroupMessage.Decode: %w", olm.ErrWrongProtocolVersion) + } for { // Read Key diff --git a/crypto/goolm/message/message.go b/crypto/goolm/message/message.go index 8bb6e0cd..9ef93630 100644 --- a/crypto/goolm/message/message.go +++ b/crypto/goolm/message/message.go @@ -2,10 +2,12 @@ package message import ( "bytes" + "fmt" "io" "maunium.net/go/mautrix/crypto/goolm/aessha2" "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/olm" ) const ( @@ -40,6 +42,9 @@ func (r *Message) Decode(input []byte) (err error) { if err != nil { return } + if r.Version != protocolVersion { + return fmt.Errorf("Message.Decode: %w", olm.ErrWrongProtocolVersion) + } for { // Read Key diff --git a/crypto/goolm/message/prekey_message.go b/crypto/goolm/message/prekey_message.go index 22ebf9c3..760be4c9 100644 --- a/crypto/goolm/message/prekey_message.go +++ b/crypto/goolm/message/prekey_message.go @@ -1,6 +1,7 @@ package message import ( + "fmt" "io" "maunium.net/go/mautrix/crypto/goolm/crypto" @@ -22,6 +23,11 @@ type PreKeyMessage struct { Message []byte `json:"message"` } +// TODO deduplicate constant with one in session/olm_session.go +const ( + protocolVersion = 0x3 +) + // Decodes decodes the input and populates the corresponding fileds. func (r *PreKeyMessage) Decode(input []byte) (err error) { r.Version = 0 @@ -41,6 +47,9 @@ func (r *PreKeyMessage) Decode(input []byte) (err error) { } return } + if r.Version != protocolVersion { + return fmt.Errorf("PreKeyMessage.Decode: %w", olm.ErrWrongProtocolVersion) + } for { // Read Key From 6e7b692098a170c89b14b72b0d1a2a4b85301a9e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 1 Nov 2025 22:19:57 +0100 Subject: [PATCH 415/581] federation/eventauth: fix restricted joins typo --- federation/eventauth/eventauth.go | 2 +- federation/pdu/auth.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/federation/eventauth/eventauth.go b/federation/eventauth/eventauth.go index bd102213..32b4424b 100644 --- a/federation/eventauth/eventauth.go +++ b/federation/eventauth/eventauth.go @@ -310,7 +310,7 @@ func authorizeMember(roomVersion id.RoomVersion, evt, createEvt *pdu.PDU, authEv // 5.1. If there is no state_key property, or no membership property in content, reject. return ErrMemberNotState } - authorizedVia := id.UserID(gjson.GetBytes(evt.Content, "authorized_via_users_server").Str) + authorizedVia := id.UserID(gjson.GetBytes(evt.Content, "authorised_via_users_server").Str) if authorizedVia != "" { homeserver := authorizedVia.Homeserver() err := evt.VerifySignature(roomVersion, homeserver, getKey) diff --git a/federation/pdu/auth.go b/federation/pdu/auth.go index 1f98de06..16706fe5 100644 --- a/federation/pdu/auth.go +++ b/federation/pdu/auth.go @@ -61,7 +61,7 @@ func (pdu *PDU) AuthEventSelection(roomVersion id.RoomVersion) (keys AuthEventSe } } if membership == event.MembershipJoin && roomVersion.RestrictedJoins() { - authorizedVia := gjson.GetBytes(pdu.Content, "authorized_via_users_server").Str + authorizedVia := gjson.GetBytes(pdu.Content, "authorised_via_users_server").Str if authorizedVia != "" { keys.Add(event.StateMember.Type, authorizedVia) } From cfa47299df03606ae04fe56ecec175c71cd5349a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 6 Nov 2025 09:26:28 +0100 Subject: [PATCH 416/581] bridgev2/provisioning: add select type for login user input --- bridgev2/login.go | 4 ++++ bridgev2/matrix/provisioning.yaml | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/bridgev2/login.go b/bridgev2/login.go index 1fa3afbc..46dcf7da 100644 --- a/bridgev2/login.go +++ b/bridgev2/login.go @@ -178,6 +178,7 @@ const ( LoginInputFieldTypeToken LoginInputFieldType = "token" LoginInputFieldTypeURL LoginInputFieldType = "url" LoginInputFieldTypeDomain LoginInputFieldType = "domain" + LoginInputFieldTypeSelect LoginInputFieldType = "select" ) type LoginInputDataField struct { @@ -191,6 +192,9 @@ type LoginInputDataField struct { Description string `json:"description"` // A regex pattern that the client can use to validate input client-side. Pattern string `json:"pattern,omitempty"` + // For fields of type select, the valid options. + // Pattern may also be filled with a regex that matches the same options. + Options []string `json:"options,omitempty"` // A function that validates the input and optionally cleans it up before it's submitted to the connector. Validate func(string) (string, error) `json:"-"` } diff --git a/bridgev2/matrix/provisioning.yaml b/bridgev2/matrix/provisioning.yaml index 21c93ca4..50b73c66 100644 --- a/bridgev2/matrix/provisioning.yaml +++ b/bridgev2/matrix/provisioning.yaml @@ -714,7 +714,7 @@ components: type: type: string description: The type of field. - enum: [ username, phone_number, email, password, 2fa_code, token, url, domain ] + enum: [ username, phone_number, email, password, 2fa_code, token, url, domain, select ] id: type: string description: The internal ID of the field. This must be used as the key in the object when submitting the data back to the bridge. @@ -732,6 +732,11 @@ components: type: string format: regex description: A regular expression that the field value must match. + select: + type: array + description: For fields of type select, the valid options. + items: + type: string - description: Cookie login step required: [ type, cookies ] properties: From 36d4e1f99c22aef40765ef8dcd4414ffa8d89399 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 6 Nov 2025 16:37:27 +0100 Subject: [PATCH 417/581] federation: don't close body when not reading it Closes #431 --- client.go | 1 + federation/client.go | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/client.go b/client.go index dcc3fe5e..3c60a2d1 100644 --- a/client.go +++ b/client.go @@ -707,6 +707,7 @@ const ErrorResponseSizeLimit = 512 * 1024 var DefaultResponseSizeLimit int64 = 512 * 1024 * 1024 func ParseErrorResponse(req *http.Request, res *http.Response) ([]byte, error) { + defer res.Body.Close() contents, err := readResponseBody(req, res, ErrorResponseSizeLimit) if err != nil { return contents, err diff --git a/federation/client.go b/federation/client.go index b20af4ab..b24fd2d2 100644 --- a/federation/client.go +++ b/federation/client.go @@ -314,9 +314,9 @@ func (c *Client) MakeFullRequest(ctx context.Context, params RequestParams) ([]b WrappedError: err, } } - defer func() { - _ = resp.Body.Close() - }() + if !params.DontReadBody { + defer resp.Body.Close() + } var body []byte if resp.StatusCode >= 300 { body, err = mautrix.ParseErrorResponse(req, resp) From 3014bf966c6bf81ae845347ccf3ae52a36a6161d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 6 Nov 2025 16:37:50 +0100 Subject: [PATCH 418/581] bridgev2/commands: include options in user input prompt --- bridgev2/commands/login.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bridgev2/commands/login.go b/bridgev2/commands/login.go index 0f7bd821..80a7c733 100644 --- a/bridgev2/commands/login.go +++ b/bridgev2/commands/login.go @@ -199,11 +199,14 @@ type userInputLoginCommandState struct { func (uilcs *userInputLoginCommandState) promptNext(ce *Event) { field := uilcs.RemainingFields[0] + parts := []string{fmt.Sprintf("Please enter your %s", field.Name)} if field.Description != "" { - ce.Reply("Please enter your %s\n%s", field.Name, field.Description) - } else { - ce.Reply("Please enter your %s", field.Name) + parts = append(parts, field.Description) } + if len(field.Options) > 0 { + parts = append(parts, fmt.Sprintf("Options: `%s`", strings.Join(field.Options, "`, `"))) + } + ce.Reply(strings.Join(parts, "\n")) StoreCommandState(ce.User, &CommandState{ Next: MinimalCommandHandlerFunc(uilcs.submitNext), Action: "Login", From bade596e495e2aa28cb59350602ec6fe221dc6c6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 7 Nov 2025 14:33:00 +0100 Subject: [PATCH 419/581] bridgev2/portal: allow chaining ChatMembermap.Set calls --- bridgev2/portal.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 67199ada..8d846f43 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -3705,11 +3705,12 @@ type ChatMember struct { type ChatMemberMap map[networkid.UserID]ChatMember // Set adds the given entry to this map, overwriting any existing entry with the same Sender field. -func (cmm ChatMemberMap) Set(member ChatMember) { +func (cmm ChatMemberMap) Set(member ChatMember) ChatMemberMap { if member.Sender == "" && member.SenderLogin == "" && !member.IsFromMe { - return + return cmm } cmm[member.Sender] = member + return cmm } // Add adds the given entry to this map, but will ignore it if an entry with the same Sender field already exists. From a973e5dc94c7fcaa43e6e181d04a539b005a5e28 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 8 Nov 2025 09:49:15 +0100 Subject: [PATCH 420/581] event/reply: only remove plaintext reply fallback if there is one in HTML --- event/reply.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/event/reply.go b/event/reply.go index 9ae1c110..5f55bb80 100644 --- a/event/reply.go +++ b/event/reply.go @@ -32,12 +32,13 @@ func TrimReplyFallbackText(text string) string { } func (content *MessageEventContent) RemoveReplyFallback() { - if len(content.RelatesTo.GetReplyTo()) > 0 && !content.replyFallbackRemoved { - if content.Format == FormatHTML { - content.FormattedBody = TrimReplyFallbackHTML(content.FormattedBody) + if len(content.RelatesTo.GetReplyTo()) > 0 && !content.replyFallbackRemoved && content.Format == FormatHTML { + origHTML := content.FormattedBody + content.FormattedBody = TrimReplyFallbackHTML(content.FormattedBody) + if content.FormattedBody != origHTML { + content.Body = TrimReplyFallbackText(content.Body) + content.replyFallbackRemoved = true } - content.Body = TrimReplyFallbackText(content.Body) - content.replyFallbackRemoved = true } } From fdd7632e53874af2546d43ea5c43614377fc02cf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 9 Nov 2025 11:33:39 +0200 Subject: [PATCH 421/581] bridgev2/matrix: avoid sending message status notices for m.notice events --- bridgev2/matrix/connector.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index edd98045..362f74aa 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -514,7 +514,8 @@ func (br *Connector) internalSendMessageStatus(ctx context.Context, ms *bridgev2 Msg("Failed to send MSS event") } } - if ms.SendNotice && br.Config.Matrix.MessageErrorNotices && (ms.Status == event.MessageStatusFail || ms.Status == event.MessageStatusRetriable || ms.Step == status.MsgStepDecrypted) { + if ms.SendNotice && br.Config.Matrix.MessageErrorNotices && evt.MessageType != event.MsgNotice && + (ms.Status == event.MessageStatusFail || ms.Status == event.MessageStatusRetriable || ms.Step == status.MsgStepDecrypted) { content := ms.ToNoticeEvent(evt) if editEvent != "" { content.SetEdit(editEvent) From 14e16a3a8190e6e4e0600b7427e4dfaed90adb9c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 9 Nov 2025 11:40:10 +0200 Subject: [PATCH 422/581] bridgev2/matrix: drop events from users without permission earlier --- bridgev2/matrix/matrix.go | 10 ++++++++++ bridgev2/queue.go | 18 ++++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/bridgev2/matrix/matrix.go b/bridgev2/matrix/matrix.go index 64165941..6c94bccc 100644 --- a/bridgev2/matrix/matrix.go +++ b/bridgev2/matrix/matrix.go @@ -27,6 +27,11 @@ func (br *Connector) handleRoomEvent(ctx context.Context, evt *event.Event) { if br.shouldIgnoreEvent(evt) { return } + if !br.Config.Bridge.Permissions.Get(evt.Sender).SendEvents && evt.Type != event.StateMember { + zerolog.Ctx(ctx).Debug().Msg("Dropping event from user with no permission to send events") + br.SendMessageStatus(ctx, &bridgev2.ErrNoPermissionToInteract, bridgev2.StatusEventInfoFromEvent(evt)) + return + } if (evt.Type == event.EventMessage || evt.Type == event.EventSticker) && !evt.Mautrix.WasEncrypted && br.Config.Encryption.Require { zerolog.Ctx(ctx).Warn().Msg("Dropping unencrypted event as encryption is configured to be required") br.sendCryptoStatusError(ctx, evt, errMessageNotEncrypted, nil, 0, true) @@ -76,6 +81,11 @@ func (br *Connector) handleEncryptedEvent(ctx context.Context, evt *event.Event) Str("event_id", evt.ID.String()). Str("session_id", content.SessionID.String()). Logger() + if !br.Config.Bridge.Permissions.Get(evt.Sender).SendEvents { + log.Debug().Msg("Dropping event from user with no permission to send events") + br.SendMessageStatus(ctx, &bridgev2.ErrNoPermissionToInteract, bridgev2.StatusEventInfoFromEvent(evt)) + return + } ctx = log.WithContext(ctx) if br.Crypto == nil { br.sendCryptoStatusError(ctx, evt, errNoCrypto, nil, 0, true) diff --git a/bridgev2/queue.go b/bridgev2/queue.go index 95011cda..e1fb61c0 100644 --- a/bridgev2/queue.go +++ b/bridgev2/queue.go @@ -63,6 +63,12 @@ func (br *Bridge) rejectInviteOnNoPermission(ctx context.Context, evt *event.Eve return true } +var ( + ErrEventSenderUserNotFound = WrapErrorInStatus(errors.New("sender not found for event")).WithIsCertain(true).WithErrorAsMessage() + ErrNoPermissionToInteract = WrapErrorInStatus(errors.New("you don't have permission to send messages")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage() + ErrNoPermissionForCommands = WrapErrorInStatus(WrapErrorInStatus(errors.New("you don't have permission to use commands")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage()) +) + func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) EventHandlingResult { // TODO maybe HandleMatrixEvent would be more appropriate as this also handles bot invites and commands @@ -78,13 +84,11 @@ func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) EventH return EventHandlingResultFailed } else if sender == nil { log.Error().Msg("Couldn't get sender for incoming non-ephemeral Matrix event") - status := WrapErrorInStatus(errors.New("sender not found for event")).WithIsCertain(true).WithErrorAsMessage() - br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) + br.Matrix.SendMessageStatus(ctx, &ErrEventSenderUserNotFound, StatusEventInfoFromEvent(evt)) return EventHandlingResultFailed } else if !sender.Permissions.SendEvents { if !br.rejectInviteOnNoPermission(ctx, evt, "interact with") { - status := WrapErrorInStatus(errors.New("you don't have permission to send messages")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage() - br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) + br.Matrix.SendMessageStatus(ctx, &ErrNoPermissionToInteract, StatusEventInfoFromEvent(evt)) } return EventHandlingResultIgnored } else if !sender.Permissions.Commands && br.rejectInviteOnNoPermission(ctx, evt, "send commands to") { @@ -92,8 +96,7 @@ func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) EventH } } else if evt.Type.Class != event.EphemeralEventType { log.Error().Msg("Missing sender for incoming non-ephemeral Matrix event") - status := WrapErrorInStatus(errors.New("sender not found for event")).WithIsCertain(true).WithErrorAsMessage() - br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) + br.Matrix.SendMessageStatus(ctx, &ErrEventSenderUserNotFound, StatusEventInfoFromEvent(evt)) return EventHandlingResultIgnored } if evt.Type == event.EventMessage && sender != nil { @@ -102,8 +105,7 @@ func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) EventH msg.RemovePerMessageProfileFallback() if strings.HasPrefix(msg.Body, br.Config.CommandPrefix) || evt.RoomID == sender.ManagementRoom { if !sender.Permissions.Commands { - status := WrapErrorInStatus(errors.New("you don't have permission to use commands")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage() - br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) + br.Matrix.SendMessageStatus(ctx, &ErrNoPermissionForCommands, StatusEventInfoFromEvent(evt)) return EventHandlingResultIgnored } go br.Commands.Handle( From 60cbe66e2f2877754662ad02462704b33b8d8ffa Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 9 Nov 2025 22:43:35 +0200 Subject: [PATCH 423/581] bridgev2/publicmedia: add support for custom path prefixes --- bridgev2/bridgeconfig/config.go | 3 ++- bridgev2/bridgeconfig/upgrade.go | 1 + bridgev2/matrix/connector.go | 2 +- bridgev2/matrix/mxmain/example-config.yaml | 3 +++ bridgev2/matrix/publicmedia.go | 16 ++++++++++------ 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index 01819945..7d5ad46c 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -114,8 +114,9 @@ type DirectMediaConfig struct { type PublicMediaConfig struct { Enabled bool `yaml:"enabled"` SigningKey string `yaml:"signing_key"` - HashLength int `yaml:"hash_length"` Expiry int `yaml:"expiry"` + HashLength int `yaml:"hash_length"` + PathPrefix string `yaml:"path_prefix"` } type DoublePuppetConfig struct { diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index be8a8f96..1cec0f1e 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -133,6 +133,7 @@ func doUpgrade(helper up.Helper) { } helper.Copy(up.Int, "public_media", "expiry") helper.Copy(up.Int, "public_media", "hash_length") + helper.Copy(up.Str|up.Null, "public_media", "path_prefix") helper.Copy(up.Bool, "backfill", "enabled") helper.Copy(up.Int, "backfill", "max_initial_messages") diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 362f74aa..d81c34d2 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -275,7 +275,7 @@ func (br *Connector) GetPublicAddress() string { if br.Config.AppService.PublicAddress == "https://bridge.example.com" { return "" } - return br.Config.AppService.PublicAddress + return strings.TrimRight(br.Config.AppService.PublicAddress, "/") } func (br *Connector) GetRouter() *http.ServeMux { diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index aeb5b7db..59a307a0 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -277,6 +277,9 @@ public_media: expiry: 0 # Length of hash to use for public media URLs. Must be between 0 and 32. hash_length: 32 + # The path prefix for generated URLs. Note that this will NOT change the path where media is actually served. + # If you change this, you must configure your reverse proxy to rewrite the path accordingly. + path_prefix: /_mautrix/publicmedia # Settings for converting remote media to custom mxc:// URIs instead of reuploading. # More details can be found at https://docs.mau.fi/bridges/go/discord/direct-media.html diff --git a/bridgev2/matrix/publicmedia.go b/bridgev2/matrix/publicmedia.go index 95e37262..956a1eb7 100644 --- a/bridgev2/matrix/publicmedia.go +++ b/bridgev2/matrix/publicmedia.go @@ -14,6 +14,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" "maunium.net/go/mautrix/bridgev2" @@ -115,11 +116,14 @@ func (br *Connector) GetPublicMediaAddress(contentURI id.ContentURIString) strin if err != nil || !parsed.IsValid() { return "" } - return fmt.Sprintf( - "%s/_mautrix/publicmedia/%s/%s/%s", - br.GetPublicAddress(), - parsed.Homeserver, - parsed.FileID, - base64.RawURLEncoding.EncodeToString(br.makePublicMediaChecksum(parsed)), + return strings.Join( + []string{ + br.GetPublicAddress(), + strings.Trim(br.Config.PublicMedia.PathPrefix, "/"), + parsed.Homeserver, + parsed.FileID, + base64.RawURLEncoding.EncodeToString(br.makePublicMediaChecksum(parsed)), + }, + "/", ) } From 2eea2e74125fae8c4f8848774fc22a272f9ef884 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 9 Nov 2025 23:02:23 +0200 Subject: [PATCH 424/581] bridgev2/publicmedia: add support for file name in content disposition --- bridgev2/matrix/publicmedia.go | 42 ++++++++++++++++++++++++++-------- bridgev2/matrixinterface.go | 1 + 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/bridgev2/matrix/publicmedia.go b/bridgev2/matrix/publicmedia.go index 956a1eb7..1212f5f9 100644 --- a/bridgev2/matrix/publicmedia.go +++ b/bridgev2/matrix/publicmedia.go @@ -13,7 +13,9 @@ import ( "encoding/binary" "fmt" "io" + "mime" "net/http" + "net/url" "strings" "time" @@ -35,6 +37,7 @@ func (br *Connector) initPublicMedia() error { } br.pubMediaSigKey = []byte(br.Config.PublicMedia.SigningKey) br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{server}/{mediaID}/{checksum}", br.servePublicMedia) + br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{server}/{mediaID}/{checksum}/{filename}", br.servePublicMedia) return nil } @@ -104,11 +107,24 @@ func (br *Connector) servePublicMedia(w http.ResponseWriter, r *http.Request) { for _, hdr := range proxyHeadersToCopy { w.Header()[hdr] = resp.Header[hdr] } + if filename := r.PathValue("filename"); filename != "" { + contentDisposition, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Disposition")) + if contentDisposition == "" { + contentDisposition = "attachment" + } + w.Header().Set("Content-Disposition", mime.FormatMediaType(contentDisposition, map[string]string{ + "filename": filename, + })) + } w.WriteHeader(http.StatusOK) _, _ = io.Copy(w, resp.Body) } func (br *Connector) GetPublicMediaAddress(contentURI id.ContentURIString) string { + return br.GetPublicMediaAddressWithFileName(contentURI, "") +} + +func (br *Connector) GetPublicMediaAddressWithFileName(contentURI id.ContentURIString, fileName string) string { if br.pubMediaSigKey == nil { return "" } @@ -116,14 +132,20 @@ func (br *Connector) GetPublicMediaAddress(contentURI id.ContentURIString) strin if err != nil || !parsed.IsValid() { return "" } - return strings.Join( - []string{ - br.GetPublicAddress(), - strings.Trim(br.Config.PublicMedia.PathPrefix, "/"), - parsed.Homeserver, - parsed.FileID, - base64.RawURLEncoding.EncodeToString(br.makePublicMediaChecksum(parsed)), - }, - "/", - ) + fileName = url.PathEscape(strings.ReplaceAll(fileName, "/", "_")) + if fileName == ".." { + fileName = "" + } + parts := []string{ + br.GetPublicAddress(), + strings.Trim(br.Config.PublicMedia.PathPrefix, "/"), + parsed.Homeserver, + parsed.FileID, + base64.RawURLEncoding.EncodeToString(br.makePublicMediaChecksum(parsed)), + fileName, + } + if fileName == "" { + parts = parts[:len(parts)-1] + } + return strings.Join(parts, "/") } diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index e8489dc1..e388b6c2 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -69,6 +69,7 @@ type MatrixConnectorWithServer interface { type MatrixConnectorWithPublicMedia interface { GetPublicMediaAddress(contentURI id.ContentURIString) string + GetPublicMediaAddressWithFileName(contentURI id.ContentURIString, fileName string) string } type MatrixConnectorWithNameDisambiguation interface { From aa53cbc5285042228581d3add1dd68d1101b4d41 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 10 Nov 2025 00:11:39 +0200 Subject: [PATCH 425/581] bridgev2/publicmedia: add support for encrypted files --- bridgev2/bridgeconfig/config.go | 11 +- bridgev2/bridgeconfig/upgrade.go | 1 + bridgev2/database/database.go | 7 + bridgev2/database/publicmedia.go | 72 +++++++++ bridgev2/database/upgrades/00-latest.sql | 11 ++ .../database/upgrades/24-public-media.sql | 11 ++ bridgev2/errors.go | 4 + bridgev2/matrix/mxmain/example-config.yaml | 5 + bridgev2/matrix/publicmedia.go | 137 +++++++++++++++++- bridgev2/matrixinterface.go | 2 +- 10 files changed, 250 insertions(+), 11 deletions(-) create mode 100644 bridgev2/database/publicmedia.go create mode 100644 bridgev2/database/upgrades/24-public-media.sql diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index 7d5ad46c..1bf4dfcc 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -112,11 +112,12 @@ type DirectMediaConfig struct { } type PublicMediaConfig struct { - Enabled bool `yaml:"enabled"` - SigningKey string `yaml:"signing_key"` - Expiry int `yaml:"expiry"` - HashLength int `yaml:"hash_length"` - PathPrefix string `yaml:"path_prefix"` + Enabled bool `yaml:"enabled"` + SigningKey string `yaml:"signing_key"` + Expiry int `yaml:"expiry"` + HashLength int `yaml:"hash_length"` + PathPrefix string `yaml:"path_prefix"` + UseDatabase bool `yaml:"use_database"` } type DoublePuppetConfig struct { diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index 1cec0f1e..8a9b6f4b 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -134,6 +134,7 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.Int, "public_media", "expiry") helper.Copy(up.Int, "public_media", "hash_length") helper.Copy(up.Str|up.Null, "public_media", "path_prefix") + helper.Copy(up.Bool, "public_media", "use_database") helper.Copy(up.Bool, "backfill", "enabled") helper.Copy(up.Int, "backfill", "max_initial_messages") diff --git a/bridgev2/database/database.go b/bridgev2/database/database.go index f1789441..0729cb83 100644 --- a/bridgev2/database/database.go +++ b/bridgev2/database/database.go @@ -34,6 +34,7 @@ type Database struct { UserPortal *UserPortalQuery BackfillTask *BackfillTaskQuery KV *KVQuery + PublicMedia *PublicMediaQuery } type MetaMerger interface { @@ -141,6 +142,12 @@ func New(bridgeID networkid.BridgeID, mt MetaTypes, db *dbutil.Database) *Databa BridgeID: bridgeID, Database: db, }, + PublicMedia: &PublicMediaQuery{ + BridgeID: bridgeID, + QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*PublicMedia]) *PublicMedia { + return &PublicMedia{} + }), + }, } } diff --git a/bridgev2/database/publicmedia.go b/bridgev2/database/publicmedia.go new file mode 100644 index 00000000..b667399c --- /dev/null +++ b/bridgev2/database/publicmedia.go @@ -0,0 +1,72 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package database + +import ( + "context" + "database/sql" + "time" + + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/id" +) + +type PublicMediaQuery struct { + BridgeID networkid.BridgeID + *dbutil.QueryHelper[*PublicMedia] +} + +type PublicMedia struct { + BridgeID networkid.BridgeID + PublicID string + MXC id.ContentURI + Keys *attachment.EncryptedFile + MimeType string + Expiry time.Time +} + +const ( + upsertPublicMediaQuery = ` + INSERT INTO public_media (bridge_id, public_id, mxc, keys, mimetype, expiry) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (bridge_id, public_id) DO UPDATE SET expiry=EXCLUDED.expiry + ` + getPublicMediaQuery = ` + SELECT bridge_id, public_id, mxc, keys, mimetype, expiry + FROM public_media WHERE bridge_id=$1 AND public_id=$2 + ` +) + +func (pmq *PublicMediaQuery) Put(ctx context.Context, pm *PublicMedia) error { + ensureBridgeIDMatches(&pm.BridgeID, pmq.BridgeID) + return pmq.Exec(ctx, upsertPublicMediaQuery, pm.sqlVariables()...) +} + +func (pmq *PublicMediaQuery) Get(ctx context.Context, publicID string) (*PublicMedia, error) { + return pmq.QueryOne(ctx, getPublicMediaQuery, pmq.BridgeID, publicID) +} + +func (pm *PublicMedia) Scan(row dbutil.Scannable) (*PublicMedia, error) { + var expiry sql.NullInt64 + var mimetype sql.NullString + err := row.Scan(&pm.BridgeID, &pm.PublicID, &pm.MXC, dbutil.JSON{Data: &pm.Keys}, &mimetype, &expiry) + if err != nil { + return nil, err + } + if expiry.Valid { + pm.Expiry = time.Unix(0, expiry.Int64) + } + pm.MimeType = mimetype.String + return pm, nil +} + +func (pm *PublicMedia) sqlVariables() []any { + return []any{pm.BridgeID, pm.PublicID, &pm.MXC, dbutil.JSONPtr(pm.Keys), dbutil.StrPtr(pm.MimeType), dbutil.ConvertedPtr(pm.Expiry, time.Time.UnixNano)} +} diff --git a/bridgev2/database/upgrades/00-latest.sql b/bridgev2/database/upgrades/00-latest.sql index a8bb5c64..786ef5ff 100644 --- a/bridgev2/database/upgrades/00-latest.sql +++ b/bridgev2/database/upgrades/00-latest.sql @@ -216,3 +216,14 @@ CREATE TABLE kv_store ( PRIMARY KEY (bridge_id, key) ); + +CREATE TABLE public_media ( + bridge_id TEXT NOT NULL, + public_id TEXT NOT NULL, + mxc TEXT NOT NULL, + keys jsonb, + mimetype TEXT, + expiry BIGINT, + + PRIMARY KEY (bridge_id, public_id) +); diff --git a/bridgev2/database/upgrades/24-public-media.sql b/bridgev2/database/upgrades/24-public-media.sql new file mode 100644 index 00000000..c4290090 --- /dev/null +++ b/bridgev2/database/upgrades/24-public-media.sql @@ -0,0 +1,11 @@ +-- v24 (compatible with v9+): Custom URLs for public media +CREATE TABLE public_media ( + bridge_id TEXT NOT NULL, + public_id TEXT NOT NULL, + mxc TEXT NOT NULL, + keys jsonb, + mimetype TEXT, + expiry BIGINT, + + PRIMARY KEY (bridge_id, public_id) +); diff --git a/bridgev2/errors.go b/bridgev2/errors.go index a06f30ed..ae13086d 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -75,6 +75,10 @@ var ( ErrRemoteEchoTimeout = WrapErrorInStatus(errors.New("remote echo timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) ErrRemoteAckTimeout = WrapErrorInStatus(errors.New("remote ack timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) + ErrPublicMediaDisabled = WrapErrorInStatus(errors.New("public media is not enabled in the bridge config")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrPublicMediaDatabaseDisabled = WrapErrorInStatus(errors.New("public media database storage is disabled")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrPublicMediaGenerateFailed = WrapErrorInStatus(errors.New("failed to generate public media URL")).WithIsCertain(true).WithMessage("failed to generate public media URL").WithErrorReason(event.MessageStatusUnsupported) + ErrDisappearingTimerUnsupported error = WrapErrorInStatus(errors.New("invalid disappearing timer")).WithIsCertain(true) ) diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 59a307a0..60d41772 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -280,6 +280,11 @@ public_media: # The path prefix for generated URLs. Note that this will NOT change the path where media is actually served. # If you change this, you must configure your reverse proxy to rewrite the path accordingly. path_prefix: /_mautrix/publicmedia + # Should the bridge store media metadata in the database in order to support encrypted media and generate shorter URLs? + # If false, the generated URLs will just have the MXC URI and a HMAC signature. + # The hash_length field will be used to decide the length of the generated URL. + # This also allows invalidating URLs by deleting the database entry. + use_database: false # Settings for converting remote media to custom mxc:// URIs instead of reuploading. # More details can be found at https://docs.mau.fi/bridges/go/discord/direct-media.html diff --git a/bridgev2/matrix/publicmedia.go b/bridgev2/matrix/publicmedia.go index 1212f5f9..82ea8c2b 100644 --- a/bridgev2/matrix/publicmedia.go +++ b/bridgev2/matrix/publicmedia.go @@ -7,6 +7,7 @@ package matrix import ( + "context" "crypto/hmac" "crypto/sha256" "encoding/base64" @@ -16,10 +17,16 @@ import ( "mime" "net/http" "net/url" + "slices" "strings" "time" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -36,6 +43,8 @@ func (br *Connector) initPublicMedia() error { return fmt.Errorf("public media hash length is negative") } br.pubMediaSigKey = []byte(br.Config.PublicMedia.SigningKey) + br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{customID}", br.serveDatabasePublicMedia) + br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{customID}/{filename}", br.serveDatabasePublicMedia) br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{server}/{mediaID}/{checksum}", br.servePublicMedia) br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{server}/{mediaID}/{checksum}/{filename}", br.servePublicMedia) return nil @@ -48,6 +57,20 @@ func (br *Connector) hashContentURI(uri id.ContentURI, expiry []byte) []byte { return hasher.Sum(expiry)[:br.Config.PublicMedia.HashLength+len(expiry)] } +func (br *Connector) hashDBPublicMedia(pm *database.PublicMedia) []byte { + hasher := hmac.New(sha256.New, br.pubMediaSigKey) + hasher.Write([]byte(pm.MXC.String())) + hasher.Write([]byte(pm.MimeType)) + if pm.Keys != nil { + hasher.Write([]byte(pm.Keys.Version)) + hasher.Write([]byte(pm.Keys.Key.Algorithm)) + hasher.Write([]byte(pm.Keys.Key.Key)) + hasher.Write([]byte(pm.Keys.InitVector)) + hasher.Write([]byte(pm.Keys.Hashes.SHA256)) + } + return hasher.Sum(nil)[:br.Config.PublicMedia.HashLength] +} + func (br *Connector) makePublicMediaChecksum(uri id.ContentURI) []byte { var expiresAt []byte if br.Config.PublicMedia.Expiry > 0 { @@ -97,9 +120,47 @@ func (br *Connector) servePublicMedia(w http.ResponseWriter, r *http.Request) { http.Error(w, "checksum expired", http.StatusGone) return } + br.doProxyMedia(w, r, contentURI, nil, "") +} + +func (br *Connector) serveDatabasePublicMedia(w http.ResponseWriter, r *http.Request) { + if !br.Config.PublicMedia.UseDatabase { + http.Error(w, "public media short links are disabled", http.StatusNotFound) + return + } + log := zerolog.Ctx(r.Context()) + media, err := br.Bridge.DB.PublicMedia.Get(r.Context(), r.PathValue("customID")) + if err != nil { + log.Err(err).Msg("Failed to get public media from database") + http.Error(w, "failed to get media metadata", http.StatusInternalServerError) + return + } else if media == nil { + http.Error(w, "media ID not found", http.StatusNotFound) + return + } else if !media.Expiry.IsZero() && media.Expiry.Before(time.Now()) { + // This is not gone as it can still be refreshed in the DB + http.Error(w, "media expired", http.StatusNotFound) + return + } else if media.Keys != nil && media.Keys.PrepareForDecryption() != nil { + http.Error(w, "media keys are malformed", http.StatusInternalServerError) + return + } + br.doProxyMedia(w, r, media.MXC, media.Keys, media.MimeType) +} + +var safeMimes = []string{ + "text/css", "text/plain", "text/csv", + "application/json", "application/ld+json", + "image/jpeg", "image/gif", "image/png", "image/apng", "image/webp", "image/avif", + "video/mp4", "video/webm", "video/ogg", "video/quicktime", + "audio/mp4", "audio/webm", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wave", + "audio/wav", "audio/x-wav", "audio/x-pn-wav", "audio/flac", "audio/x-flac", +} + +func (br *Connector) doProxyMedia(w http.ResponseWriter, r *http.Request, contentURI id.ContentURI, encInfo *attachment.EncryptedFile, mimeType string) { resp, err := br.Bot.Download(r.Context(), contentURI) if err != nil { - br.Log.Warn().Stringer("uri", contentURI).Err(err).Msg("Failed to download media to proxy") + zerolog.Ctx(r.Context()).Warn().Stringer("uri", contentURI).Err(err).Msg("Failed to download media to proxy") http.Error(w, "failed to download media", http.StatusInternalServerError) return } @@ -107,7 +168,24 @@ func (br *Connector) servePublicMedia(w http.ResponseWriter, r *http.Request) { for _, hdr := range proxyHeadersToCopy { w.Header()[hdr] = resp.Header[hdr] } - if filename := r.PathValue("filename"); filename != "" { + stream := resp.Body + if encInfo != nil { + if mimeType == "" { + mimeType = "application/octet-stream" + } + contentDisposition := "attachment" + if slices.Contains(safeMimes, mimeType) { + contentDisposition = "inline" + } + dispositionArgs := map[string]string{} + if filename := r.PathValue("filename"); filename != "" { + dispositionArgs["filename"] = filename + } + w.Header().Set("Content-Type", mimeType) + w.Header().Set("Content-Disposition", mime.FormatMediaType(contentDisposition, dispositionArgs)) + // Note: this won't check the Close result like it should, but it's probably not a big deal here + stream = encInfo.DecryptStream(stream) + } else if filename := r.PathValue("filename"); filename != "" { contentDisposition, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Disposition")) if contentDisposition == "" { contentDisposition = "attachment" @@ -117,14 +195,14 @@ func (br *Connector) servePublicMedia(w http.ResponseWriter, r *http.Request) { })) } w.WriteHeader(http.StatusOK) - _, _ = io.Copy(w, resp.Body) + _, _ = io.Copy(w, stream) } func (br *Connector) GetPublicMediaAddress(contentURI id.ContentURIString) string { - return br.GetPublicMediaAddressWithFileName(contentURI, "") + return br.getPublicMediaAddressWithFileName(contentURI, "") } -func (br *Connector) GetPublicMediaAddressWithFileName(contentURI id.ContentURIString, fileName string) string { +func (br *Connector) getPublicMediaAddressWithFileName(contentURI id.ContentURIString, fileName string) string { if br.pubMediaSigKey == nil { return "" } @@ -149,3 +227,52 @@ func (br *Connector) GetPublicMediaAddressWithFileName(contentURI id.ContentURIS } return strings.Join(parts, "/") } + +func (br *Connector) GetPublicMediaAddressForEvent(ctx context.Context, evt *event.MessageEventContent) (string, error) { + if br.pubMediaSigKey == nil { + return "", bridgev2.ErrPublicMediaDisabled + } + if !br.Config.PublicMedia.UseDatabase { + if evt.File != nil { + return "", fmt.Errorf("can't generate address for encrypted file: %w", bridgev2.ErrPublicMediaDatabaseDisabled) + } + return br.getPublicMediaAddressWithFileName(evt.URL, evt.GetFileName()), nil + } + mxc := evt.URL + var keys *attachment.EncryptedFile + if evt.File != nil { + mxc = evt.File.URL + keys = &evt.File.EncryptedFile + } + parsedMXC, err := mxc.Parse() + if err != nil { + return "", fmt.Errorf("%w: failed to parse MXC: %w", bridgev2.ErrPublicMediaGenerateFailed, err) + } + pm := &database.PublicMedia{ + MXC: parsedMXC, + Keys: keys, + MimeType: evt.GetInfo().MimeType, + } + if br.Config.PublicMedia.Expiry > 0 { + pm.Expiry = time.Now().Add(time.Duration(br.Config.PublicMedia.Expiry) * time.Second) + } + pm.PublicID = base64.RawURLEncoding.EncodeToString(br.hashDBPublicMedia(pm)) + err = br.Bridge.DB.PublicMedia.Put(ctx, pm) + if err != nil { + return "", fmt.Errorf("%w: failed to store public media in database: %w", bridgev2.ErrPublicMediaGenerateFailed, err) + } + fileName := url.PathEscape(strings.ReplaceAll(evt.GetFileName(), "/", "_")) + if fileName == ".." { + fileName = "" + } + parts := []string{ + br.GetPublicAddress(), + strings.Trim(br.Config.PublicMedia.PathPrefix, "/"), + pm.PublicID, + fileName, + } + if fileName == "" { + parts = parts[:len(parts)-1] + } + return strings.Join(parts, "/"), nil +} diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index e388b6c2..07615daf 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -69,7 +69,7 @@ type MatrixConnectorWithServer interface { type MatrixConnectorWithPublicMedia interface { GetPublicMediaAddress(contentURI id.ContentURIString) string - GetPublicMediaAddressWithFileName(contentURI id.ContentURIString, fileName string) string + GetPublicMediaAddressForEvent(ctx context.Context, evt *event.MessageEventContent) (string, error) } type MatrixConnectorWithNameDisambiguation interface { From 1779c723168a9a10179d2f871a9b83b1b5be26c3 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Tue, 4 Nov 2025 16:45:23 +0100 Subject: [PATCH 426/581] bridgev2: pass back event ID and stream order in send results --- bridgev2/portal.go | 4 ++-- bridgev2/queue.go | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 8d846f43..3319f874 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1227,7 +1227,7 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin // Not exactly queued, but not finished either return EventHandlingResultQueued } - return EventHandlingResultSuccess + return EventHandlingResultSuccess.WithEventID(message.MXID).WithStreamOrder(resp.StreamOrder) } // AddPendingToIgnore adds a transaction ID that should be ignored if encountered as a new message. @@ -1551,7 +1551,7 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi log.Err(err).Msg("Failed to save reaction to database") } portal.sendSuccessStatus(ctx, evt, 0, deterministicID) - return EventHandlingResultSuccess + return EventHandlingResultSuccess.WithEventID(deterministicID) } func handleMatrixRoomMeta[APIType any, ContentType any]( diff --git a/bridgev2/queue.go b/bridgev2/queue.go index e1fb61c0..308d03c5 100644 --- a/bridgev2/queue.go +++ b/bridgev2/queue.go @@ -163,6 +163,21 @@ type EventHandlingResult struct { Error error // Whether the Error should be sent as a MSS event. SendMSS bool + + // EventID from the network + EventID id.EventID + // Stream order from the network + StreamOrder int64 +} + +func (ehr EventHandlingResult) WithEventID(id id.EventID) EventHandlingResult { + ehr.EventID = id + return ehr +} + +func (ehr EventHandlingResult) WithStreamOrder(order int64) EventHandlingResult { + ehr.StreamOrder = order + return ehr } func (ehr EventHandlingResult) WithError(err error) EventHandlingResult { From 913a28fdce79c1e250c578bc554eafd9d672a021 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Tue, 4 Nov 2025 16:45:23 +0100 Subject: [PATCH 427/581] bridgev2: pass back event ID and stream order in send results --- bridgev2/portal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 3319f874..955fd401 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1469,7 +1469,7 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi if existing.EmojiID != "" || existing.Emoji == preResp.Emoji { log.Debug().Msg("Ignoring duplicate reaction") portal.sendSuccessStatus(ctx, evt, 0, deterministicID) - return EventHandlingResultIgnored + return EventHandlingResultIgnored.WithEventID(deterministicID) } react.ReactionToOverride = existing _, err = portal.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ From 77519b6de74742075ad3e4138ab74526b7621b33 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 10 Nov 2025 00:17:39 +0200 Subject: [PATCH 428/581] bridgev2/errors: send notice for public media errors --- bridgev2/errors.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bridgev2/errors.go b/bridgev2/errors.go index ae13086d..e81b8953 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -75,9 +75,9 @@ var ( ErrRemoteEchoTimeout = WrapErrorInStatus(errors.New("remote echo timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) ErrRemoteAckTimeout = WrapErrorInStatus(errors.New("remote ack timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) - ErrPublicMediaDisabled = WrapErrorInStatus(errors.New("public media is not enabled in the bridge config")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrPublicMediaDatabaseDisabled = WrapErrorInStatus(errors.New("public media database storage is disabled")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrPublicMediaGenerateFailed = WrapErrorInStatus(errors.New("failed to generate public media URL")).WithIsCertain(true).WithMessage("failed to generate public media URL").WithErrorReason(event.MessageStatusUnsupported) + ErrPublicMediaDisabled = WrapErrorInStatus(errors.New("public media is not enabled in the bridge config")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported).WithSendNotice(true) + ErrPublicMediaDatabaseDisabled = WrapErrorInStatus(errors.New("public media database storage is disabled")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported).WithSendNotice(true) + ErrPublicMediaGenerateFailed = WrapErrorInStatus(errors.New("failed to generate public media URL")).WithIsCertain(true).WithMessage("failed to generate public media URL").WithErrorReason(event.MessageStatusUnsupported).WithSendNotice(true) ErrDisappearingTimerUnsupported error = WrapErrorInStatus(errors.New("invalid disappearing timer")).WithIsCertain(true) ) From bb0b26a58bbdcc1157b9d8cffbc474674f3ad480 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 10 Nov 2025 23:34:28 +0200 Subject: [PATCH 429/581] bridgev2/database: fix latest version --- bridgev2/database/upgrades/00-latest.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/database/upgrades/00-latest.sql b/bridgev2/database/upgrades/00-latest.sql index 786ef5ff..efde8816 100644 --- a/bridgev2/database/upgrades/00-latest.sql +++ b/bridgev2/database/upgrades/00-latest.sql @@ -1,4 +1,4 @@ --- v0 -> v23 (compatible with v9+): Latest revision +-- v0 -> v24 (compatible with v9+): Latest revision CREATE TABLE "user" ( bridge_id TEXT NOT NULL, mxid TEXT NOT NULL, From 19ed3ac40b850e916e8ad0b2688b21a5c70d7183 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 11 Nov 2025 01:32:27 +0200 Subject: [PATCH 430/581] changelog: update --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f59e6853..7ee4a13d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,43 @@ +## v0.26.0 (unreleased) + +* *(client,federation)* Added size limits for responses to make it safer to send + requests to untrusted servers. +* *(client)* Added wrapper for `/admin/whois` client API + (thanks to [@nexy7574] in [#411]). +* *(synapseadmin)* Added `force_purge` option to DeleteRoom + (thanks to [@nexy7574] in [#420]). +* *(bridgev2)* Added optional automatic rollback of room state if bridging the + change to the remote network fails. +* *(bridgev2)* Added management room notices if transient disconnect state + doesn't resolve within 3 minutes. +* *(bridgev2)* Added interface to signal that certain participants couldn't be + invited when creating a group. +* *(bridgev2)* Added `select` type for user input fields in login. +* *(bridgev2/matrix)* Added checks to avoid sending error messages in reply to + other bots. +* *(bridgev2/publicmedia)* Added support for custom path prefixes, file names, + and encrypted files. +* *(bridgev2/commands)* Added command to resync a single portal. +* *(bridgev2/commands)* Added create group command. +* *(bridgev2/config)* Added option to limit maximum number of logins. +* *(bridgev2/disappear)* Changed read receipt handling to only start + disappearing timers for messages up to the read message (note: may not work in + all cases if the read receipt points at an unknown event). +* *(event/reply)* Changed plaintext reply fallback removal to only happen when + an HTML reply fallback is removed successfully. +* *(bridgev2/matrix)* Fixed unnecessary sleep after registering bot on first run. +* *(crypto/goolm)* Fixed panic when processing certain malformed Olm messages. +* *(federation)* Fixed HTTP method for sending transactions + (thanks to [@nexy7574] in [#426]). +* *(federation)* Fixed response body being closed even when using `DontReadBody` + parameter. +* *(federation)* Fixed validating auth for requests with query params. +* *(federation/eventauth)* Fixed typo causing restricted joins to not work. + +[#411]: github.com/mautrix/go/pull/411 +[#420]: github.com/mautrix/go/pull/420 +[#426]: github.com/mautrix/go/pull/426 + ## v0.25.2 (2025-10-16) * **Breaking change *(id)*** Split `UserID.ParseAndValidate` into From 7b33248d3dd019c340334781ec608a877c5f2ccc Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 12 Nov 2025 01:53:31 +0200 Subject: [PATCH 431/581] bridgev2: add flag to indicate when bridge is stopping --- bridgev2/bridge.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index 2ad6a614..c84c2fd5 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -11,6 +11,7 @@ import ( "fmt" "os" "sync" + "sync/atomic" "time" "github.com/rs/zerolog" @@ -52,6 +53,7 @@ type Bridge struct { Background bool ExternallyManagedDB bool + stopping atomic.Bool wakeupBackfillQueue chan struct{} stopBackfillQueue *exsync.Event @@ -127,6 +129,7 @@ func (br *Bridge) Start(ctx context.Context) error { func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, params *ConnectBackgroundParams) error { br.Background = true + br.stopping.Store(false) err := br.StartConnectors(ctx) if err != nil { return err @@ -162,6 +165,7 @@ func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, pa case <-time.After(20 * time.Second): case <-ctx.Done(): } + br.stopping.Store(true) return nil } else { br.Log.Info().Str("user_login_id", string(login.ID)).Msg("Starting individual user login in background mode") @@ -171,6 +175,7 @@ func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, pa func (br *Bridge) StartConnectors(ctx context.Context) error { br.Log.Info().Msg("Starting bridge") + br.stopping.Store(false) if br.BackgroundCtx == nil || br.BackgroundCtx.Err() != nil { br.BackgroundCtx, br.cancelBackgroundCtx = context.WithCancel(context.Background()) br.BackgroundCtx = br.Log.WithContext(br.BackgroundCtx) @@ -368,6 +373,10 @@ func (br *Bridge) StartLogins(ctx context.Context) error { return nil } +func (br *Bridge) IsStopping() bool { + return br.stopping.Load() +} + func (br *Bridge) Stop() { br.stop(false, 0) } @@ -378,6 +387,7 @@ func (br *Bridge) StopWithTimeout(timeout time.Duration) { func (br *Bridge) stop(isRunOnce bool, timeout time.Duration) { br.Log.Info().Msg("Shutting down bridge") + br.stopping.Store(true) br.DisappearLoop.Stop() br.stopBackfillQueue.Set() br.Matrix.PreStop() From 4913b123f19b0534a936748e6ce921419bfb9994 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 12 Nov 2025 14:57:18 +0200 Subject: [PATCH 432/581] bridgev2/space: let network connector customize personal filtering space --- bridgev2/networkinterface.go | 6 ++++++ bridgev2/space.go | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 8a39c7f8..9bbcf897 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -18,6 +18,7 @@ import ( "go.mau.fi/util/ptr" "go.mau.fi/util/random" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" @@ -294,6 +295,11 @@ type PortalBridgeInfoFillingNetwork interface { FillPortalBridgeInfo(portal *Portal, content *event.BridgeEventContent) } +type PersonalFilteringCustomizingNetworkAPI interface { + NetworkAPI + CustomizePersonalFilteringSpace(req *mautrix.ReqCreateRoom) +} + // ConfigValidatingNetwork is an optional interface that network connectors can implement to validate config fields // before the bridge is started. // diff --git a/bridgev2/space.go b/bridgev2/space.go index ae9013cb..f6d07922 100644 --- a/bridgev2/space.go +++ b/bridgev2/space.go @@ -172,6 +172,10 @@ func (ul *UserLogin) GetSpaceRoom(ctx context.Context) (id.RoomID, error) { // TODO remove this after initial_members is supported in hungryserv req.BeeperAutoJoinInvites = true } + pfc, ok := ul.Client.(PersonalFilteringCustomizingNetworkAPI) + if ok { + pfc.CustomizePersonalFilteringSpace(req) + } ul.SpaceRoom, err = ul.Bridge.Bot.CreateRoom(ctx, req) if err != nil { return "", fmt.Errorf("failed to create space room: %w", err) From 8b70baa3360b203c59e7d7c2b81151001f30365a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 12 Nov 2025 15:34:25 +0200 Subject: [PATCH 433/581] bridgev2/commands: add support for ResolveIdentifierTryNext in pm command --- bridgev2/commands/startchat.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bridgev2/commands/startchat.go b/bridgev2/commands/startchat.go index 99924851..24586387 100644 --- a/bridgev2/commands/startchat.go +++ b/bridgev2/commands/startchat.go @@ -8,6 +8,7 @@ package commands import ( "context" + "errors" "fmt" "html" "maps" @@ -118,9 +119,13 @@ func fnResolveIdentifier(ce *Event) { if api == nil { return } + allLogins := ce.User.GetUserLogins() createChat := ce.Command == "start-chat" || ce.Command == "pm" identifier := strings.Join(identifierParts, " ") resp, err := provisionutil.ResolveIdentifier(ce.Ctx, login, identifier, createChat) + for i := 0; i < len(allLogins) && errors.Is(err, bridgev2.ErrResolveIdentifierTryNext); i++ { + resp, err = provisionutil.ResolveIdentifier(ce.Ctx, allLogins[i], identifier, createChat) + } if err != nil { ce.Reply("Failed to resolve identifier: %v", err) return From 981addddc91c38970f85ca886e8bc2bdeb550a36 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 12 Nov 2025 19:38:08 +0200 Subject: [PATCH 434/581] bridgev2/config: add option to disable kicking matrix users --- bridgev2/bridgeconfig/config.go | 1 + bridgev2/bridgeconfig/upgrade.go | 1 + bridgev2/matrix/mxmain/example-config.yaml | 3 +++ bridgev2/portal.go | 2 +- 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index 1bf4dfcc..b1718f30 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -78,6 +78,7 @@ type BridgeConfig struct { CrossRoomReplies bool `yaml:"cross_room_replies"` OutgoingMessageReID bool `yaml:"outgoing_message_re_id"` RevertFailedStateChanges bool `yaml:"revert_failed_state_changes"` + KickMatrixUsers bool `yaml:"kick_matrix_users"` CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"` Relay RelayConfig `yaml:"relay"` Permissions PermissionConfig `yaml:"permissions"` diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index 8a9b6f4b..0dbff802 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -41,6 +41,7 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.Bool, "bridge", "deduplicate_matrix_messages") helper.Copy(up.Bool, "bridge", "cross_room_replies") helper.Copy(up.Bool, "bridge", "revert_failed_state_changes") + helper.Copy(up.Bool, "bridge", "kick_matrix_users") helper.Copy(up.Bool, "bridge", "cleanup_on_logout", "enabled") helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "private") helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "relayed") diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 60d41772..27c3aa67 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -49,6 +49,9 @@ bridge: cross_room_replies: false # If a state event fails to bridge, should the bridge revert any state changes made by that event? revert_failed_state_changes: false + # In portals with no relay set, should Matrix users be kicked if they're + # not logged into an account that's in the remote chat? + kick_matrix_users: true # What should be done to portal rooms when a user logs out or is logged out? # Permitted values: diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 955fd401..c2d87d4e 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4411,7 +4411,7 @@ func (portal *Portal) syncParticipants( if memberEvt.Membership == event.MembershipLeave || memberEvt.Membership == event.MembershipBan { continue } - if !portal.Bridge.IsGhostMXID(extraMember) && portal.Relay != nil { + if !portal.Bridge.IsGhostMXID(extraMember) && (portal.Relay != nil || !portal.Bridge.Config.KickMatrixUsers) { continue } _, err = portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateMember, extraMember.String(), &event.Content{ From e31d186dc8d3813b171564404cf3e7859d800748 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 12 Nov 2025 21:44:04 +0200 Subject: [PATCH 435/581] statestore: save join rules for rooms --- sqlstatestore/statestore.go | 23 +++++++++++++++++++++++ sqlstatestore/v00-latest-revision.sql | 3 ++- sqlstatestore/v10-join-rules.sql | 2 ++ statestore.go | 20 ++++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 sqlstatestore/v10-join-rules.sql diff --git a/sqlstatestore/statestore.go b/sqlstatestore/statestore.go index c4126802..11957dfa 100644 --- a/sqlstatestore/statestore.go +++ b/sqlstatestore/statestore.go @@ -470,3 +470,26 @@ func (store *SQLStateStore) GetCreate(ctx context.Context, roomID id.RoomID) (ev } return } + +func (store *SQLStateStore) SetJoinRules(ctx context.Context, roomID id.RoomID, rules *event.JoinRulesEventContent) error { + if roomID == "" { + return fmt.Errorf("room ID is empty") + } + _, err := store.Exec(ctx, ` + INSERT INTO mx_room_state (room_id, join_rules) VALUES ($1, $2) + ON CONFLICT (room_id) DO UPDATE SET join_rules=excluded.join_rules + `, roomID, dbutil.JSON{Data: rules}) + return err +} + +func (store *SQLStateStore) GetJoinRules(ctx context.Context, roomID id.RoomID) (levels *event.JoinRulesEventContent, err error) { + levels = &event.JoinRulesEventContent{} + err = store. + QueryRow(ctx, "SELECT join_rules FROM mx_room_state WHERE room_id=$1 AND join_rules IS NOT NULL", roomID). + Scan(&dbutil.JSON{Data: &levels}) + if errors.Is(err, sql.ErrNoRows) { + levels = nil + err = nil + } + return +} diff --git a/sqlstatestore/v00-latest-revision.sql b/sqlstatestore/v00-latest-revision.sql index b5a858ec..4679f1c6 100644 --- a/sqlstatestore/v00-latest-revision.sql +++ b/sqlstatestore/v00-latest-revision.sql @@ -1,4 +1,4 @@ --- v0 -> v9 (compatible with v3+): Latest revision +-- v0 -> v10 (compatible with v3+): Latest revision CREATE TABLE mx_registrations ( user_id TEXT PRIMARY KEY @@ -27,5 +27,6 @@ CREATE TABLE mx_room_state ( power_levels jsonb, encryption jsonb, create_event jsonb, + join_rules jsonb, members_fetched BOOLEAN NOT NULL DEFAULT false ); diff --git a/sqlstatestore/v10-join-rules.sql b/sqlstatestore/v10-join-rules.sql new file mode 100644 index 00000000..3074c46a --- /dev/null +++ b/sqlstatestore/v10-join-rules.sql @@ -0,0 +1,2 @@ +-- v10 (compatible with v3+): Add join rules to room state table +ALTER TABLE mx_room_state ADD COLUMN join_rules jsonb; diff --git a/statestore.go b/statestore.go index 1933ab95..c6267c5b 100644 --- a/statestore.go +++ b/statestore.go @@ -37,6 +37,9 @@ type StateStore interface { SetCreate(ctx context.Context, evt *event.Event) error GetCreate(ctx context.Context, roomID id.RoomID) (*event.Event, error) + GetJoinRules(ctx context.Context, roomID id.RoomID) (*event.JoinRulesEventContent, error) + SetJoinRules(ctx context.Context, roomID id.RoomID, content *event.JoinRulesEventContent) error + HasFetchedMembers(ctx context.Context, roomID id.RoomID) (bool, error) MarkMembersFetched(ctx context.Context, roomID id.RoomID) error GetAllMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) @@ -73,6 +76,8 @@ func UpdateStateStore(ctx context.Context, store StateStore, evt *event.Event) { err = store.SetEncryptionEvent(ctx, evt.RoomID, content) case *event.CreateEventContent: err = store.SetCreate(ctx, evt) + case *event.JoinRulesEventContent: + err = store.SetJoinRules(ctx, evt.RoomID, content) default: switch evt.Type { case event.StateMember, event.StatePowerLevels, event.StateEncryption, event.StateCreate: @@ -107,11 +112,13 @@ type MemoryStateStore struct { PowerLevels map[id.RoomID]*event.PowerLevelsEventContent `json:"power_levels"` Encryption map[id.RoomID]*event.EncryptionEventContent `json:"encryption"` Create map[id.RoomID]*event.Event `json:"create"` + JoinRules map[id.RoomID]*event.JoinRulesEventContent `json:"join_rules"` registrationsLock sync.RWMutex membersLock sync.RWMutex powerLevelsLock sync.RWMutex encryptionLock sync.RWMutex + joinRulesLock sync.RWMutex } func NewMemoryStateStore() StateStore { @@ -354,6 +361,19 @@ func (store *MemoryStateStore) GetEncryptionEvent(_ context.Context, roomID id.R return store.Encryption[roomID], nil } +func (store *MemoryStateStore) SetJoinRules(ctx context.Context, roomID id.RoomID, content *event.JoinRulesEventContent) error { + store.joinRulesLock.Lock() + store.JoinRules[roomID] = content + store.joinRulesLock.Unlock() + return nil +} + +func (store *MemoryStateStore) GetJoinRules(ctx context.Context, roomID id.RoomID) (*event.JoinRulesEventContent, error) { + store.joinRulesLock.RLock() + defer store.joinRulesLock.RUnlock() + return store.JoinRules[roomID], nil +} + func (store *MemoryStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) { cfg, err := store.GetEncryptionEvent(ctx, roomID) return cfg != nil && cfg.Algorithm == id.AlgorithmMegolmV1, err From 6c7828afe37073991cad089d85efa1016c94dc94 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 12 Nov 2025 21:44:23 +0200 Subject: [PATCH 436/581] bridgev2/portal: skip invite step if room is public --- bridgev2/matrix/connector.go | 24 ++++++++++++++++++++---- bridgev2/portal.go | 11 ++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index d81c34d2..3e05837f 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -26,6 +26,7 @@ import ( _ "go.mau.fi/util/dbutil/litestream" "go.mau.fi/util/exbytes" "go.mau.fi/util/exsync" + "go.mau.fi/util/ptr" "go.mau.fi/util/random" "golang.org/x/sync/semaphore" @@ -599,10 +600,25 @@ func (br *Connector) GetPowerLevels(ctx context.Context, roomID id.RoomID) (*eve } func (br *Connector) GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error) { - if eventType == event.StateCreate && stateKey == "" { - createEvt, err := br.Bot.StateStore.GetCreate(ctx, roomID) - if err != nil || createEvt != nil { - return createEvt, err + if stateKey == "" { + switch eventType { + case event.StateCreate: + createEvt, err := br.Bot.StateStore.GetCreate(ctx, roomID) + if err != nil || createEvt != nil { + return createEvt, err + } + case event.StateJoinRules: + joinRulesContent, err := br.Bot.StateStore.GetJoinRules(ctx, roomID) + if err != nil { + return nil, err + } else if joinRulesContent != nil { + return &event.Event{ + Type: event.StateJoinRules, + RoomID: roomID, + StateKey: ptr.Ptr(""), + Content: event.Content{Parsed: joinRulesContent}, + }, nil + } } } return br.Bot.FullStateEvent(ctx, roomID, eventType, "") diff --git a/bridgev2/portal.go b/bridgev2/portal.go index c2d87d4e..344ca807 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4236,6 +4236,15 @@ func (portal *Portal) updateOtherUser(ctx context.Context, members *ChatMemberLi return false } +func (portal *Portal) roomIsPublic(ctx context.Context) bool { + evt, err := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState).GetStateEvent(ctx, portal.MXID, event.StateJoinRules, "") + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get join rules to check if room is public") + return false + } + return evt != nil && evt.Content.AsJoinRules().JoinRule == event.JoinRulePublic +} + func (portal *Portal) syncParticipants( ctx context.Context, members *ChatMemberList, @@ -4304,7 +4313,7 @@ func (portal *Portal) syncParticipants( wrappedContent := &event.Content{Parsed: content, Raw: exmaps.NonNilClone(member.MemberEventExtra)} addExcludeFromTimeline(wrappedContent.Raw) thisEvtSender := sender - if member.Membership == event.MembershipJoin { + if member.Membership == event.MembershipJoin && (intent == nil || !portal.roomIsPublic(ctx)) { content.Membership = event.MembershipInvite if intent != nil { wrappedContent.Raw["fi.mau.will_auto_accept"] = true From e9bfa0c51912e6a25ea6d992861fe5abe081417a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 12 Nov 2025 22:04:29 +0200 Subject: [PATCH 437/581] bridgev2/portal: treat spam checker join rule as public --- bridgev2/portal.go | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 344ca807..c59d21c7 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4236,13 +4236,33 @@ func (portal *Portal) updateOtherUser(ctx context.Context, members *ChatMemberLi return false } +func looksDirectlyJoinable(rule *event.JoinRulesEventContent) bool { + switch rule.JoinRule { + case event.JoinRulePublic: + return true + case event.JoinRuleKnockRestricted, event.JoinRuleRestricted: + for _, allow := range rule.Allow { + if allow.Type == "fi.mau.spam_checker" { + return true + } + } + } + return false +} + func (portal *Portal) roomIsPublic(ctx context.Context) bool { evt, err := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState).GetStateEvent(ctx, portal.MXID, event.StateJoinRules, "") if err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get join rules to check if room is public") return false + } else if evt == nil { + return false } - return evt != nil && evt.Content.AsJoinRules().JoinRule == event.JoinRulePublic + content, ok := evt.Content.Parsed.(*event.JoinRulesEventContent) + if !ok { + return false + } + return looksDirectlyJoinable(content) } func (portal *Portal) syncParticipants( From 85e25748a8a052825e79ee761ebfe9b910438581 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 12 Nov 2025 23:09:49 +0200 Subject: [PATCH 438/581] bridgev2/portal: ensure join is sent using target intent --- bridgev2/portal.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index c59d21c7..fcdfc02c 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4363,7 +4363,11 @@ func (portal *Portal) syncParticipants( currentMember.Membership = event.MembershipLeave } } - _, err = portal.sendStateWithIntentOrBot(ctx, thisEvtSender, event.StateMember, extraUserID.String(), wrappedContent, ts) + if content.Membership == event.MembershipJoin && intent != nil && intent.GetMXID() == extraUserID { + _, err = intent.SendState(ctx, portal.MXID, event.StateMember, extraUserID.String(), wrappedContent, ts) + } else { + _, err = portal.sendStateWithIntentOrBot(ctx, thisEvtSender, event.StateMember, extraUserID.String(), wrappedContent, ts) + } if err != nil { addLogContext(log.Err(err)). Str("new_membership", string(content.Membership)). From 828ba3cec1012c3377dd23c6b07715543d02eb0b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 12 Nov 2025 23:14:37 +0200 Subject: [PATCH 439/581] bridgev2/portal: add capability to disable formatting relayed messages --- bridgev2/portal.go | 10 ++++++---- event/capabilities.go | 2 ++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index fcdfc02c..b664c8f6 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1054,10 +1054,12 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin log.Debug().Msg("Ignoring poll event from relayed user") return EventHandlingResultIgnored.WithMSSError(ErrIgnoringPollFromRelayedUser) } - msgContent, err = portal.Bridge.Config.Relay.FormatMessage(msgContent, origSender) - if err != nil { - log.Err(err).Msg("Failed to format message for relaying") - return EventHandlingResultFailed.WithMSSError(err) + if !caps.PerMessageProfileRelay { + msgContent, err = portal.Bridge.Config.Relay.FormatMessage(msgContent, origSender) + if err != nil { + log.Err(err).Msg("Failed to format message for relaying") + return EventHandlingResultFailed.WithMSSError(err) + } } } if msgContent != nil { diff --git a/event/capabilities.go b/event/capabilities.go index 5ecea4a2..4b7ff186 100644 --- a/event/capabilities.go +++ b/event/capabilities.go @@ -60,6 +60,8 @@ type RoomFeatures struct { MarkAsUnread bool `json:"mark_as_unread,omitempty"` DeleteChat bool `json:"delete_chat,omitempty"` DeleteChatForEveryone bool `json:"delete_chat_for_everyone,omitempty"` + + PerMessageProfileRelay bool `json:"-"` } func (rf *RoomFeatures) GetID() string { From 151d9456850166ea9ef839fb1906f23c9e09b04b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 13 Nov 2025 01:29:45 +0200 Subject: [PATCH 440/581] event/capabilities: add docstrings for state and member_actions --- event/capabilities.d.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/event/capabilities.d.ts b/event/capabilities.d.ts index 2d95cd50..1fbc9610 100644 --- a/event/capabilities.d.ts +++ b/event/capabilities.d.ts @@ -16,7 +16,22 @@ export interface RoomFeatures { * If a message type isn't listed here, it should be treated as support level -2 (will be rejected). */ file?: Record + /** + * Supported state event types and their parameters. Currently, there are no parameters, + * but it is likely there will be some in the future (like max name/topic length, avatar mime types, etc.). + * + * Events that are not listed or have a support level of zero or below should be treated as unsupported. + * + * Clients should at least check `m.room.name`, `m.room.topic`, and `m.room.avatar` here. + * `m.room.member` will not be listed here, as it's controlled by the member_actions field. + * `com.beeper.disappearing_timer` should be listed here, but the parameters are in the disappearing_timer field for now. + */ state?: Record + /** + * Supported member actions and their support levels. + * + * Actions that are not listed or have a support level of zero or below should be treated as unsupported. + */ member_actions?: Record /** Maximum length of normal text messages. */ From eb2fb84009591af94f00f110970f59c108b0a875 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 13 Nov 2025 17:32:14 +0200 Subject: [PATCH 441/581] appservice/intent: don't EnsureJoined when sending massaged own join event --- appservice/intent.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/appservice/intent.go b/appservice/intent.go index 4635f59a..611bf6d8 100644 --- a/appservice/intent.go +++ b/appservice/intent.go @@ -243,7 +243,11 @@ func (intent *IntentAPI) SendStateEvent(ctx context.Context, roomID id.RoomID, e } func (intent *IntentAPI) SendMassagedStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) { - if err := intent.EnsureJoined(ctx, roomID); err != nil { + if eventType != event.StateMember || stateKey != string(intent.UserID) { + if err := intent.EnsureJoined(ctx, roomID); err != nil { + return nil, err + } + } else if err := intent.EnsureRegistered(ctx); err != nil { return nil, err } contentJSON = intent.AddDoublePuppetValueWithTS(contentJSON, ts) From 0b73e9e7bedbbe6201d7163901f0e52fe7b512f2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 13 Nov 2025 17:38:45 +0200 Subject: [PATCH 442/581] client,appservice: deprecate SendMassagedStateEvent in favor of SendStateEvent params --- appservice/intent.go | 26 ++++++++------------------ bridgev2/matrix/intent.go | 12 ++---------- client.go | 17 +++++++++-------- 3 files changed, 19 insertions(+), 36 deletions(-) diff --git a/appservice/intent.go b/appservice/intent.go index 611bf6d8..e4d8e100 100644 --- a/appservice/intent.go +++ b/appservice/intent.go @@ -214,23 +214,20 @@ func (intent *IntentAPI) AddDoublePuppetValueWithTS(into any, ts int64) any { } } -func (intent *IntentAPI) SendMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON interface{}) (*mautrix.RespSendEvent, error) { +func (intent *IntentAPI) SendMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON any, extra ...mautrix.ReqSendEvent) (*mautrix.RespSendEvent, error) { if err := intent.EnsureJoined(ctx, roomID); err != nil { return nil, err } contentJSON = intent.AddDoublePuppetValue(contentJSON) - return intent.Client.SendMessageEvent(ctx, roomID, eventType, contentJSON) + return intent.Client.SendMessageEvent(ctx, roomID, eventType, contentJSON, extra...) } +// Deprecated: use SendMessageEvent with mautrix.ReqSendEvent.Timestamp instead func (intent *IntentAPI) SendMassagedMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) { - if err := intent.EnsureJoined(ctx, roomID); err != nil { - return nil, err - } - contentJSON = intent.AddDoublePuppetValueWithTS(contentJSON, ts) - return intent.Client.SendMessageEvent(ctx, roomID, eventType, contentJSON, mautrix.ReqSendEvent{Timestamp: ts}) + return intent.SendMessageEvent(ctx, roomID, eventType, contentJSON, mautrix.ReqSendEvent{Timestamp: ts}) } -func (intent *IntentAPI) SendStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}) (*mautrix.RespSendEvent, error) { +func (intent *IntentAPI) SendStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON any, extra ...mautrix.ReqSendEvent) (*mautrix.RespSendEvent, error) { if eventType != event.StateMember || stateKey != string(intent.UserID) { if err := intent.EnsureJoined(ctx, roomID); err != nil { return nil, err @@ -239,19 +236,12 @@ func (intent *IntentAPI) SendStateEvent(ctx context.Context, roomID id.RoomID, e return nil, err } contentJSON = intent.AddDoublePuppetValue(contentJSON) - return intent.Client.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON) + return intent.Client.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, extra...) } +// Deprecated: use SendStateEvent with mautrix.ReqSendEvent.Timestamp instead func (intent *IntentAPI) SendMassagedStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) { - if eventType != event.StateMember || stateKey != string(intent.UserID) { - if err := intent.EnsureJoined(ctx, roomID); err != nil { - return nil, err - } - } else if err := intent.EnsureRegistered(ctx); err != nil { - return nil, err - } - contentJSON = intent.AddDoublePuppetValueWithTS(contentJSON, ts) - return intent.Client.SendMassagedStateEvent(ctx, roomID, eventType, stateKey, contentJSON, ts) + return intent.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, mautrix.ReqSendEvent{Timestamp: ts}) } func (intent *IntentAPI) StateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, outContent interface{}) error { diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index 27892fb6..cb4b9b8f 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -82,11 +82,7 @@ func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType eventType = event.EventEncrypted } } - if extra.Timestamp.IsZero() { - return as.Matrix.SendMessageEvent(ctx, roomID, eventType, content) - } else { - return as.Matrix.SendMassagedMessageEvent(ctx, roomID, eventType, content, extra.Timestamp.UnixMilli()) - } + return as.Matrix.SendMessageEvent(ctx, roomID, eventType, content, mautrix.ReqSendEvent{Timestamp: extra.Timestamp.UnixMilli()}) } func (as *ASIntent) fillMemberEvent(ctx context.Context, roomID id.RoomID, userID id.UserID, content *event.Content) { @@ -126,11 +122,7 @@ func (as *ASIntent) SendState(ctx context.Context, roomID id.RoomID, eventType e if eventType == event.StateMember { as.fillMemberEvent(ctx, roomID, id.UserID(stateKey), content) } - if ts.IsZero() { - resp, err = as.Matrix.SendStateEvent(ctx, roomID, eventType, stateKey, content) - } else { - resp, err = as.Matrix.SendMassagedStateEvent(ctx, roomID, eventType, stateKey, content, ts.UnixMilli()) - } + resp, err = as.Matrix.SendStateEvent(ctx, roomID, eventType, stateKey, content, mautrix.ReqSendEvent{Timestamp: ts.UnixMilli()}) if err != nil && eventType == event.StateMember { var httpErr mautrix.HTTPError if errors.As(err, &httpErr) && httpErr.RespError != nil && diff --git a/client.go b/client.go index 3c60a2d1..d07bede5 100644 --- a/client.go +++ b/client.go @@ -1342,9 +1342,9 @@ func (cli *Client) SendMessageEvent(ctx context.Context, roomID id.RoomID, event return } -// SendStateEvent sends a state event into a room. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey +// SendStateEvent sends a state event into a room. See https://spec.matrix.org/v1.16/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey // contentJSON should be a pointer to something that can be encoded as JSON using json.Marshal. -func (cli *Client) SendStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, extra ...ReqSendEvent) (resp *RespSendEvent, err error) { +func (cli *Client) SendStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON any, extra ...ReqSendEvent) (resp *RespSendEvent, err error) { var req ReqSendEvent if len(extra) > 0 { req = extra[0] @@ -1360,6 +1360,9 @@ func (cli *Client) SendStateEvent(ctx context.Context, roomID id.RoomID, eventTy if req.UnstableDelay > 0 { queryParams["org.matrix.msc4140.delay"] = strconv.FormatInt(req.UnstableDelay.Milliseconds(), 10) } + if req.Timestamp > 0 { + queryParams["ts"] = strconv.FormatInt(req.Timestamp, 10) + } urlData := ClientURLPath{"v3", "rooms", roomID, "state", eventType.String(), stateKey} urlPath := cli.BuildURLWithQuery(urlData, queryParams) @@ -1372,14 +1375,12 @@ func (cli *Client) SendStateEvent(ctx context.Context, roomID id.RoomID, eventTy // SendMassagedStateEvent sends a state event into a room with a custom timestamp. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey // contentJSON should be a pointer to something that can be encoded as JSON using json.Marshal. +// +// Deprecated: SendStateEvent accepts a timestamp via ReqSendEvent and should be used instead. func (cli *Client) SendMassagedStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, ts int64) (resp *RespSendEvent, err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "state", eventType.String(), stateKey}, map[string]string{ - "ts": strconv.FormatInt(ts, 10), + resp, err = cli.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, ReqSendEvent{ + Timestamp: ts, }) - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, contentJSON, &resp) - if err == nil && cli.StateStore != nil { - cli.updateStoreWithOutgoingEvent(ctx, roomID, eventType, stateKey, contentJSON) - } return } From a61e4d05f868b147f10ff0b5c16f7ff21b17ddfc Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 13 Nov 2025 17:39:27 +0200 Subject: [PATCH 443/581] bridgev2/matrix: use MSC4169 to send redactions when available --- bridgev2/matrix/intent.go | 3 +-- versions.go | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index cb4b9b8f..1f82f77f 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -48,8 +48,7 @@ func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType if extra == nil { extra = &bridgev2.MatrixSendExtra{} } - // TODO remove this once hungryserv and synapse support sending m.room.redactions directly in all room versions - if eventType == event.EventRedaction { + if eventType == event.EventRedaction && !as.Connector.SpecVersions.Supports(mautrix.FeatureRedactSendAsEvent) { parsedContent := content.Parsed.(*event.RedactionEventContent) as.Matrix.AddDoublePuppetValue(content) return as.Matrix.RedactEvent(ctx, roomID, parsedContent.Redacts, mautrix.ReqRedact{ diff --git a/versions.go b/versions.go index 8c1c49aa..2aaf6399 100644 --- a/versions.go +++ b/versions.go @@ -69,6 +69,7 @@ var ( FeatureAccountModeration = UnstableFeature{UnstableFlag: "uk.timedout.msc4323"} FeatureUnstableProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133"} FeatureArbitraryProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133.stable", SpecVersion: SpecV116} + FeatureRedactSendAsEvent = UnstableFeature{UnstableFlag: "com.beeper.msc4169"} BeeperFeatureHungry = UnstableFeature{UnstableFlag: "com.beeper.hungry"} BeeperFeatureBatchSending = UnstableFeature{UnstableFlag: "com.beeper.batch_sending"} From a0cb5c6129feda78147fff549eac84e488f3b2ad Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 13 Nov 2025 18:10:27 +0200 Subject: [PATCH 444/581] bridgev2/backfill: ignore nil reactions --- bridgev2/portalbackfill.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bridgev2/portalbackfill.go b/bridgev2/portalbackfill.go index cbbce596..88503380 100644 --- a/bridgev2/portalbackfill.go +++ b/bridgev2/portalbackfill.go @@ -394,6 +394,9 @@ func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin } slices.Sort(partIDs) for _, reaction := range msg.Reactions { + if reaction == nil { + continue + } reactionIntent, ok := portal.GetIntentFor(ctx, reaction.Sender, source, RemoteEventReactionRemove) if !ok { continue From 202c7f117634a595b948b005541bcd4f512e164c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 16 Nov 2025 12:43:52 +0200 Subject: [PATCH 445/581] dependencies: update --- go.mod | 16 ++++++++-------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index fb63cf59..c2acc7d6 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module maunium.net/go/mautrix go 1.24.0 -toolchain go1.25.3 +toolchain go1.25.4 require ( filippo.io/edwards25519 v1.1.0 @@ -17,12 +17,12 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.13 - go.mau.fi/util v0.9.2 + go.mau.fi/util v0.9.3 go.mau.fi/zeroconfig v0.2.0 - golang.org/x/crypto v0.43.0 - golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b - golang.org/x/net v0.46.0 - golang.org/x/sync v0.17.0 + golang.org/x/crypto v0.44.0 + golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 + golang.org/x/net v0.47.0 + golang.org/x/sync v0.18.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 ) @@ -36,7 +36,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index faa4ef4c..b5fbf85f 100644 --- a/go.sum +++ b/go.sum @@ -51,26 +51,26 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.2 h1:+S4Z03iCsGqU2WY8X2gySFsFjaLlUHFRDVCYvVwynKM= -go.mau.fi/util v0.9.2/go.mod h1:055elBBCJSdhRsmub7ci9hXZPgGr1U6dYg44cSgRgoU= +go.mau.fi/util v0.9.3 h1:aqNF8KDIN8bFpFbybSk+mEBil7IHeBwlujfyTnvP0uU= +go.mau.fi/util v0.9.3/go.mod h1:krWWfBM1jWTb5f8NCa2TLqWMQuM81X7TGQjhMjBeXmQ= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b h1:18qgiDvlvH7kk8Ioa8Ov+K6xCi0GMvmGfGW0sgd/SYA= -golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= From 36029b762290538d82ef566fce87d7b72ff5732e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 16 Nov 2025 12:51:14 +0200 Subject: [PATCH 446/581] Bump version to v0.26.0 --- CHANGELOG.md | 12 +++++++++++- version.go | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ee4a13d..b6c0ff70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,14 @@ -## v0.26.0 (unreleased) +## v0.26.0 (2025-11-16) +* *(client,appservice)* Deprecated `SendMassagedStateEvent` as `SendStateEvent` + has been able to do the same for a while now. * *(client,federation)* Added size limits for responses to make it safer to send requests to untrusted servers. * *(client)* Added wrapper for `/admin/whois` client API (thanks to [@nexy7574] in [#411]). * *(synapseadmin)* Added `force_purge` option to DeleteRoom (thanks to [@nexy7574] in [#420]). +* *(statestore)* Added saving join rules for rooms. * *(bridgev2)* Added optional automatic rollback of room state if bridging the change to the remote network fails. * *(bridgev2)* Added management room notices if transient disconnect state @@ -13,13 +16,19 @@ * *(bridgev2)* Added interface to signal that certain participants couldn't be invited when creating a group. * *(bridgev2)* Added `select` type for user input fields in login. +* *(bridgev2)* Added interface to let network connector customize personal + filtering space. * *(bridgev2/matrix)* Added checks to avoid sending error messages in reply to other bots. +* *(bridgev2/matrix)* Switched to using [MSC4169] to send redactions whenever + possible. * *(bridgev2/publicmedia)* Added support for custom path prefixes, file names, and encrypted files. * *(bridgev2/commands)* Added command to resync a single portal. * *(bridgev2/commands)* Added create group command. * *(bridgev2/config)* Added option to limit maximum number of logins. +* *(bridgev2)* Changed ghost joining to skip unnecessary invite if portal room + is public. * *(bridgev2/disappear)* Changed read receipt handling to only start disappearing timers for messages up to the read message (note: may not work in all cases if the read receipt points at an unknown event). @@ -34,6 +43,7 @@ * *(federation)* Fixed validating auth for requests with query params. * *(federation/eventauth)* Fixed typo causing restricted joins to not work. +[MSC416]: https://github.com/matrix-org/matrix-spec-proposals/pull/4169 [#411]: github.com/mautrix/go/pull/411 [#420]: github.com/mautrix/go/pull/420 [#426]: github.com/mautrix/go/pull/426 diff --git a/version.go b/version.go index 7b4eea41..f6d20c3f 100644 --- a/version.go +++ b/version.go @@ -8,7 +8,7 @@ import ( "strings" ) -const Version = "v0.25.2" +const Version = "v0.26.0" var GoModVersion = "" var Commit = "" From 14b85e98a6a9b2c3aca5b94d04556cef474ab123 Mon Sep 17 00:00:00 2001 From: timedout Date: Mon, 17 Nov 2025 16:35:46 +0000 Subject: [PATCH 447/581] federation: Implement federated membership functions (make/send join/knock/leave) (#422) --- federation/client.go | 163 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/federation/client.go b/federation/client.go index b24fd2d2..183fb5d1 100644 --- a/federation/client.go +++ b/federation/client.go @@ -263,6 +263,169 @@ func (c *Client) GetOpenIDUserInfo(ctx context.Context, serverName, accessToken return } +type ReqMakeJoin struct { + RoomID id.RoomID + UserID id.UserID + Via string + SupportedVersions []id.RoomVersion +} + +type RespMakeJoin struct { + RoomVersion id.RoomVersion `json:"room_version"` + Event PDU `json:"event"` +} + +type ReqSendJoin struct { + RoomID id.RoomID + EventID id.EventID + OmitMembers bool + Event PDU + Via string +} + +type ReqSendKnock struct { + RoomID id.RoomID + EventID id.EventID + Event PDU + Via string +} + +type RespSendJoin struct { + AuthChain []PDU `json:"auth_chain"` + Event PDU `json:"event"` + MembersOmitted bool `json:"members_omitted"` + ServersInRoom []string `json:"servers_in_room"` + State []PDU `json:"state"` +} + +type RespSendKnock struct { + KnockRoomState []PDU `json:"knock_room_state"` +} + +type ReqSendInvite struct { + RoomID id.RoomID `json:"-"` + UserID id.UserID `json:"-"` + Event PDU `json:"event"` + InviteRoomState []PDU `json:"invite_room_state"` + RoomVersion id.RoomVersion `json:"room_version"` +} + +type RespSendInvite struct { + Event PDU `json:"event"` +} + +type ReqMakeLeave struct { + RoomID id.RoomID + UserID id.UserID + Via string +} + +type ReqSendLeave struct { + RoomID id.RoomID + EventID id.EventID + Event PDU + Via string +} + +type ( + ReqMakeKnock = ReqMakeJoin + RespMakeKnock = RespMakeJoin + RespMakeLeave = RespMakeJoin +) + +func (c *Client) MakeJoin(ctx context.Context, req *ReqMakeJoin) (resp *RespMakeJoin, err error) { + versions := make([]string, len(req.SupportedVersions)) + for i, v := range req.SupportedVersions { + versions[i] = string(v) + } + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.Via, + Method: http.MethodGet, + Path: URLPath{"v1", "make_join", req.RoomID, req.UserID}, + Query: url.Values{"ver": versions}, + Authenticate: true, + ResponseJSON: &resp, + }) + return +} + +func (c *Client) MakeKnock(ctx context.Context, req *ReqMakeKnock) (resp *RespMakeKnock, err error) { + versions := make([]string, len(req.SupportedVersions)) + for i, v := range req.SupportedVersions { + versions[i] = string(v) + } + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.Via, + Method: http.MethodGet, + Path: URLPath{"v1", "make_knock", req.RoomID, req.UserID}, + Query: url.Values{"ver": versions}, + Authenticate: true, + ResponseJSON: &resp, + }) + return +} + +func (c *Client) SendJoin(ctx context.Context, req *ReqSendJoin) (resp *RespSendJoin, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.Via, + Method: http.MethodPut, + Path: URLPath{"v2", "send_join", req.RoomID, req.EventID}, + Query: url.Values{ + "omit_members": {strconv.FormatBool(req.OmitMembers)}, + }, + Authenticate: true, + RequestJSON: req.Event, + ResponseJSON: &resp, + }) + return +} + +func (c *Client) SendKnock(ctx context.Context, req *ReqSendKnock) (resp *RespSendKnock, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.Via, + Method: http.MethodPut, + Path: URLPath{"v1", "send_knock", req.RoomID, req.EventID}, + Authenticate: true, + RequestJSON: req.Event, + ResponseJSON: &resp, + }) + return +} + +func (c *Client) SendInvite(ctx context.Context, req *ReqSendInvite) (resp *RespSendInvite, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.UserID.Homeserver(), + Method: http.MethodPut, + Path: URLPath{"v2", "invite", req.RoomID, req.UserID}, + Authenticate: true, + RequestJSON: req, + ResponseJSON: &resp, + }) + return +} + +func (c *Client) MakeLeave(ctx context.Context, req *ReqMakeLeave) (resp *RespMakeLeave, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.Via, + Method: http.MethodGet, + Path: URLPath{"v1", "make_leave", req.RoomID, req.UserID}, + Authenticate: true, + ResponseJSON: &resp, + }) + return +} + +func (c *Client) SendLeave(ctx context.Context, req *ReqSendLeave) (err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.Via, + Method: http.MethodPut, + Path: URLPath{"v2", "send_leave", req.RoomID, req.EventID}, + Authenticate: true, + RequestJSON: req.Event, + }) + return +} + type URLPath []any func (fup URLPath) FullPath() []any { From 346100cfd4fae875fd13b7e82b0ebe94c8e77a74 Mon Sep 17 00:00:00 2001 From: Finn Date: Mon, 17 Nov 2025 10:18:46 -0800 Subject: [PATCH 448/581] statestore: fix missing JoinRules map when initializing MemoryStateStore (#432) --- statestore.go | 1 + 1 file changed, 1 insertion(+) diff --git a/statestore.go b/statestore.go index c6267c5b..2bd498dd 100644 --- a/statestore.go +++ b/statestore.go @@ -129,6 +129,7 @@ func NewMemoryStateStore() StateStore { PowerLevels: make(map[id.RoomID]*event.PowerLevelsEventContent), Encryption: make(map[id.RoomID]*event.EncryptionEventContent), Create: make(map[id.RoomID]*event.Event), + JoinRules: make(map[id.RoomID]*event.JoinRulesEventContent), } } From 606b627d48797c988884574473a884cbd220c438 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 16 Nov 2025 13:04:02 +0200 Subject: [PATCH 449/581] changelog: fix link --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6c0ff70..b30e055e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ * *(federation)* Fixed validating auth for requests with query params. * *(federation/eventauth)* Fixed typo causing restricted joins to not work. -[MSC416]: https://github.com/matrix-org/matrix-spec-proposals/pull/4169 +[MSC4169]: https://github.com/matrix-org/matrix-spec-proposals/pull/4169 [#411]: github.com/mautrix/go/pull/411 [#420]: github.com/mautrix/go/pull/420 [#426]: github.com/mautrix/go/pull/426 From 8a59112eb1302b3d1429d096929289f2fca0c842 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 16 Nov 2025 13:04:13 +0200 Subject: [PATCH 450/581] client: move some room summary fields to public room info --- responses.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/responses.go b/responses.go index e7b6b75e..d822c84b 100644 --- a/responses.go +++ b/responses.go @@ -263,10 +263,7 @@ type RespMutualRooms struct { type RespRoomSummary struct { PublicRoomInfo - Membership event.Membership `json:"membership,omitempty"` - RoomVersion id.RoomVersion `json:"room_version,omitempty"` - Encryption id.Algorithm `json:"encryption,omitempty"` - AllowedRoomIDs []id.RoomID `json:"allowed_room_ids,omitempty"` + Membership event.Membership `json:"membership,omitempty"` UnstableRoomVersion id.RoomVersion `json:"im.nheko.summary.room_version,omitempty"` UnstableRoomVersionOld id.RoomVersion `json:"im.nheko.summary.version,omitempty"` @@ -685,6 +682,10 @@ type PublicRoomInfo struct { RoomType event.RoomType `json:"room_type"` Topic string `json:"topic,omitempty"` WorldReadable bool `json:"world_readable"` + + RoomVersion id.RoomVersion `json:"room_version,omitempty"` + Encryption id.Algorithm `json:"encryption,omitempty"` + AllowedRoomIDs []id.RoomID `json:"allowed_room_ids,omitempty"` } // RespHierarchy is the JSON response for https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv1roomsroomidhierarchy From 57657d54eeac15038496c3df6c9388b7071ced0c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 19 Nov 2025 12:15:38 +0100 Subject: [PATCH 451/581] bridgev2: add custom event for requesting state change (#428) --- bridgev2/matrix/connector.go | 1 + bridgev2/messagestatus.go | 9 +++++--- bridgev2/portal.go | 44 +++++++++++++++++++++++++++++++++++- event/beeper.go | 8 +++++++ event/content.go | 1 + event/type.go | 1 + 6 files changed, 60 insertions(+), 4 deletions(-) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 3e05837f..dbddaff2 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -145,6 +145,7 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) { br.EventProcessor.On(event.StateMember, br.handleRoomEvent) br.EventProcessor.On(event.StatePowerLevels, br.handleRoomEvent) br.EventProcessor.On(event.StateRoomName, br.handleRoomEvent) + br.EventProcessor.On(event.BeeperSendState, br.handleRoomEvent) br.EventProcessor.On(event.StateRoomAvatar, br.handleRoomEvent) br.EventProcessor.On(event.StateTopic, br.handleRoomEvent) br.EventProcessor.On(event.StateTombstone, br.handleRoomEvent) diff --git a/bridgev2/messagestatus.go b/bridgev2/messagestatus.go index 7118649d..df0c9e4d 100644 --- a/bridgev2/messagestatus.go +++ b/bridgev2/messagestatus.go @@ -20,6 +20,7 @@ import ( type MessageStatusEventInfo struct { RoomID id.RoomID + TransactionID string SourceEventID id.EventID NewEventID id.EventID EventType event.Type @@ -41,6 +42,7 @@ func StatusEventInfoFromEvent(evt *event.Event) *MessageStatusEventInfo { return &MessageStatusEventInfo{ RoomID: evt.RoomID, + TransactionID: evt.Unsigned.TransactionID, SourceEventID: evt.ID, EventType: evt.Type, MessageType: evt.Content.AsMessage().MsgType, @@ -182,9 +184,10 @@ func (ms *MessageStatus) ToMSSEvent(evt *MessageStatusEventInfo) *event.BeeperMe Type: event.RelReference, EventID: evt.SourceEventID, }, - Status: ms.Status, - Reason: ms.ErrorReason, - Message: ms.Message, + TargetTxnID: evt.TransactionID, + Status: ms.Status, + Reason: ms.ErrorReason, + Message: ms.Message, } if ms.InternalError != nil { content.InternalError = ms.InternalError.Error() diff --git a/bridgev2/portal.go b/bridgev2/portal.go index b664c8f6..0fae1724 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -512,6 +512,13 @@ func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCal }() switch evt := rawEvt.(type) { case *portalMatrixEvent: + isStateRequest := evt.evt.Type == event.BeeperSendState + if isStateRequest { + if err := portal.unwrapBeeperSendState(ctx, evt.evt); err != nil { + portal.sendErrorStatus(ctx, evt.evt, err) + return + } + } res = portal.handleMatrixEvent(ctx, evt.sender, evt.evt) if res.SendMSS { if res.Error != nil { @@ -520,9 +527,21 @@ func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCal portal.sendSuccessStatus(ctx, evt.evt, 0, "") } } - if res.Error != nil && evt.evt.StateKey != nil { + if !isStateRequest && res.Error != nil && evt.evt.StateKey != nil { portal.revertRoomMeta(ctx, evt.evt) } + if isStateRequest && res.Success { + portal.sendRoomMeta( + ctx, + evt.sender.DoublePuppet(ctx), + time.UnixMilli(evt.evt.Timestamp), + evt.evt.Type, + evt.evt.GetStateKey(), + evt.evt.Content.Parsed, + false, + evt.evt.Content.Raw, + ) + } case *portalRemoteEvent: res = portal.handleRemoteEvent(ctx, evt.source, evt.evtType, evt.evt) case *portalCreateEvent: @@ -534,6 +553,29 @@ func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCal } } +func (portal *Portal) unwrapBeeperSendState(ctx context.Context, evt *event.Event) error { + content, ok := evt.Content.Parsed.(*event.BeeperSendStateEventContent) + if !ok { + return fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) + } + evt.Content = content.Content + evt.StateKey = &content.StateKey + evt.Type = event.Type{Type: content.Type, Class: event.StateEventType} + _ = evt.Content.ParseRaw(evt.Type) + mx, ok := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState) + if !ok { + return fmt.Errorf("matrix connector doesn't support fetching state") + } + prevEvt, err := mx.GetStateEvent(ctx, portal.MXID, evt.Type, evt.GetStateKey()) + if err != nil { + return fmt.Errorf("failed to get prev event: %w", err) + } else if prevEvt != nil { + evt.Unsigned.PrevContent = &prevEvt.Content + evt.Unsigned.PrevSender = prevEvt.Sender + } + return nil +} + func (portal *Portal) FindPreferredLogin(ctx context.Context, user *User, allowRelay bool) (*UserLogin, *database.UserPortal, error) { if portal.Receiver != "" { login, err := portal.Bridge.GetExistingUserLoginByID(ctx, portal.Receiver) diff --git a/event/beeper.go b/event/beeper.go index 95b4a571..94892de7 100644 --- a/event/beeper.go +++ b/event/beeper.go @@ -53,6 +53,8 @@ type BeeperMessageStatusEventContent struct { LastRetry id.EventID `json:"last_retry,omitempty"` + TargetTxnID string `json:"relates_to_txn_id,omitempty"` + MutateEventKey string `json:"mutate_event_key,omitempty"` // Indicates the set of users to whom the event was delivered. If nil, then @@ -90,6 +92,12 @@ type BeeperChatDeleteEventContent struct { DeleteForEveryone bool `json:"delete_for_everyone,omitempty"` } +type BeeperSendStateEventContent struct { + Type string `json:"type"` + StateKey string `json:"state_key"` + Content Content `json:"content"` +} + type IntOrString int func (ios *IntOrString) UnmarshalJSON(data []byte) error { diff --git a/event/content.go b/event/content.go index c0ff51ad..73fb0db5 100644 --- a/event/content.go +++ b/event/content.go @@ -64,6 +64,7 @@ var TypeMap = map[Type]reflect.Type{ BeeperMessageStatus: reflect.TypeOf(BeeperMessageStatusEventContent{}), BeeperTranscription: reflect.TypeOf(BeeperTranscriptionEventContent{}), BeeperDeleteChat: reflect.TypeOf(BeeperChatDeleteEventContent{}), + BeeperSendState: reflect.TypeOf(BeeperSendStateEventContent{}), AccountDataRoomTags: reflect.TypeOf(TagEventContent{}), AccountDataDirectChats: reflect.TypeOf(DirectChatsEventContent{}), diff --git a/event/type.go b/event/type.go index 56ea82f6..4fca07ea 100644 --- a/event/type.go +++ b/event/type.go @@ -237,6 +237,7 @@ var ( BeeperMessageStatus = Type{"com.beeper.message_send_status", MessageEventType} BeeperTranscription = Type{"com.beeper.transcription", MessageEventType} BeeperDeleteChat = Type{"com.beeper.delete_chat", MessageEventType} + BeeperSendState = Type{"com.beeper.send_state", MessageEventType} EventUnstablePollStart = Type{Type: "org.matrix.msc3381.poll.start", Class: MessageEventType} EventUnstablePollResponse = Type{Type: "org.matrix.msc3381.poll.response", Class: MessageEventType} From fa56255a06be1ea60ec6ed7b544fa123a640ed3c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 19 Nov 2025 23:13:19 +0200 Subject: [PATCH 452/581] bridgev2/portal: ignore not found errors when fetching prev state --- bridgev2/portal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 0fae1724..27faef73 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -567,7 +567,7 @@ func (portal *Portal) unwrapBeeperSendState(ctx context.Context, evt *event.Even return fmt.Errorf("matrix connector doesn't support fetching state") } prevEvt, err := mx.GetStateEvent(ctx, portal.MXID, evt.Type, evt.GetStateKey()) - if err != nil { + if err != nil && !errors.Is(err, mautrix.MNotFound) { return fmt.Errorf("failed to get prev event: %w", err) } else if prevEvt != nil { evt.Unsigned.PrevContent = &prevEvt.Content From 1fac8ceb66534a7e34b4ad070b69a71034dabcd3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 19 Nov 2025 23:21:56 +0200 Subject: [PATCH 453/581] bridgev2/matrix: fix GetStateEvent not passing state key through --- bridgev2/matrix/connector.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index dbddaff2..e34e3252 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -622,7 +622,7 @@ func (br *Connector) GetStateEvent(ctx context.Context, roomID id.RoomID, eventT } } } - return br.Bot.FullStateEvent(ctx, roomID, eventType, "") + return br.Bot.FullStateEvent(ctx, roomID, eventType, stateKey) } func (br *Connector) GetMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { From 75d54132ae2619e63db6f762a2452c4d6388260d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 21 Nov 2025 16:07:16 +0200 Subject: [PATCH 454/581] bridgev2/portal: fix getting state events in roomIsPublic --- bridgev2/portal.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 27faef73..032207e8 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4295,7 +4295,11 @@ func looksDirectlyJoinable(rule *event.JoinRulesEventContent) bool { } func (portal *Portal) roomIsPublic(ctx context.Context) bool { - evt, err := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState).GetStateEvent(ctx, portal.MXID, event.StateJoinRules, "") + mx, ok := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState) + if !ok { + return false + } + evt, err := mx.GetStateEvent(ctx, portal.MXID, event.StateJoinRules, "") if err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get join rules to check if room is public") return false From 41b1dfc8c14150232ad162b66e544b8a5cbff6ed Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Nov 2025 15:51:15 +0200 Subject: [PATCH 455/581] bridgev2/provisionutil: check for orphaned DMs in resolve identifier --- bridgev2/matrixinvite.go | 59 +++++++++++---------- bridgev2/provisionutil/resolveidentifier.go | 1 + 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/bridgev2/matrixinvite.go b/bridgev2/matrixinvite.go index b8a5aec6..75c00cb0 100644 --- a/bridgev2/matrixinvite.go +++ b/bridgev2/matrixinvite.go @@ -88,6 +88,36 @@ func sendErrorAndLeave(ctx context.Context, evt *event.Event, intent MatrixAPI, rejectInvite(ctx, evt, intent, "") } +func (portal *Portal) CleanupOrphanedDM(ctx context.Context, userMXID id.UserID) { + if portal.MXID == "" { + return + } + log := zerolog.Ctx(ctx) + existingPortalMembers, err := portal.Bridge.Matrix.GetMembers(ctx, portal.MXID) + if err != nil { + log.Err(err). + Stringer("old_portal_mxid", portal.MXID). + Msg("Failed to check existing portal members, deleting room") + } else if targetUserMember, ok := existingPortalMembers[userMXID]; !ok { + log.Debug(). + Stringer("old_portal_mxid", portal.MXID). + Msg("Inviter has no member event in old portal, deleting room") + } else if targetUserMember.Membership.IsInviteOrJoin() { + return + } else { + log.Debug(). + Stringer("old_portal_mxid", portal.MXID). + Str("membership", string(targetUserMember.Membership)). + Msg("Inviter is not in old portal, deleting room") + } + + if err = portal.RemoveMXID(ctx); err != nil { + log.Err(err).Msg("Failed to delete old portal mxid") + } else if err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, true); err != nil { + log.Err(err).Msg("Failed to clean up old portal room") + } +} + func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sender *User) EventHandlingResult { ghostID, _ := br.Matrix.ParseGhostMXID(id.UserID(evt.GetStateKey())) validator, ok := br.Network.(IdentifierValidatingNetwork) @@ -165,34 +195,7 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen return EventHandlingResultFailed } } - if portal.MXID != "" { - doCleanup := true - existingPortalMembers, err := br.Matrix.GetMembers(ctx, portal.MXID) - if err != nil { - log.Err(err). - Stringer("old_portal_mxid", portal.MXID). - Msg("Failed to check existing portal members, deleting room") - } else if targetUserMember, ok := existingPortalMembers[sender.MXID]; !ok { - log.Debug(). - Stringer("old_portal_mxid", portal.MXID). - Msg("Inviter has no member event in old portal, deleting room") - } else if targetUserMember.Membership.IsInviteOrJoin() { - doCleanup = false - } else { - log.Debug(). - Stringer("old_portal_mxid", portal.MXID). - Str("membership", string(targetUserMember.Membership)). - Msg("Inviter is not in old portal, deleting room") - } - - if doCleanup { - if err = portal.RemoveMXID(ctx); err != nil { - log.Err(err).Msg("Failed to delete old portal mxid") - } else if err = br.Bot.DeleteRoom(ctx, portal.MXID, true); err != nil { - log.Err(err).Msg("Failed to clean up old portal room") - } - } - } + portal.CleanupOrphanedDM(ctx, sender.MXID) err = invitedGhost.Intent.EnsureInvited(ctx, evt.RoomID, br.Bot.GetMXID()) if err != nil { log.Err(err).Msg("Failed to ensure bot is invited to room") diff --git a/bridgev2/provisionutil/resolveidentifier.go b/bridgev2/provisionutil/resolveidentifier.go index 5387347c..cfc388d0 100644 --- a/bridgev2/provisionutil/resolveidentifier.go +++ b/bridgev2/provisionutil/resolveidentifier.go @@ -109,6 +109,7 @@ func ResolveIdentifier( return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to get portal")) } } + resp.Chat.Portal.CleanupOrphanedDM(ctx, login.UserMXID) if createChat && resp.Chat.Portal.MXID == "" { apiResp.JustCreated = true err := resp.Chat.Portal.CreateMatrixRoom(ctx, login, resp.Chat.PortalInfo) From eaa4e07eae677740d0ce2ef5ea8c8f763d8f5ed5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 25 Nov 2025 14:23:09 +0200 Subject: [PATCH 456/581] bridgev2/portal: only allow setting receiver as relay in split portals --- bridgev2/commands/relay.go | 16 +++++++++++++--- bridgev2/portal.go | 3 +++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/bridgev2/commands/relay.go b/bridgev2/commands/relay.go index af756c87..94c19739 100644 --- a/bridgev2/commands/relay.go +++ b/bridgev2/commands/relay.go @@ -37,7 +37,7 @@ func fnSetRelay(ce *Event) { } onlySetDefaultRelays := !ce.User.Permissions.Admin && ce.Bridge.Config.Relay.AdminOnly var relay *bridgev2.UserLogin - if len(ce.Args) == 0 { + if len(ce.Args) == 0 && ce.Portal.Receiver == "" { relay = ce.User.GetDefaultLogin() isLoggedIn := relay != nil if onlySetDefaultRelays { @@ -73,9 +73,19 @@ func fnSetRelay(ce *Event) { } } } else { - relay = ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0])) + var targetID networkid.UserLoginID + if ce.Portal.Receiver != "" { + targetID = ce.Portal.Receiver + if len(ce.Args) > 0 && ce.Args[0] != string(targetID) { + ce.Reply("In split portals, only the receiver (%s) can be set as relay", targetID) + return + } + } else { + targetID = networkid.UserLoginID(ce.Args[0]) + } + relay = ce.Bridge.GetCachedUserLoginByID(targetID) if relay == nil { - ce.Reply("User login with ID `%s` not found", ce.Args[0]) + ce.Reply("User login with ID `%s` not found", targetID) return } else if slices.Contains(ce.Bridge.Config.Relay.DefaultRelays, relay.ID) { // All good diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 032207e8..8c628880 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -5153,6 +5153,9 @@ func (portal *Portal) Save(ctx context.Context) error { } func (portal *Portal) SetRelay(ctx context.Context, relay *UserLogin) error { + if portal.Receiver != "" && relay.ID != portal.Receiver { + return fmt.Errorf("can't set non-receiver login as relay") + } portal.Relay = relay if relay == nil { portal.RelayLoginID = "" From 0f2ff4a090a1ca84b9adba07afa7a0dafee667ba Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 25 Nov 2025 14:23:21 +0200 Subject: [PATCH 457/581] bridgev2/portal: improve error messages in FindPreferredLogin when portal has receiver --- bridgev2/portal.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 8c628880..e777a717 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -582,12 +582,15 @@ func (portal *Portal) FindPreferredLogin(ctx context.Context, user *User, allowR if err != nil { return nil, nil, err } - if login == nil || login.UserMXID != user.MXID || !login.Client.IsLoggedIn() { + if login == nil { + return nil, nil, fmt.Errorf("%w (receiver login is nil)", ErrNotLoggedIn) + } else if !login.Client.IsLoggedIn() { + return nil, nil, fmt.Errorf("%w (receiver login is not logged in)", ErrNotLoggedIn) + } else if login.UserMXID != user.MXID { if allowRelay && portal.Relay != nil { return nil, nil, nil } - // TODO different error for this case? - return nil, nil, ErrNotLoggedIn + return nil, nil, fmt.Errorf("%w (relay not set and receiver login is owned by %s, not %s)", ErrNotLoggedIn, login.UserMXID, user.MXID) } up, err := portal.Bridge.DB.UserPortal.Get(ctx, login.UserLogin, portal.PortalKey) return login, up, err From dc38165473d052b59c967cd322d8c67a731730ab Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Wed, 26 Nov 2025 10:24:51 +0000 Subject: [PATCH 458/581] crypto: allow storing arbitrary metadata alongside encrypted account data For example, the creation time of a key. --- crypto/ssss/client.go | 16 ++++++++++++++++ crypto/ssss/types.go | 1 + 2 files changed, 17 insertions(+) diff --git a/crypto/ssss/client.go b/crypto/ssss/client.go index e30925d9..8691d032 100644 --- a/crypto/ssss/client.go +++ b/crypto/ssss/client.go @@ -95,6 +95,22 @@ func (mach *Machine) SetEncryptedAccountData(ctx context.Context, eventType even return mach.Client.SetAccountData(ctx, eventType.Type, &EncryptedAccountDataEventContent{Encrypted: encrypted}) } +// SetEncryptedAccountDataWithMetadata encrypts the given data with the given keys and stores it, +// alongside the unencrypted metadata, on the server. +func (mach *Machine) SetEncryptedAccountDataWithMetadata(ctx context.Context, eventType event.Type, data []byte, metadata map[string]any, keys ...*Key) error { + if len(keys) == 0 { + return ErrNoKeyGiven + } + encrypted := make(map[string]EncryptedKeyData, len(keys)) + for _, key := range keys { + encrypted[key.ID] = key.Encrypt(eventType.Type, data) + } + return mach.Client.SetAccountData(ctx, eventType.Type, &EncryptedAccountDataEventContent{ + Encrypted: encrypted, + Metadata: metadata, + }) +} + // GenerateAndUploadKey generates a new SSSS key and stores the metadata on the server. func (mach *Machine) GenerateAndUploadKey(ctx context.Context, passphrase string) (key *Key, err error) { key, err = NewKey(passphrase) diff --git a/crypto/ssss/types.go b/crypto/ssss/types.go index 345393b0..c08f107c 100644 --- a/crypto/ssss/types.go +++ b/crypto/ssss/types.go @@ -57,6 +57,7 @@ type EncryptedKeyData struct { type EncryptedAccountDataEventContent struct { Encrypted map[string]EncryptedKeyData `json:"encrypted"` + Metadata map[string]any `json:"com.beeper.metadata,omitzero"` } func (ed *EncryptedAccountDataEventContent) Decrypt(eventType string, key *Key) ([]byte, error) { From 016637ebf88a33d5c11c62e140f5a49b795db370 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 24 Nov 2025 19:07:56 +0000 Subject: [PATCH 459/581] bridgev2/bridgestate: add var to disable catching bridge state queue panics --- bridgev2/bridgestate.go | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/bridgev2/bridgestate.go b/bridgev2/bridgestate.go index 63d5876b..a1d3e70b 100644 --- a/bridgev2/bridgestate.go +++ b/bridgev2/bridgestate.go @@ -22,6 +22,8 @@ import ( "maunium.net/go/mautrix/format" ) +var CatchBridgeStateQueuePanics = true + type BridgeStateQueue struct { prevUnsent *status.BridgeState prevSent *status.BridgeState @@ -84,15 +86,17 @@ func (bsq *BridgeStateQueue) StopUnknownErrorReconnect() { } func (bsq *BridgeStateQueue) loop() { - defer func() { - err := recover() - if err != nil { - bsq.login.Log.Error(). - Bytes(zerolog.ErrorStackFieldName, debug.Stack()). - Any(zerolog.ErrorFieldName, err). - Msg("Panic in bridge state loop") - } - }() + if CatchBridgeStateQueuePanics { + defer func() { + err := recover() + if err != nil { + bsq.login.Log.Error(). + Bytes(zerolog.ErrorStackFieldName, debug.Stack()). + Any(zerolog.ErrorFieldName, err). + Msg("Panic in bridge state loop") + } + }() + } for state := range bsq.ch { bsq.immediateSendBridgeState(state) } From c3b85e8e3c3999ceb8dd267b2a0d3aec35058c05 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Thu, 20 Nov 2025 15:54:04 +0000 Subject: [PATCH 460/581] client: add special error that indicates to retry canceled contexts On it's own this is useless since the retries would all immediately fail with the canceled context error. The caller is expected to also set a `UpdateRequestOnRetry` on the client which is used to swap out the context. --- client.go | 5 ++++- error.go | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/client.go b/client.go index d07bede5..ba67a205 100644 --- a/client.go +++ b/client.go @@ -745,7 +745,10 @@ func (cli *Client) executeCompiledRequest( defer res.Body.Close() } if err != nil { - if retries > 0 && !errors.Is(err, context.Canceled) { + // Either error is *not* canceled or the underlying cause of cancelation explicitly asks to retry + canRetry := !errors.Is(err, context.Canceled) || + errors.Is(context.Cause(req.Context()), ErrContextCancelRetry) + if retries > 0 && canRetry { return cli.doRetry( req, err, retries, backoff, responseJSON, handler, dontReadResponse, sizeLimit, client, ) diff --git a/error.go b/error.go index 826af179..5ff671e0 100644 --- a/error.go +++ b/error.go @@ -85,6 +85,10 @@ var ( ErrResponseTooLong = errors.New("response content length too long") ErrBodyReadReachedLimit = errors.New("reached response size limit while reading body") + + // Special error that indicates we should retry canceled contexts. Note that on it's own this + // is useless, the context itself must also be replaced. + ErrContextCancelRetry = errors.New("retry canceled context") ) // HTTPError An HTTP Error response, which may wrap an underlying native Go Error. From 3293e2f8ff35ef032c6ddafef5b8abbdd72abf34 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 28 Nov 2025 13:38:05 +0200 Subject: [PATCH 461/581] dependencies: update --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index c2acc7d6..d873892c 100644 --- a/go.mod +++ b/go.mod @@ -17,10 +17,10 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.13 - go.mau.fi/util v0.9.3 + go.mau.fi/util v0.9.4-0.20251128113707-115b8b18bd18 go.mau.fi/zeroconfig v0.2.0 - golang.org/x/crypto v0.44.0 - golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 + golang.org/x/crypto v0.45.0 + golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 golang.org/x/net v0.47.0 golang.org/x/sync v0.18.0 gopkg.in/yaml.v3 v3.0.1 @@ -32,7 +32,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490 // indirect + github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect diff --git a/go.sum b/go.sum index b5fbf85f..fae6084d 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490 h1:QTvNkZ5ylY0PGgA+Lih+GdboMLY/G9SEGLMEGVjTVA4= -github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a h1:VweslR2akb/ARhXfqSfRbj1vpWwYXf3eeAUyw/ndms0= +github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -51,14 +51,14 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.3 h1:aqNF8KDIN8bFpFbybSk+mEBil7IHeBwlujfyTnvP0uU= -go.mau.fi/util v0.9.3/go.mod h1:krWWfBM1jWTb5f8NCa2TLqWMQuM81X7TGQjhMjBeXmQ= +go.mau.fi/util v0.9.4-0.20251128113707-115b8b18bd18 h1:h1/wE/SLTuat12/SRsKyh+edWX2Aung1ZsiWnY3t5Zs= +go.mau.fi/util v0.9.4-0.20251128113707-115b8b18bd18/go.mod h1:viDmhBOAFfcqDdKSk53EPJV3N4Mi8Jst5/ahGJ/vwsA= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= -golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= -golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= -golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= +golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= From 1d1ecb228668b819d04bb1e6299b7944626c0c17 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 28 Nov 2025 13:40:54 +0200 Subject: [PATCH 462/581] federation/eventauth: fix sender membership check when kicking --- federation/eventauth/eventauth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/federation/eventauth/eventauth.go b/federation/eventauth/eventauth.go index 32b4424b..eac110a3 100644 --- a/federation/eventauth/eventauth.go +++ b/federation/eventauth/eventauth.go @@ -484,7 +484,7 @@ func authorizeMember(roomVersion id.RoomVersion, evt, createEvt *pdu.PDU, authEv } return ErrCantLeaveWithoutBeingInRoom } - if senderMembership != event.MembershipLeave { + if senderMembership != event.MembershipJoin { // 5.5.2. If the sender’s current membership state is not join, reject. return ErrCantKickWithoutBeingInRoom } From 6e402e8fd2c2b131affef3feb87c0931953f6215 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 1 Dec 2025 00:10:29 +0200 Subject: [PATCH 463/581] bridgev2/backfill: don't try to backfill empty threads --- bridgev2/portalbackfill.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bridgev2/portalbackfill.go b/bridgev2/portalbackfill.go index 88503380..e8292388 100644 --- a/bridgev2/portalbackfill.go +++ b/bridgev2/portalbackfill.go @@ -194,6 +194,9 @@ func (portal *Portal) doThreadBackfill(ctx context.Context, source *UserLogin, t if err != nil { log.Err(err).Msg("Failed to get last thread message") return + } else if anchorMessage == nil { + log.Warn().Msg("No messages found in thread?") + return } resp := portal.fetchThreadBackfill(ctx, source, anchorMessage) if resp != nil { From 09052986b2d3333446a6ca3b4d18553b8602447c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 1 Dec 2025 15:28:56 +0200 Subject: [PATCH 464/581] bridgev2/commands: add command for muting chat on remote network --- bridgev2/commands/processor.go | 2 +- bridgev2/commands/startchat.go | 43 +++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/bridgev2/commands/processor.go b/bridgev2/commands/processor.go index 13a35687..692db80d 100644 --- a/bridgev2/commands/processor.go +++ b/bridgev2/commands/processor.go @@ -44,7 +44,7 @@ func NewProcessor(bridge *bridgev2.Bridge) bridgev2.CommandProcessor { CommandRegisterPush, CommandSendAccountData, CommandDeletePortal, CommandDeleteAllPortals, CommandSetManagementRoom, CommandLogin, CommandRelogin, CommandListLogins, CommandLogout, CommandSetPreferredLogin, CommandSetRelay, CommandUnsetRelay, - CommandResolveIdentifier, CommandStartChat, CommandCreateGroup, CommandSearch, CommandSyncChat, + CommandResolveIdentifier, CommandStartChat, CommandCreateGroup, CommandSearch, CommandSyncChat, CommandMute, CommandSudo, CommandDoIn, ) return proc diff --git a/bridgev2/commands/startchat.go b/bridgev2/commands/startchat.go index 24586387..c7b05a6e 100644 --- a/bridgev2/commands/startchat.go +++ b/bridgev2/commands/startchat.go @@ -80,7 +80,7 @@ var CommandStartChat = &FullHandler{ NetworkAPI: NetworkAPIImplements[bridgev2.IdentifierResolvingNetworkAPI], } -func getClientForStartingChat[T bridgev2.IdentifierResolvingNetworkAPI](ce *Event, thing string) (*bridgev2.UserLogin, T, []string) { +func getClientForStartingChat[T bridgev2.NetworkAPI](ce *Event, thing string) (*bridgev2.UserLogin, T, []string) { var remainingArgs []string if len(ce.Args) > 1 { remainingArgs = ce.Args[1:] @@ -290,3 +290,44 @@ func fnSearch(ce *Event) { } ce.Reply("Search results:\n\n%s", strings.Join(resultsString, "\n")) } + +var CommandMute = &FullHandler{ + Func: fnMute, + Name: "mute", + Aliases: []string{"unmute"}, + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Mute or unmute a chat on the remote network", + Args: "[duration]", + }, + RequiresPortal: true, + RequiresLogin: true, + NetworkAPI: NetworkAPIImplements[bridgev2.MuteHandlingNetworkAPI], +} + +func fnMute(ce *Event) { + _, api, _ := getClientForStartingChat[bridgev2.MuteHandlingNetworkAPI](ce, "muting chats") + var mutedUntil int64 + if ce.Command == "mute" { + mutedUntil = -1 + if len(ce.Args) > 0 { + duration, err := time.ParseDuration(ce.Args[0]) + if err != nil { + ce.Reply("Invalid duration: %v", err) + return + } + mutedUntil = time.Now().Add(duration).UnixMilli() + } + } + err := api.HandleMute(ce.Ctx, &bridgev2.MatrixMute{ + MatrixEventBase: bridgev2.MatrixEventBase[*event.BeeperMuteEventContent]{ + Content: &event.BeeperMuteEventContent{MutedUntil: mutedUntil}, + Portal: ce.Portal, + }, + }) + if err != nil { + ce.Reply("Failed to %s chat: %v", ce.Command, err) + } else { + ce.React("✅️") + } +} From e22802b9bb27d05dcd766df9b46f3fb55db6027f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 1 Dec 2025 17:07:54 +0200 Subject: [PATCH 465/581] bridgev2/database: improve missing parents when migrating to split portals --- bridgev2/database/portal.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bridgev2/database/portal.go b/bridgev2/database/portal.go index a230df19..f6868be6 100644 --- a/bridgev2/database/portal.go +++ b/bridgev2/database/portal.go @@ -88,7 +88,7 @@ const ( getPortalByIDWithUncertainReceiverQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND id=$2 AND (receiver=$3 OR receiver='')` getPortalByMXIDQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND mxid=$2` getAllPortalsWithMXIDQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND mxid IS NOT NULL` - getAllPortalsWithoutReceiver = getPortalBaseQuery + `WHERE bridge_id=$1 AND receiver=''` + getAllPortalsWithoutReceiver = getPortalBaseQuery + `WHERE bridge_id=$1 AND (receiver='' OR (parent_id<>'' AND parent_receiver='')) ORDER BY parent_id DESC` getAllDMPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND room_type='dm' AND other_user_id=$2` getDMPortalQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND room_type='dm' AND receiver=$2 AND other_user_id=$3` getAllPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1` @@ -148,7 +148,10 @@ const ( ) ` fixParentsAfterSplitPortalMigrationQuery = ` - UPDATE portal SET parent_receiver=receiver WHERE bridge_id=$1 AND parent_receiver='' AND receiver<>'' AND parent_id<>''; + UPDATE portal + SET parent_receiver=receiver + WHERE bridge_id=$1 AND parent_receiver='' AND receiver<>'' AND parent_id<>'' + AND EXISTS(SELECT 1 FROM portal pp WHERE pp.bridge_id=$1 AND pp.id=portal.parent_id AND pp.receiver=portal.receiver); ` ) From 5206439b83b35211304b49265e20f8c5b6361f4a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 2 Dec 2025 13:52:28 +0200 Subject: [PATCH 466/581] bridgev2/portal: pass is state request flag to event handlers --- bridgev2/networkinterface.go | 3 ++- bridgev2/portal.go | 31 ++++++++++++++++++++----------- bridgev2/portalinternal.go | 20 ++++++++++++++------ bridgev2/queue.go | 1 + 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 9bbcf897..193dc909 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -1382,7 +1382,8 @@ type MatrixMessageRemove struct { type MatrixRoomMeta[ContentType any] struct { MatrixEventBase[ContentType] - PrevContent ContentType + PrevContent ContentType + IsStateRequest bool } type MatrixRoomName = MatrixRoomMeta[*event.RoomNameEventContent] diff --git a/bridgev2/portal.go b/bridgev2/portal.go index e777a717..84fb5333 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -519,7 +519,7 @@ func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCal return } } - res = portal.handleMatrixEvent(ctx, evt.sender, evt.evt) + res = portal.handleMatrixEvent(ctx, evt.sender, evt.evt, isStateRequest) if res.SendMSS { if res.Error != nil { portal.sendErrorStatus(ctx, evt.evt, res.Error) @@ -673,7 +673,7 @@ func (portal *Portal) checkConfusableName(ctx context.Context, userID id.UserID, var fakePerMessageProfileEventType = event.Type{Class: event.StateEventType, Type: "m.per_message_profile"} -func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt *event.Event) EventHandlingResult { +func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt *event.Event, isStateRequest bool) EventHandlingResult { log := zerolog.Ctx(ctx) if evt.Mautrix.EventSource&event.SourceEphemeral != 0 { switch evt.Type { @@ -705,6 +705,9 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * } var origSender *OrigSender if login == nil { + if isStateRequest { + return EventHandlingResultFailed.WithMSSError(ErrCantRelayStateRequest) + } login = portal.Relay origSender = &OrigSender{ User: sender, @@ -775,13 +778,13 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * case event.EventRedaction: return portal.handleMatrixRedaction(ctx, login, origSender, evt) case event.StateRoomName: - return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, RoomNameHandlingNetworkAPI.HandleMatrixRoomName) + return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, isStateRequest, RoomNameHandlingNetworkAPI.HandleMatrixRoomName) case event.StateTopic: - return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, RoomTopicHandlingNetworkAPI.HandleMatrixRoomTopic) + return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, isStateRequest, RoomTopicHandlingNetworkAPI.HandleMatrixRoomTopic) case event.StateRoomAvatar: - return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, RoomAvatarHandlingNetworkAPI.HandleMatrixRoomAvatar) + return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, isStateRequest, RoomAvatarHandlingNetworkAPI.HandleMatrixRoomAvatar) case event.StateBeeperDisappearingTimer: - return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, DisappearTimerChangingNetworkAPI.HandleMatrixDisappearingTimer) + return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, isStateRequest, DisappearTimerChangingNetworkAPI.HandleMatrixDisappearingTimer) case event.StateEncryption: // TODO? return EventHandlingResultIgnored @@ -792,9 +795,9 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * case event.AccountDataBeeperMute: return handleMatrixAccountData(portal, ctx, login, evt, MuteHandlingNetworkAPI.HandleMute) case event.StateMember: - return portal.handleMatrixMembership(ctx, login, origSender, evt) + return portal.handleMatrixMembership(ctx, login, origSender, evt, isStateRequest) case event.StatePowerLevels: - return portal.handleMatrixPowerLevels(ctx, login, origSender, evt) + return portal.handleMatrixPowerLevels(ctx, login, origSender, evt, isStateRequest) case event.BeeperDeleteChat: return portal.handleMatrixDeleteChat(ctx, login, origSender, evt) default: @@ -1607,6 +1610,7 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( sender *UserLogin, origSender *OrigSender, evt *event.Event, + isStateRequest bool, fn func(APIType, context.Context, *MatrixRoomMeta[ContentType]) (bool, error), ) EventHandlingResult { if evt.StateKey == nil || *evt.StateKey != "" { @@ -1670,7 +1674,8 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( InputTransactionID: portal.parseInputTransactionID(origSender, evt), }, - PrevContent: prevContent, + IsStateRequest: isStateRequest, + PrevContent: prevContent, }) if err != nil { log.Err(err).Msg("Failed to handle Matrix room metadata") @@ -1797,6 +1802,7 @@ func (portal *Portal) handleMatrixMembership( sender *UserLogin, origSender *OrigSender, evt *event.Event, + isStateRequest bool, ) EventHandlingResult { if evt.StateKey == nil { return EventHandlingResultFailed.WithMSSError(ErrInvalidStateKey) @@ -1847,7 +1853,8 @@ func (portal *Portal) handleMatrixMembership( InputTransactionID: portal.parseInputTransactionID(origSender, evt), }, - PrevContent: prevContent, + IsStateRequest: isStateRequest, + PrevContent: prevContent, }, Target: target, TargetGhost: targetGhost, @@ -1884,6 +1891,7 @@ func (portal *Portal) handleMatrixPowerLevels( sender *UserLogin, origSender *OrigSender, evt *event.Event, + isStateRequest bool, ) EventHandlingResult { if evt.StateKey == nil || *evt.StateKey != "" { return EventHandlingResultFailed.WithMSSError(ErrInvalidStateKey) @@ -1925,7 +1933,8 @@ func (portal *Portal) handleMatrixPowerLevels( InputTransactionID: portal.parseInputTransactionID(origSender, evt), }, - PrevContent: prevContent, + IsStateRequest: isStateRequest, + PrevContent: prevContent, }, Users: make(map[id.UserID]*UserPowerLevelChange), Events: make(map[string]*SinglePowerLevelChange), diff --git a/bridgev2/portalinternal.go b/bridgev2/portalinternal.go index 749ee389..4c7e2447 100644 --- a/bridgev2/portalinternal.go +++ b/bridgev2/portalinternal.go @@ -49,6 +49,10 @@ func (portal *PortalInternals) HandleSingleEvent(ctx context.Context, rawEvt any (*Portal)(portal).handleSingleEvent(ctx, rawEvt, doneCallback) } +func (portal *PortalInternals) UnwrapBeeperSendState(ctx context.Context, evt *event.Event) error { + return (*Portal)(portal).unwrapBeeperSendState(ctx, evt) +} + func (portal *PortalInternals) SendSuccessStatus(ctx context.Context, evt *event.Event, streamOrder int64, newEventID id.EventID) { (*Portal)(portal).sendSuccessStatus(ctx, evt, streamOrder, newEventID) } @@ -61,8 +65,8 @@ func (portal *PortalInternals) CheckConfusableName(ctx context.Context, userID i return (*Portal)(portal).checkConfusableName(ctx, userID, name) } -func (portal *PortalInternals) HandleMatrixEvent(ctx context.Context, sender *User, evt *event.Event) EventHandlingResult { - return (*Portal)(portal).handleMatrixEvent(ctx, sender, evt) +func (portal *PortalInternals) HandleMatrixEvent(ctx context.Context, sender *User, evt *event.Event, isStateRequest bool) EventHandlingResult { + return (*Portal)(portal).handleMatrixEvent(ctx, sender, evt, isStateRequest) } func (portal *PortalInternals) HandleMatrixReceipts(ctx context.Context, evt *event.Event) EventHandlingResult { @@ -125,12 +129,12 @@ func (portal *PortalInternals) HandleMatrixDeleteChat(ctx context.Context, sende return (*Portal)(portal).handleMatrixDeleteChat(ctx, sender, origSender, evt) } -func (portal *PortalInternals) HandleMatrixMembership(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { - return (*Portal)(portal).handleMatrixMembership(ctx, sender, origSender, evt) +func (portal *PortalInternals) HandleMatrixMembership(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, isStateRequest bool) EventHandlingResult { + return (*Portal)(portal).handleMatrixMembership(ctx, sender, origSender, evt, isStateRequest) } -func (portal *PortalInternals) HandleMatrixPowerLevels(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { - return (*Portal)(portal).handleMatrixPowerLevels(ctx, sender, origSender, evt) +func (portal *PortalInternals) HandleMatrixPowerLevels(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, isStateRequest bool) EventHandlingResult { + return (*Portal)(portal).handleMatrixPowerLevels(ctx, sender, origSender, evt, isStateRequest) } func (portal *PortalInternals) HandleMatrixTombstone(ctx context.Context, evt *event.Event) EventHandlingResult { @@ -305,6 +309,10 @@ func (portal *PortalInternals) UpdateOtherUser(ctx context.Context, members *Cha return (*Portal)(portal).updateOtherUser(ctx, members) } +func (portal *PortalInternals) RoomIsPublic(ctx context.Context) bool { + return (*Portal)(portal).roomIsPublic(ctx) +} + func (portal *PortalInternals) SyncParticipants(ctx context.Context, members *ChatMemberList, source *UserLogin, sender MatrixAPI, ts time.Time) error { return (*Portal)(portal).syncParticipants(ctx, members, source, sender, ts) } diff --git a/bridgev2/queue.go b/bridgev2/queue.go index 308d03c5..8a3b707b 100644 --- a/bridgev2/queue.go +++ b/bridgev2/queue.go @@ -67,6 +67,7 @@ var ( ErrEventSenderUserNotFound = WrapErrorInStatus(errors.New("sender not found for event")).WithIsCertain(true).WithErrorAsMessage() ErrNoPermissionToInteract = WrapErrorInStatus(errors.New("you don't have permission to send messages")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage() ErrNoPermissionForCommands = WrapErrorInStatus(WrapErrorInStatus(errors.New("you don't have permission to use commands")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage()) + ErrCantRelayStateRequest = WrapErrorInStatus(errors.New("relayed users can't use beeper state requests")).WithIsCertain(true).WithErrorAsMessage() ) func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) EventHandlingResult { From dfd5485a0dafc809f2b9edc0b89e9dab85474aea Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 2 Dec 2025 14:16:22 +0200 Subject: [PATCH 467/581] bridgev2/networkinterface: remove deprecated fields in MatrixMembershipChange --- bridgev2/networkinterface.go | 5 ----- bridgev2/portal.go | 7 ++----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 193dc909..b4bf36ff 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -897,11 +897,6 @@ type MatrixMembershipChange struct { MatrixRoomMeta[*event.MemberEventContent] Target GhostOrUserLogin Type MembershipChangeType - - // Deprecated: Use Target instead - TargetGhost *Ghost - // Deprecated: Use Target instead - TargetUserLogin *UserLogin } type MembershipHandlingNetworkAPI interface { diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 84fb5333..5b4e31ef 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1842,7 +1842,6 @@ func (portal *Portal) handleMatrixMembership( return EventHandlingResultIgnored //.WithMSSError(ErrIgnoringLeaveEvent) } targetGhost, _ := target.(*Ghost) - targetUserLogin, _ := target.(*UserLogin) membershipChange := &MatrixMembershipChange{ MatrixRoomMeta: MatrixRoomMeta[*event.MemberEventContent]{ MatrixEventBase: MatrixEventBase[*event.MemberEventContent]{ @@ -1856,10 +1855,8 @@ func (portal *Portal) handleMatrixMembership( IsStateRequest: isStateRequest, PrevContent: prevContent, }, - Target: target, - TargetGhost: targetGhost, - TargetUserLogin: targetUserLogin, - Type: membershipChangeType, + Target: target, + Type: membershipChangeType, } _, err = api.HandleMatrixMembership(ctx, membershipChange) if err != nil { From 2eeece6942544a2e53f196b03f0bfab42c14db02 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 2 Dec 2025 15:22:01 +0200 Subject: [PATCH 468/581] bridgev2/networkinterface: allow HandleMatrixMembership to redirect invites to another user ID --- bridgev2/networkinterface.go | 6 ++++- bridgev2/portal.go | 48 +++++++++++++++++++++++++++++++++--- bridgev2/queue.go | 7 ++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index b4bf36ff..9c3f7d71 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -899,9 +899,13 @@ type MatrixMembershipChange struct { Type MembershipChangeType } +type MatrixMembershipResult struct { + RedirectTo networkid.UserID +} + type MembershipHandlingNetworkAPI interface { NetworkAPI - HandleMatrixMembership(ctx context.Context, msg *MatrixMembershipChange) (bool, error) + HandleMatrixMembership(ctx context.Context, msg *MatrixMembershipChange) (*MatrixMembershipResult, error) } type SinglePowerLevelChange struct { diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 5b4e31ef..c0855c2d 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -530,7 +530,7 @@ func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCal if !isStateRequest && res.Error != nil && evt.evt.StateKey != nil { portal.revertRoomMeta(ctx, evt.evt) } - if isStateRequest && res.Success { + if isStateRequest && res.Success && !res.SkipStateEcho { portal.sendRoomMeta( ctx, evt.sender.DoublePuppet(ctx), @@ -1858,12 +1858,54 @@ func (portal *Portal) handleMatrixMembership( Target: target, Type: membershipChangeType, } - _, err = api.HandleMatrixMembership(ctx, membershipChange) + res, err := api.HandleMatrixMembership(ctx, membershipChange) if err != nil { log.Err(err).Msg("Failed to handle Matrix membership change") return EventHandlingResultFailed.WithMSSError(err) } - return EventHandlingResultSuccess.WithMSS() + didRedirectInvite := membershipChangeType == Invite && + targetGhost != nil && + res != nil && + res.RedirectTo != "" && + res.RedirectTo != targetGhost.ID + if didRedirectInvite { + log.Debug(). + Str("orig_id", string(targetGhost.ID)). + Str("redirect_id", string(res.RedirectTo)). + Msg("Invite was redirected to different ghost") + var redirectGhost *Ghost + redirectGhost, err = portal.Bridge.GetGhostByID(ctx, res.RedirectTo) + if err != nil { + log.Err(err).Msg("Failed to get redirect target ghost") + return EventHandlingResultFailed.WithError(err) + } + if !isStateRequest { + portal.sendRoomMeta( + ctx, + sender.User.DoublePuppet(ctx), + time.UnixMilli(evt.Timestamp), + event.StateMember, + evt.GetStateKey(), + &event.MemberEventContent{ + Membership: event.MembershipLeave, + Reason: fmt.Sprintf("Invite redirected to %s", res.RedirectTo), + }, + true, + nil, + ) + } + portal.sendRoomMeta( + ctx, + sender.User.DoublePuppet(ctx), + time.UnixMilli(evt.Timestamp), + event.StateMember, + redirectGhost.Intent.GetMXID().String(), + content, + false, + nil, + ) + } + return EventHandlingResultSuccess.WithMSS().WithSkipStateEcho(didRedirectInvite) } func makePLChange(old, new int, newIsSet bool) *SinglePowerLevelChange { diff --git a/bridgev2/queue.go b/bridgev2/queue.go index 8a3b707b..6667caea 100644 --- a/bridgev2/queue.go +++ b/bridgev2/queue.go @@ -160,6 +160,8 @@ type EventHandlingResult struct { Ignored bool Queued bool + SkipStateEcho bool + // Error is an optional reason for failure. It is not required, Success may be false even without a specific error. Error error // Whether the Error should be sent as a MSS event. @@ -195,6 +197,11 @@ func (ehr EventHandlingResult) WithMSS() EventHandlingResult { return ehr } +func (ehr EventHandlingResult) WithSkipStateEcho(skip bool) EventHandlingResult { + ehr.SkipStateEcho = skip + return ehr +} + func (ehr EventHandlingResult) WithMSSError(err error) EventHandlingResult { if err == nil { return ehr From 7d54edbfda13aac65a7499353f9f0043e2c6338a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 2 Dec 2025 18:15:24 +0200 Subject: [PATCH 469/581] bridgev2/mxmain: add support for reading env vars from config --- bridgev2/bridgeconfig/config.go | 2 + bridgev2/bridgeconfig/upgrade.go | 3 + bridgev2/matrix/mxmain/envconfig.go | 161 +++++++++++++++++++++ bridgev2/matrix/mxmain/example-config.yaml | 10 ++ bridgev2/matrix/mxmain/main.go | 7 + 5 files changed, 183 insertions(+) create mode 100644 bridgev2/matrix/mxmain/envconfig.go diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index b1718f30..8b9aa019 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -33,6 +33,8 @@ type Config struct { Encryption EncryptionConfig `yaml:"encryption"` Logging zeroconfig.Config `yaml:"logging"` + EnvConfigPrefix string `yaml:"env_config_prefix"` + ManagementRoomTexts ManagementRoomTexts `yaml:"management_room_texts"` } diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index 0dbff802..a3ac8747 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -184,6 +184,8 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.Int, "encryption", "rotation", "messages") helper.Copy(up.Bool, "encryption", "rotation", "disable_device_change_key_rotation") + helper.Copy(up.Str, "env_config_prefix") + helper.Copy(up.Map, "logging") } @@ -211,6 +213,7 @@ var SpacedBlocks = [][]string{ {"backfill"}, {"double_puppet"}, {"encryption"}, + {"env_config_prefix"}, {"logging"}, } diff --git a/bridgev2/matrix/mxmain/envconfig.go b/bridgev2/matrix/mxmain/envconfig.go new file mode 100644 index 00000000..1b4f1467 --- /dev/null +++ b/bridgev2/matrix/mxmain/envconfig.go @@ -0,0 +1,161 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mxmain + +import ( + "fmt" + "iter" + "os" + "reflect" + "strconv" + "strings" + + "go.mau.fi/util/random" +) + +var randomParseFilePrefix = random.String(16) + "READFILE:" + +func parseEnv(prefix string) iter.Seq2[[]string, string] { + return func(yield func([]string, string) bool) { + for _, s := range os.Environ() { + if !strings.HasPrefix(s, prefix) { + continue + } + kv := strings.SplitN(s, "=", 2) + key := strings.TrimPrefix(kv[0], prefix) + value := kv[1] + if strings.HasSuffix(key, "_FILE") { + key = strings.TrimSuffix(key, "_FILE") + value = randomParseFilePrefix + value + } + key = strings.ToLower(key) + if !strings.ContainsRune(key, '.') { + key = strings.ReplaceAll(key, "__", ".") + } + if !yield(strings.Split(key, "."), value) { + return + } + } + } +} + +func reflectYAMLFieldName(f *reflect.StructField) string { + parts := strings.SplitN(f.Tag.Get("yaml"), ",", 2) + fieldName := parts[0] + if fieldName == "-" && len(parts) == 1 { + return "" + } + if fieldName == "" { + return strings.ToLower(f.Name) + } + return fieldName +} + +type reflectGetResult struct { + val reflect.Value + valKind reflect.Kind + remainingPath []string +} + +func reflectGetYAML(rv reflect.Value, path []string) (*reflectGetResult, bool) { + if len(path) == 0 { + return &reflectGetResult{val: rv, valKind: rv.Kind()}, true + } + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + switch rv.Kind() { + case reflect.Map: + return &reflectGetResult{val: rv, remainingPath: path, valKind: rv.Type().Elem().Kind()}, true + case reflect.Struct: + fields := reflect.VisibleFields(rv.Type()) + for _, field := range fields { + fieldName := reflectYAMLFieldName(&field) + if fieldName != "" && fieldName == path[0] { + return reflectGetYAML(rv.FieldByIndex(field.Index), path[1:]) + } + } + default: + } + return nil, false +} + +func reflectGetFromMainOrNetwork(main, network reflect.Value, path []string) (*reflectGetResult, bool) { + if len(path) > 0 && path[0] == "network" { + return reflectGetYAML(network, path[1:]) + } + return reflectGetYAML(main, path) +} + +func formatKeyString(key []string) string { + return strings.Join(key, "->") +} + +func UpdateConfigFromEnv(cfg, networkData any, prefix string) error { + cfgVal := reflect.ValueOf(cfg) + networkVal := reflect.ValueOf(networkData) + for key, value := range parseEnv(prefix) { + field, ok := reflectGetFromMainOrNetwork(cfgVal, networkVal, key) + if !ok { + return fmt.Errorf("%s not found", formatKeyString(key)) + } + if strings.HasPrefix(value, randomParseFilePrefix) { + filepath := strings.TrimPrefix(value, randomParseFilePrefix) + fileData, err := os.ReadFile(filepath) + if err != nil { + return fmt.Errorf("failed to read file %s for %s: %w", filepath, formatKeyString(key), err) + } + value = strings.TrimSpace(string(fileData)) + } + var parsedVal any + var err error + switch field.valKind { + case reflect.String: + parsedVal = value + case reflect.Bool: + parsedVal, err = strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err) + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + parsedVal, err = strconv.ParseInt(value, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + parsedVal, err = strconv.ParseUint(value, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err) + } + case reflect.Float32, reflect.Float64: + parsedVal, err = strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err) + } + default: + return fmt.Errorf("unsupported type %s in %s", field.valKind, formatKeyString(key)) + } + if field.val.Kind() == reflect.Ptr { + if field.val.IsNil() { + field.val.Set(reflect.New(field.val.Type().Elem())) + } + field.val = field.val.Elem() + } + if field.val.Kind() == reflect.Map { + key = key[:len(key)-len(field.remainingPath)] + mapKeyStr := strings.Join(field.remainingPath, ".") + key = append(key, mapKeyStr) + if field.val.Type().Key().Kind() != reflect.String { + return fmt.Errorf("unsupported map key type %s in %s", field.val.Type().Key().Kind(), formatKeyString(key)) + } + field.val.SetMapIndex(reflect.ValueOf(mapKeyStr), reflect.ValueOf(parsedVal)) + } else { + field.val.Set(reflect.ValueOf(parsedVal)) + } + } + return nil +} diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 27c3aa67..947d771b 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -444,6 +444,16 @@ encryption: # You should not enable this option unless you understand all the implications. disable_device_change_key_rotation: false +# Prefix for environment variables. All variables with this prefix must map to valid config fields. +# Nesting in variable names is represented with a dot (.). +# If there are no dots in the name, two underscores (__) are replaced with a dot. +# +# e.g. if the prefix is set to `BRIDGE_`, then `BRIDGE_APPSERVICE__AS_TOKEN` will set appservice.as_token. +# `BRIDGE_appservice.as_token` would work as well, but can't be set in a shell as easily. +# +# If this is null, reading config fields from environment will be disabled. +env_config_prefix: null + # Logging config. See https://github.com/tulir/zeroconfig for details. logging: min_level: debug diff --git a/bridgev2/matrix/mxmain/main.go b/bridgev2/matrix/mxmain/main.go index ca0ca5f7..1e8b51d1 100644 --- a/bridgev2/matrix/mxmain/main.go +++ b/bridgev2/matrix/mxmain/main.go @@ -354,6 +354,13 @@ func (br *BridgeMain) LoadConfig() { } } cfg.Bridge.Backfill = cfg.Backfill + if cfg.EnvConfigPrefix != "" { + err = UpdateConfigFromEnv(&cfg, networkData, cfg.EnvConfigPrefix) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Failed to parse environment variables:", err) + os.Exit(10) + } + } br.Config = &cfg } From 02ce6ff9185113f31c9f3a55b7e2e5e6fbd4101c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 3 Dec 2025 21:59:41 +0200 Subject: [PATCH 470/581] mediaproxy: allow delayed mime type and redirects for file responses --- mediaproxy/mediaproxy.go | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/mediaproxy/mediaproxy.go b/mediaproxy/mediaproxy.go index 07e30810..2063675a 100644 --- a/mediaproxy/mediaproxy.go +++ b/mediaproxy/mediaproxy.go @@ -95,9 +95,13 @@ func (d *GetMediaResponseCallback) GetContentType() string { return d.ContentType } +type FileMeta struct { + ContentType string + ReplacementFile string +} + type GetMediaResponseFile struct { - Callback func(w *os.File) error - ContentType string + Callback func(w *os.File) (*FileMeta, error) } type GetMediaFunc = func(ctx context.Context, mediaID string, params map[string]string) (response GetMediaResponse, err error) @@ -453,23 +457,35 @@ func doTempFileDownload( if err != nil { return false, fmt.Errorf("failed to create temp file: %w", err) } + origTempFile := tempFile defer func() { - _ = tempFile.Close() - _ = os.Remove(tempFile.Name()) + _ = origTempFile.Close() + _ = os.Remove(origTempFile.Name()) }() - err = data.Callback(tempFile) + meta, err := data.Callback(tempFile) if err != nil { return false, err } - _, err = tempFile.Seek(0, io.SeekStart) - if err != nil { - return false, fmt.Errorf("failed to seek to start of temp file: %w", err) + if meta.ReplacementFile != "" { + tempFile, err = os.Open(meta.ReplacementFile) + if err != nil { + return false, fmt.Errorf("failed to open replacement file: %w", err) + } + defer func() { + _ = tempFile.Close() + _ = os.Remove(origTempFile.Name()) + }() + } else { + _, err = tempFile.Seek(0, io.SeekStart) + if err != nil { + return false, fmt.Errorf("failed to seek to start of temp file: %w", err) + } } fileInfo, err := tempFile.Stat() if err != nil { return false, fmt.Errorf("failed to stat temp file: %w", err) } - mimeType := data.ContentType + mimeType := meta.ContentType if mimeType == "" { buf := make([]byte, 512) n, err := tempFile.Read(buf) From f6d8362278ab843dc9f5c919a5af71ea39ebf993 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Fri, 5 Dec 2025 11:36:43 +0000 Subject: [PATCH 471/581] client: add missing retry cancel check while backing off requests --- client.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client.go b/client.go index ba67a205..9961e717 100644 --- a/client.go +++ b/client.go @@ -614,7 +614,9 @@ func (cli *Client) doRetry( select { case <-time.After(backoff): case <-req.Context().Done(): - return nil, nil, req.Context().Err() + if !errors.Is(context.Cause(req.Context()), ErrContextCancelRetry) { + return nil, nil, req.Context().Err() + } } if cli.UpdateRequestOnRetry != nil { req = cli.UpdateRequestOnRetry(req, cause) From 4efa4bdac5e3821232a04ac73c7270fd457fa764 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 6 Dec 2025 12:51:01 +0200 Subject: [PATCH 472/581] bridgev2/config: allow multiple prioritized backfill limit override keys --- bridgev2/bridgeconfig/backfill.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bridgev2/bridgeconfig/backfill.go b/bridgev2/bridgeconfig/backfill.go index 53282e41..eedae1e8 100644 --- a/bridgev2/bridgeconfig/backfill.go +++ b/bridgev2/bridgeconfig/backfill.go @@ -34,10 +34,12 @@ type BackfillQueueConfig struct { MaxBatchesOverride map[string]int `yaml:"max_batches_override"` } -func (bqc *BackfillQueueConfig) GetOverride(name string) int { - override, ok := bqc.MaxBatchesOverride[name] - if !ok { - return bqc.MaxBatches +func (bqc *BackfillQueueConfig) GetOverride(names ...string) int { + for _, name := range names { + override, ok := bqc.MaxBatchesOverride[name] + if ok { + return override + } } - return override + return bqc.MaxBatches } From 3e07631f9e178807e204ab92687bd6f69f385b78 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 6 Dec 2025 22:58:11 +0200 Subject: [PATCH 473/581] bridgev2/mxmain: add better error for pre-megabridge dbs --- bridgev2/matrix/mxmain/dberror.go | 7 ++++++- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/bridgev2/matrix/mxmain/dberror.go b/bridgev2/matrix/mxmain/dberror.go index 0f6aa68c..f5e438de 100644 --- a/bridgev2/matrix/mxmain/dberror.go +++ b/bridgev2/matrix/mxmain/dberror.go @@ -66,7 +66,12 @@ func (br *BridgeMain) LogDBUpgradeErrorAndExit(name string, err error, message s } else if errors.Is(err, dbutil.ErrForeignTables) { br.Log.Info().Msg("See https://docs.mau.fi/faq/foreign-tables for more info") } else if errors.Is(err, dbutil.ErrNotOwned) { - br.Log.Info().Msg("Sharing the same database with different programs is not supported") + var noe dbutil.NotOwnedError + if errors.As(err, &noe) && noe.Owner == br.Name { + br.Log.Info().Msg("The database appears to be on a very old pre-megabridge schema. Perhaps you need to run an older version of the bridge with migration support first?") + } else { + br.Log.Info().Msg("Sharing the same database with different programs is not supported") + } } else if errors.Is(err, dbutil.ErrUnsupportedDatabaseVersion) { br.Log.Info().Msg("Downgrading the bridge is not supported") } diff --git a/go.mod b/go.mod index d873892c..bf56a014 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.13 - go.mau.fi/util v0.9.4-0.20251128113707-115b8b18bd18 + go.mau.fi/util v0.9.4-0.20251206205611-85e6fd6551e0 go.mau.fi/zeroconfig v0.2.0 golang.org/x/crypto v0.45.0 golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 diff --git a/go.sum b/go.sum index fae6084d..6ea3f378 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.4-0.20251128113707-115b8b18bd18 h1:h1/wE/SLTuat12/SRsKyh+edWX2Aung1ZsiWnY3t5Zs= -go.mau.fi/util v0.9.4-0.20251128113707-115b8b18bd18/go.mod h1:viDmhBOAFfcqDdKSk53EPJV3N4Mi8Jst5/ahGJ/vwsA= +go.mau.fi/util v0.9.4-0.20251206205611-85e6fd6551e0 h1:ESebxPGULuuxxcZigjcBFyyU62tiyY6ivtX17P4BkvY= +go.mau.fi/util v0.9.4-0.20251206205611-85e6fd6551e0/go.mod h1:viDmhBOAFfcqDdKSk53EPJV3N4Mi8Jst5/ahGJ/vwsA= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= From a2522192ff84512fa671844094c65a4775dae435 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 7 Dec 2025 19:34:29 +0200 Subject: [PATCH 474/581] bridgev2/config: fix warning log for null env_config_prefix --- bridgev2/bridgeconfig/upgrade.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index a3ac8747..960e2fb4 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -184,7 +184,7 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.Int, "encryption", "rotation", "messages") helper.Copy(up.Bool, "encryption", "rotation", "disable_device_change_key_rotation") - helper.Copy(up.Str, "env_config_prefix") + helper.Copy(up.Str|up.Null, "env_config_prefix") helper.Copy(up.Map, "logging") } From 0584fd0c0d6e43adae98ec256e288001de1d89c6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 7 Dec 2025 19:52:08 +0200 Subject: [PATCH 475/581] bridgev2/portal: don't forward backfill without CanBackfill flag --- bridgev2/portal.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index c0855c2d..b6f60b78 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -5089,7 +5089,10 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo } } portal.addToUserSpaces(ctx) - if portal.Bridge.Config.Backfill.Enabled && portal.RoomType != database.RoomTypeSpace && !portal.Bridge.Background { + if info.CanBackfill && + portal.Bridge.Config.Backfill.Enabled && + portal.RoomType != database.RoomTypeSpace && + !portal.Bridge.Background { portal.doForwardBackfill(ctx, source, nil, backfillBundle) } return nil From 00c58efc59068c72f08db7d01d854f40ec453812 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 7 Dec 2025 19:52:22 +0200 Subject: [PATCH 476/581] bridgev2/portal: don't try to update functional members if portal doesn't exist --- bridgev2/portal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index b6f60b78..ad67b773 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -2427,7 +2427,7 @@ func (portal *Portal) handleRemoteEvent(ctx context.Context, source *UserLogin, } func (portal *Portal) ensureFunctionalMember(ctx context.Context, ghost *Ghost) { - if !ghost.IsBot || portal.RoomType != database.RoomTypeDM || portal.OtherUserID == ghost.ID { + if !ghost.IsBot || portal.RoomType != database.RoomTypeDM || portal.OtherUserID == ghost.ID || portal.MXID == "" { return } ars, ok := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState) From 6017612c552b6ca7b9f3786bd8cd669358153d0f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 7 Dec 2025 23:21:05 +0200 Subject: [PATCH 477/581] bridgev2/portal: only delete old reactions if new one is successful --- bridgev2/portal.go | 46 +++++++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index ad67b773..0d71535d 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1466,7 +1466,7 @@ func (portal *Portal) handleMatrixEdit( return EventHandlingResultSuccess } -func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogin, evt *event.Event) EventHandlingResult { +func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogin, evt *event.Event) (handleRes EventHandlingResult) { log := zerolog.Ctx(ctx) reactingAPI, ok := sender.Client.(ReactionHandlingNetworkAPI) if !ok { @@ -1511,6 +1511,25 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi if portal.Bridge.Config.OutgoingMessageReID { deterministicID = portal.Bridge.Matrix.GenerateReactionEventID(portal.MXID, reactionTarget, preResp.SenderID, preResp.EmojiID) } + removeOutdatedReaction := func(oldReact *database.Reaction, deleteDB bool) { + if !handleRes.Success { + return + } + _, err := portal.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ + Parsed: &event.RedactionEventContent{ + Redacts: oldReact.MXID, + }, + }, nil) + if err != nil { + log.Err(err).Msg("Failed to remove old reaction") + } + if deleteDB { + err = portal.Bridge.DB.Reaction.Delete(ctx, oldReact) + if err != nil { + log.Err(err).Msg("Failed to delete old reaction from database") + } + } + } existing, err := portal.Bridge.DB.Reaction.GetByID(ctx, portal.Receiver, reactionTarget.ID, reactionTarget.PartID, preResp.SenderID, preResp.EmojiID) if err != nil { log.Err(err).Msg("Failed to check if reaction is a duplicate") @@ -1522,14 +1541,7 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi return EventHandlingResultIgnored.WithEventID(deterministicID) } react.ReactionToOverride = existing - _, err = portal.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ - Parsed: &event.RedactionEventContent{ - Redacts: existing.MXID, - }, - }, nil) - if err != nil { - log.Err(err).Msg("Failed to remove old reaction") - } + defer removeOutdatedReaction(existing, false) } react.PreHandleResp = &preResp if preResp.MaxReactions > 0 { @@ -1544,18 +1556,10 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi // Keep n-1 previous reactions and remove the rest react.ExistingReactionsToKeep = allReactions[:preResp.MaxReactions-1] for _, oldReaction := range allReactions[preResp.MaxReactions-1:] { - _, err = portal.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ - Parsed: &event.RedactionEventContent{ - Redacts: oldReaction.MXID, - }, - }, nil) - if err != nil { - log.Err(err).Msg("Failed to remove previous reaction after limit was exceeded") - } - err = portal.Bridge.DB.Reaction.Delete(ctx, oldReaction) - if err != nil { - log.Err(err).Msg("Failed to delete previous reaction from database after limit was exceeded") - } + // Intentionally defer in a loop, there won't be that many items, + // and we want all of them to be done after this function completes successfully + //goland:noinspection GoDeferInLoop + defer removeOutdatedReaction(oldReaction, true) } } } From 315d2ab17d338f6aef6026929e8671726cd76ba7 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 8 Dec 2025 00:07:07 +0200 Subject: [PATCH 478/581] all: fix staticcheck issues --- .github/workflows/go.yml | 1 + .pre-commit-config.yaml | 3 +- appservice/websocket.go | 2 +- bridgev2/bridgeconfig/permissions.go | 5 +- bridgev2/bridgestate.go | 6 +- bridgev2/database/database.go | 58 ------------ bridgev2/matrix/crypto.go | 8 +- bridgev2/matrix/matrix.go | 1 + bridgev2/matrix/mxmain/legacymigrate.go | 5 +- bridgev2/matrix/provisioning.go | 3 +- bridgev2/portalbackfill.go | 1 + bridgev2/portalreid.go | 2 +- client.go | 10 +- crypto/attachment/attachments.go | 49 ++++++---- crypto/attachment/attachments_test.go | 10 +- crypto/cross_sign_pubkey.go | 4 +- crypto/cross_sign_store.go | 30 +++--- crypto/cryptohelper/cryptohelper.go | 5 +- crypto/decryptmegolm.go | 57 +++++++----- crypto/decryptolm.go | 54 ++++++----- crypto/devicelist.go | 35 ++++--- crypto/encryptmegolm.go | 11 ++- crypto/goolm/account/account.go | 2 +- crypto/goolm/account/account_test.go | 2 +- crypto/goolm/goolmbase64/base64.go | 4 +- crypto/goolm/libolmpickle/picklejson.go | 2 +- crypto/goolm/message/session_export.go | 2 +- crypto/goolm/message/session_sharing.go | 2 +- crypto/goolm/pk/decryption.go | 2 +- crypto/goolm/pk/encryption.go | 3 + .../goolm/session/megolm_inbound_session.go | 4 +- .../goolm/session/megolm_outbound_session.go | 6 +- crypto/goolm/session/olm_session.go | 13 ++- crypto/goolm/session/register.go | 8 +- crypto/keybackup.go | 11 ++- crypto/libolm/account.go | 22 ++--- crypto/libolm/error.go | 30 +++--- crypto/libolm/inboundgroupsession.go | 18 ++-- crypto/libolm/outboundgroupsession.go | 8 +- crypto/libolm/pk.go | 2 +- crypto/libolm/register.go | 2 +- crypto/libolm/session.go | 20 ++-- crypto/machine.go | 2 +- crypto/olm/errors.go | 93 +++++++++++-------- crypto/sessions.go | 14 ++- event/encryption.go | 2 +- federation/resolution.go | 5 +- filter.go | 2 +- id/contenturi.go | 20 ++-- id/matrixuri.go | 2 +- id/userid.go | 8 +- pushrules/action.go | 2 +- pushrules/condition_test.go | 8 -- room.go | 6 +- synapseadmin/roomapi.go | 3 +- url.go | 6 +- 56 files changed, 358 insertions(+), 338 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index dc4f17e2..8bce4484 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -24,6 +24,7 @@ jobs: - name: Install goimports run: | go install golang.org/x/tools/cmd/goimports@latest + go install honnef.co/go/tools/cmd/staticcheck@latest export PATH="$HOME/go/bin:$PATH" - name: Run pre-commit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0b9785ae..4f769e56 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,8 +18,7 @@ repos: - "-w" - id: go-vet-repo-mod - id: go-mod-tidy - # TODO enable this - #- id: go-staticcheck-repo-mod + - id: go-staticcheck-repo-mod - repo: https://github.com/beeper/pre-commit-go rev: v0.4.2 diff --git a/appservice/websocket.go b/appservice/websocket.go index 1e401c53..4f2538bf 100644 --- a/appservice/websocket.go +++ b/appservice/websocket.go @@ -56,7 +56,7 @@ func (wsc *WebsocketCommand) MakeResponse(ok bool, data any) *WebsocketRequest { var prefixMessage string for unwrappedErr != nil { errorData, jsonErr = json.Marshal(unwrappedErr) - if errorData != nil && len(errorData) > 2 && jsonErr == nil { + if len(errorData) > 2 && jsonErr == nil { prefixMessage = strings.Replace(err.Error(), unwrappedErr.Error(), "", 1) prefixMessage = strings.TrimRight(prefixMessage, ": ") break diff --git a/bridgev2/bridgeconfig/permissions.go b/bridgev2/bridgeconfig/permissions.go index 898bf58a..9efe068e 100644 --- a/bridgev2/bridgeconfig/permissions.go +++ b/bridgev2/bridgeconfig/permissions.go @@ -41,10 +41,7 @@ func (pc PermissionConfig) IsConfigured() bool { _, hasExampleDomain := pc["example.com"] _, hasExampleUser := pc["@admin:example.com"] exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain) - if len(pc) <= exampleLen { - return false - } - return true + return len(pc) > exampleLen } func (pc PermissionConfig) Get(userID id.UserID) Permissions { diff --git a/bridgev2/bridgestate.go b/bridgev2/bridgestate.go index a1d3e70b..babbccab 100644 --- a/bridgev2/bridgestate.go +++ b/bridgev2/bridgestate.go @@ -102,9 +102,9 @@ func (bsq *BridgeStateQueue) loop() { } } -func (bsq *BridgeStateQueue) scheduleNotice(ctx context.Context, triggeredBy status.BridgeState) { +func (bsq *BridgeStateQueue) scheduleNotice(triggeredBy status.BridgeState) { log := bsq.login.Log.With().Str("action", "transient disconnect notice").Logger() - ctx = log.WithContext(bsq.bridge.BackgroundCtx) + ctx := log.WithContext(bsq.bridge.BackgroundCtx) if !bsq.waitForTransientDisconnectReconnect(ctx) { return } @@ -135,7 +135,7 @@ func (bsq *BridgeStateQueue) sendNotice(ctx context.Context, state status.Bridge if bsq.firstTransientDisconnect.IsZero() { bsq.firstTransientDisconnect = time.Now() } - go bsq.scheduleNotice(ctx, state) + go bsq.scheduleNotice(state) } return } diff --git a/bridgev2/database/database.go b/bridgev2/database/database.go index 0729cb83..05abddf0 100644 --- a/bridgev2/database/database.go +++ b/bridgev2/database/database.go @@ -7,13 +7,7 @@ package database import ( - "encoding/json" - "reflect" - "strings" - "go.mau.fi/util/dbutil" - "golang.org/x/exp/constraints" - "golang.org/x/exp/maps" "maunium.net/go/mautrix/bridgev2/networkid" @@ -158,55 +152,3 @@ func ensureBridgeIDMatches(ptr *networkid.BridgeID, expected networkid.BridgeID) panic("bridge ID mismatch") } } - -func GetNumberFromMap[T constraints.Integer | constraints.Float](m map[string]any, key string) (T, bool) { - if val, found := m[key]; found { - floatVal, ok := val.(float64) - if ok { - return T(floatVal), true - } - tVal, ok := val.(T) - if ok { - return tVal, true - } - } - return 0, false -} - -func unmarshalMerge(input []byte, data any, extra *map[string]any) error { - err := json.Unmarshal(input, data) - if err != nil { - return err - } - err = json.Unmarshal(input, extra) - if err != nil { - return err - } - if *extra == nil { - *extra = make(map[string]any) - } - return nil -} - -func marshalMerge(data any, extra map[string]any) ([]byte, error) { - if extra == nil { - return json.Marshal(data) - } - merged := make(map[string]any) - maps.Copy(merged, extra) - dataRef := reflect.ValueOf(data).Elem() - dataType := dataRef.Type() - for _, field := range reflect.VisibleFields(dataType) { - parts := strings.Split(field.Tag.Get("json"), ",") - if len(parts) == 0 || len(parts[0]) == 0 || parts[0] == "-" { - continue - } - fieldVal := dataRef.FieldByIndex(field.Index) - if fieldVal.IsZero() { - delete(merged, parts[0]) - } else { - merged[parts[0]] = fieldVal.Interface() - } - } - return json.Marshal(merged) -} diff --git a/bridgev2/matrix/crypto.go b/bridgev2/matrix/crypto.go index f4a2e9a0..7f18f1f5 100644 --- a/bridgev2/matrix/crypto.go +++ b/bridgev2/matrix/crypto.go @@ -38,9 +38,9 @@ func init() { var _ crypto.StateStore = (*sqlstatestore.SQLStateStore)(nil) -var NoSessionFound = crypto.NoSessionFound -var DuplicateMessageIndex = crypto.DuplicateMessageIndex -var UnknownMessageIndex = olm.UnknownMessageIndex +var NoSessionFound = crypto.ErrNoSessionFound +var DuplicateMessageIndex = crypto.ErrDuplicateMessageIndex +var UnknownMessageIndex = olm.ErrUnknownMessageIndex type CryptoHelper struct { bridge *Connector @@ -439,7 +439,7 @@ func (helper *CryptoHelper) Encrypt(ctx context.Context, roomID id.RoomID, evtTy var encrypted *event.EncryptedEventContent encrypted, err = helper.mach.EncryptMegolmEvent(ctx, roomID, evtType, content) if err != nil { - if !errors.Is(err, crypto.SessionExpired) && !errors.Is(err, crypto.SessionNotShared) && !errors.Is(err, crypto.NoGroupSession) { + if !errors.Is(err, crypto.ErrSessionExpired) && !errors.Is(err, crypto.ErrSessionNotShared) && !errors.Is(err, crypto.ErrNoGroupSession) { return } helper.log.Debug().Err(err). diff --git a/bridgev2/matrix/matrix.go b/bridgev2/matrix/matrix.go index 6c94bccc..570ae5f1 100644 --- a/bridgev2/matrix/matrix.go +++ b/bridgev2/matrix/matrix.go @@ -127,6 +127,7 @@ func (br *Connector) waitLongerForSession(ctx context.Context, evt *event.Event, Int("wait_seconds", int(extendedSessionWaitTimeout.Seconds())). Msg("Couldn't find session, requesting keys and waiting longer...") + //lint:ignore SA1019 RequestSession will gracefully request from all devices if DeviceID is blank go br.Crypto.RequestSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID) go br.sendCryptoStatusError(ctx, evt, fmt.Errorf("%w. The bridge will retry for %d seconds", errNoDecryptionKeys, int(extendedSessionWaitTimeout.Seconds())), errorEventID, 1, false) diff --git a/bridgev2/matrix/mxmain/legacymigrate.go b/bridgev2/matrix/mxmain/legacymigrate.go index c8eb820b..97cdeddf 100644 --- a/bridgev2/matrix/mxmain/legacymigrate.go +++ b/bridgev2/matrix/mxmain/legacymigrate.go @@ -135,7 +135,10 @@ func (br *BridgeMain) CheckLegacyDB( } var dbVersion int err = br.DB.QueryRow(ctx, "SELECT version FROM version").Scan(&dbVersion) - if dbVersion < expectedVersion { + if err != nil { + log.Fatal().Err(err).Msg("Failed to get database version") + return + } else if dbVersion < expectedVersion { log.Fatal(). Int("expected_version", expectedVersion). Int("version", dbVersion). diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 43d19380..44e00e64 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -85,10 +85,9 @@ const ( provisioningUserKey provisioningContextKey = iota provisioningUserLoginKey provisioningLoginProcessKey + ProvisioningKeyRequest ) -const ProvisioningKeyRequest = "fi.mau.provision.request" - func (prov *ProvisioningAPI) GetUser(r *http.Request) *bridgev2.User { return r.Context().Value(provisioningUserKey).(*bridgev2.User) } diff --git a/bridgev2/portalbackfill.go b/bridgev2/portalbackfill.go index e8292388..879f07ae 100644 --- a/bridgev2/portalbackfill.go +++ b/bridgev2/portalbackfill.go @@ -410,6 +410,7 @@ func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin if reaction.Timestamp.IsZero() { reaction.Timestamp = msg.Timestamp.Add(10 * time.Millisecond) } + //lint:ignore SA4006 it's a todo targetPart, ok := partMap[*reaction.TargetPart] if !ok { // TODO warning log and/or skip reaction? diff --git a/bridgev2/portalreid.go b/bridgev2/portalreid.go index a25fe820..d1a9d5a6 100644 --- a/bridgev2/portalreid.go +++ b/bridgev2/portalreid.go @@ -96,7 +96,7 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta go func() { _, err := br.Bot.SendState(ctx, sourcePortal.MXID, event.StateTombstone, "", &event.Content{ Parsed: &event.TombstoneEventContent{ - Body: fmt.Sprintf("This room has been merged"), + Body: "This room has been merged", ReplacementRoom: targetPortal.MXID, }, }, time.Now()) diff --git a/client.go b/client.go index 9961e717..b740cba6 100644 --- a/client.go +++ b/client.go @@ -742,7 +742,7 @@ func (cli *Client) executeCompiledRequest( cli.RequestStart(req) startTime := time.Now() res, err := client.Do(req) - duration := time.Now().Sub(startTime) + duration := time.Since(startTime) if res != nil && !dontReadResponse { defer res.Body.Close() } @@ -862,7 +862,7 @@ func (cli *Client) FullSyncRequest(ctx context.Context, req ReqSync) (resp *Resp } start := time.Now() _, err = cli.MakeFullRequest(ctx, fullReq) - duration := time.Now().Sub(start) + duration := time.Since(start) timeout := time.Duration(req.Timeout) * time.Millisecond buffer := 10 * time.Second if req.Since == "" { @@ -966,7 +966,7 @@ func (cli *Client) RegisterGuest(ctx context.Context, req *ReqRegister) (*RespRe // } // token := res.AccessToken func (cli *Client) RegisterDummy(ctx context.Context, req *ReqRegister) (*RespRegister, error) { - res, uia, err := cli.Register(ctx, req) + _, uia, err := cli.Register(ctx, req) if err != nil && uia == nil { return nil, err } else if uia == nil { @@ -975,7 +975,7 @@ func (cli *Client) RegisterDummy(ctx context.Context, req *ReqRegister) (*RespRe return nil, errors.New("server does not support m.login.dummy") } req.Auth = BaseAuthData{Type: AuthTypeDummy, Session: uia.Session} - res, _, err = cli.Register(ctx, req) + res, _, err := cli.Register(ctx, req) if err != nil { return nil, err } @@ -1751,6 +1751,8 @@ func parseRoomStateArray(req *http.Request, res *http.Response, responseJSON any return nil, nil } +type RoomStateMap = map[event.Type]map[string]*event.Event + // State gets all state in a room. // See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidstate func (cli *Client) State(ctx context.Context, roomID id.RoomID) (stateMap RoomStateMap, err error) { diff --git a/crypto/attachment/attachments.go b/crypto/attachment/attachments.go index 155cca5c..727aacbf 100644 --- a/crypto/attachment/attachments.go +++ b/crypto/attachment/attachments.go @@ -21,13 +21,24 @@ import ( ) var ( - HashMismatch = errors.New("mismatching SHA-256 digest") - UnsupportedVersion = errors.New("unsupported Matrix file encryption version") - UnsupportedAlgorithm = errors.New("unsupported JWK encryption algorithm") - InvalidKey = errors.New("failed to decode key") - InvalidInitVector = errors.New("failed to decode initialization vector") - InvalidHash = errors.New("failed to decode SHA-256 hash") - ReaderClosed = errors.New("encrypting reader was already closed") + ErrHashMismatch = errors.New("mismatching SHA-256 digest") + ErrUnsupportedVersion = errors.New("unsupported Matrix file encryption version") + ErrUnsupportedAlgorithm = errors.New("unsupported JWK encryption algorithm") + ErrInvalidKey = errors.New("failed to decode key") + ErrInvalidInitVector = errors.New("failed to decode initialization vector") + ErrInvalidHash = errors.New("failed to decode SHA-256 hash") + ErrReaderClosed = errors.New("encrypting reader was already closed") +) + +// Deprecated: use variables prefixed with Err +var ( + HashMismatch = ErrHashMismatch + UnsupportedVersion = ErrUnsupportedVersion + UnsupportedAlgorithm = ErrUnsupportedAlgorithm + InvalidKey = ErrInvalidKey + InvalidInitVector = ErrInvalidInitVector + InvalidHash = ErrInvalidHash + ReaderClosed = ErrReaderClosed ) var ( @@ -85,25 +96,25 @@ func (ef *EncryptedFile) decodeKeys(includeHash bool) error { if ef.decoded != nil { return nil } else if len(ef.Key.Key) != keyBase64Length { - return InvalidKey + return ErrInvalidKey } else if len(ef.InitVector) != ivBase64Length { - return InvalidInitVector + return ErrInvalidInitVector } else if includeHash && len(ef.Hashes.SHA256) != hashBase64Length { - return InvalidHash + return ErrInvalidHash } ef.decoded = &decodedKeys{} _, err := base64.RawURLEncoding.Decode(ef.decoded.key[:], []byte(ef.Key.Key)) if err != nil { - return InvalidKey + return ErrInvalidKey } _, err = base64.RawStdEncoding.Decode(ef.decoded.iv[:], []byte(ef.InitVector)) if err != nil { - return InvalidInitVector + return ErrInvalidInitVector } if includeHash { _, err = base64.RawStdEncoding.Decode(ef.decoded.sha256[:], []byte(ef.Hashes.SHA256)) if err != nil { - return InvalidHash + return ErrInvalidHash } } return nil @@ -179,7 +190,7 @@ var _ io.ReadSeekCloser = (*encryptingReader)(nil) func (r *encryptingReader) Seek(offset int64, whence int) (int64, error) { if r.closed { - return 0, ReaderClosed + return 0, ErrReaderClosed } if offset != 0 || whence != io.SeekStart { return 0, fmt.Errorf("attachments.EncryptStream: only seeking to the beginning is supported") @@ -200,7 +211,7 @@ func (r *encryptingReader) Seek(offset int64, whence int) (int64, error) { func (r *encryptingReader) Read(dst []byte) (n int, err error) { if r.closed { - return 0, ReaderClosed + return 0, ErrReaderClosed } else if r.isDecrypting && r.file.decoded == nil { if err = r.file.PrepareForDecryption(); err != nil { return @@ -224,7 +235,7 @@ func (r *encryptingReader) Close() (err error) { } if r.isDecrypting { if !hmac.Equal(r.hash.Sum(nil), r.file.decoded.sha256[:]) { - return HashMismatch + return ErrHashMismatch } } else { r.file.Hashes.SHA256 = base64.RawStdEncoding.EncodeToString(r.hash.Sum(nil)) @@ -265,9 +276,9 @@ func (ef *EncryptedFile) Decrypt(ciphertext []byte) ([]byte, error) { // DecryptInPlace will always call this automatically, so calling this manually is not necessary when using that function. func (ef *EncryptedFile) PrepareForDecryption() error { if ef.Version != "v2" { - return UnsupportedVersion + return ErrUnsupportedVersion } else if ef.Key.Algorithm != "A256CTR" { - return UnsupportedAlgorithm + return ErrUnsupportedAlgorithm } else if err := ef.decodeKeys(true); err != nil { return err } @@ -281,7 +292,7 @@ func (ef *EncryptedFile) DecryptInPlace(data []byte) error { } dataHash := sha256.Sum256(data) if !hmac.Equal(ef.decoded.sha256[:], dataHash[:]) { - return HashMismatch + return ErrHashMismatch } utils.XorA256CTR(data, ef.decoded.key, ef.decoded.iv) return nil diff --git a/crypto/attachment/attachments_test.go b/crypto/attachment/attachments_test.go index d7f1394a..9fe929ab 100644 --- a/crypto/attachment/attachments_test.go +++ b/crypto/attachment/attachments_test.go @@ -53,33 +53,33 @@ func TestUnsupportedVersion(t *testing.T) { file := parseHelloWorld() file.Version = "foo" err := file.DecryptInPlace([]byte(helloWorldCiphertext)) - assert.ErrorIs(t, err, UnsupportedVersion) + assert.ErrorIs(t, err, ErrUnsupportedVersion) } func TestUnsupportedAlgorithm(t *testing.T) { file := parseHelloWorld() file.Key.Algorithm = "bar" err := file.DecryptInPlace([]byte(helloWorldCiphertext)) - assert.ErrorIs(t, err, UnsupportedAlgorithm) + assert.ErrorIs(t, err, ErrUnsupportedAlgorithm) } func TestHashMismatch(t *testing.T) { file := parseHelloWorld() file.Hashes.SHA256 = base64.RawStdEncoding.EncodeToString([]byte(random32Bytes)) err := file.DecryptInPlace([]byte(helloWorldCiphertext)) - assert.ErrorIs(t, err, HashMismatch) + assert.ErrorIs(t, err, ErrHashMismatch) } func TestTooLongHash(t *testing.T) { file := parseHelloWorld() file.Hashes.SHA256 = "TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVlciBhZGlwaXNjaW5nIGVsaXQuIFNlZCBwb3N1ZXJlIGludGVyZHVtIHNlbS4gUXVpc3F1ZSBsaWd1bGEgZXJvcyB1bGxhbWNvcnBlciBxdWlzLCBsYWNpbmlhIHF1aXMgZmFjaWxpc2lzIHNlZCBzYXBpZW4uCg" err := file.DecryptInPlace([]byte(helloWorldCiphertext)) - assert.ErrorIs(t, err, InvalidHash) + assert.ErrorIs(t, err, ErrInvalidHash) } func TestTooShortHash(t *testing.T) { file := parseHelloWorld() file.Hashes.SHA256 = "5/Gy1JftyyQ" err := file.DecryptInPlace([]byte(helloWorldCiphertext)) - assert.ErrorIs(t, err, InvalidHash) + assert.ErrorIs(t, err, ErrInvalidHash) } diff --git a/crypto/cross_sign_pubkey.go b/crypto/cross_sign_pubkey.go index f85d1ea3..223fc7b5 100644 --- a/crypto/cross_sign_pubkey.go +++ b/crypto/cross_sign_pubkey.go @@ -63,8 +63,8 @@ func (mach *OlmMachine) GetCrossSigningPublicKeys(ctx context.Context, userID id if len(dbKeys) > 0 { masterKey, ok := dbKeys[id.XSUsageMaster] if ok { - selfSigning, _ := dbKeys[id.XSUsageSelfSigning] - userSigning, _ := dbKeys[id.XSUsageUserSigning] + selfSigning := dbKeys[id.XSUsageSelfSigning] + userSigning := dbKeys[id.XSUsageUserSigning] return &CrossSigningPublicKeysCache{ MasterKey: masterKey.Key, SelfSigningKey: selfSigning.Key, diff --git a/crypto/cross_sign_store.go b/crypto/cross_sign_store.go index d30b7e32..57406b11 100644 --- a/crypto/cross_sign_store.go +++ b/crypto/cross_sign_store.go @@ -26,24 +26,22 @@ func (mach *OlmMachine) storeCrossSigningKeys(ctx context.Context, crossSigningK log.Error().Err(err). Msg("Error fetching current cross-signing keys of user") } - if currentKeys != nil { - for curKeyUsage, curKey := range currentKeys { - log := log.With().Stringer("old_key", curKey.Key).Str("old_key_usage", string(curKeyUsage)).Logger() - // got a new key with the same usage as an existing key - for _, newKeyUsage := range userKeys.Usage { - if newKeyUsage == curKeyUsage { - if _, ok := userKeys.Keys[id.NewKeyID(id.KeyAlgorithmEd25519, curKey.Key.String())]; !ok { - // old key is not in the new key map, so we drop signatures made by it - if count, err := mach.CryptoStore.DropSignaturesByKey(ctx, userID, curKey.Key); err != nil { - log.Error().Err(err).Msg("Error deleting old signatures made by user") - } else { - log.Debug(). - Int64("signature_count", count). - Msg("Dropped signatures made by old key as it has been replaced") - } + for curKeyUsage, curKey := range currentKeys { + log := log.With().Stringer("old_key", curKey.Key).Str("old_key_usage", string(curKeyUsage)).Logger() + // got a new key with the same usage as an existing key + for _, newKeyUsage := range userKeys.Usage { + if newKeyUsage == curKeyUsage { + if _, ok := userKeys.Keys[id.NewKeyID(id.KeyAlgorithmEd25519, curKey.Key.String())]; !ok { + // old key is not in the new key map, so we drop signatures made by it + if count, err := mach.CryptoStore.DropSignaturesByKey(ctx, userID, curKey.Key); err != nil { + log.Error().Err(err).Msg("Error deleting old signatures made by user") + } else { + log.Debug(). + Int64("signature_count", count). + Msg("Dropped signatures made by old key as it has been replaced") } - break } + break } } } diff --git a/crypto/cryptohelper/cryptohelper.go b/crypto/cryptohelper/cryptohelper.go index 1939ea79..b62dc128 100644 --- a/crypto/cryptohelper/cryptohelper.go +++ b/crypto/cryptohelper/cryptohelper.go @@ -278,7 +278,7 @@ func (helper *CryptoHelper) verifyDeviceKeysOnServer(ctx context.Context) error } } -var NoSessionFound = crypto.NoSessionFound +var NoSessionFound = crypto.ErrNoSessionFound const initialSessionWaitTimeout = 3 * time.Second const extendedSessionWaitTimeout = 22 * time.Second @@ -371,6 +371,7 @@ func (helper *CryptoHelper) waitLongerForSession(ctx context.Context, evt *event content := evt.Content.AsEncrypted() log.Debug().Int("wait_seconds", int(extendedSessionWaitTimeout.Seconds())).Msg("Couldn't find session, requesting keys and waiting longer...") + //lint:ignore SA1019 RequestSession will gracefully request from all devices if DeviceID is blank go helper.RequestSession(context.TODO(), evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID) if !helper.mach.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, extendedSessionWaitTimeout) { @@ -418,7 +419,7 @@ func (helper *CryptoHelper) EncryptWithStateKey(ctx context.Context, roomID id.R defer helper.lock.RUnlock() encrypted, err = helper.mach.EncryptMegolmEventWithStateKey(ctx, roomID, evtType, stateKey, content) if err != nil { - if !errors.Is(err, crypto.SessionExpired) && err != crypto.NoGroupSession && !errors.Is(err, crypto.SessionNotShared) { + if !errors.Is(err, crypto.ErrSessionExpired) && err != crypto.ErrNoGroupSession && !errors.Is(err, crypto.ErrSessionNotShared) { return } helper.log.Debug(). diff --git a/crypto/decryptmegolm.go b/crypto/decryptmegolm.go index 47279474..d8b419ab 100644 --- a/crypto/decryptmegolm.go +++ b/crypto/decryptmegolm.go @@ -24,13 +24,24 @@ import ( ) var ( - IncorrectEncryptedContentType = errors.New("event content is not instance of *event.EncryptedEventContent") - NoSessionFound = errors.New("failed to decrypt megolm event: no session with given ID found") - DuplicateMessageIndex = errors.New("duplicate megolm message index") - WrongRoom = errors.New("encrypted megolm event is not intended for this room") - DeviceKeyMismatch = errors.New("device keys in event and verified device info do not match") - SenderKeyMismatch = errors.New("sender keys in content and megolm session do not match") - RatchetError = errors.New("failed to ratchet session after use") + ErrIncorrectEncryptedContentType = errors.New("event content is not instance of *event.EncryptedEventContent") + ErrNoSessionFound = errors.New("failed to decrypt megolm event: no session with given ID found") + ErrDuplicateMessageIndex = errors.New("duplicate megolm message index") + ErrWrongRoom = errors.New("encrypted megolm event is not intended for this room") + ErrDeviceKeyMismatch = errors.New("device keys in event and verified device info do not match") + ErrSenderKeyMismatch = errors.New("sender keys in content and megolm session do not match") + ErrRatchetError = errors.New("failed to ratchet session after use") +) + +// Deprecated: use variables prefixed with Err +var ( + IncorrectEncryptedContentType = ErrIncorrectEncryptedContentType + NoSessionFound = ErrNoSessionFound + DuplicateMessageIndex = ErrDuplicateMessageIndex + WrongRoom = ErrWrongRoom + DeviceKeyMismatch = ErrDeviceKeyMismatch + SenderKeyMismatch = ErrSenderKeyMismatch + RatchetError = ErrRatchetError ) type megolmEvent struct { @@ -49,9 +60,9 @@ var ( func (mach *OlmMachine) DecryptMegolmEvent(ctx context.Context, evt *event.Event) (*event.Event, error) { content, ok := evt.Content.Parsed.(*event.EncryptedEventContent) if !ok { - return nil, IncorrectEncryptedContentType + return nil, ErrIncorrectEncryptedContentType } else if content.Algorithm != id.AlgorithmMegolmV1 { - return nil, UnsupportedAlgorithm + return nil, ErrUnsupportedAlgorithm } log := mach.machOrContextLog(ctx).With(). Str("action", "decrypt megolm event"). @@ -97,7 +108,7 @@ func (mach *OlmMachine) DecryptMegolmEvent(ctx context.Context, evt *event.Event Msg("Couldn't resolve trust level of session: sent by unknown device") trustLevel = id.TrustStateUnknownDevice } else if device.SigningKey != sess.SigningKey || device.IdentityKey != sess.SenderKey { - return nil, DeviceKeyMismatch + return nil, ErrDeviceKeyMismatch } else { trustLevel, err = mach.ResolveTrustContext(ctx, device) if err != nil { @@ -147,7 +158,7 @@ func (mach *OlmMachine) DecryptMegolmEvent(ctx context.Context, evt *event.Event if err != nil { return nil, fmt.Errorf("failed to parse megolm payload: %w", err) } else if megolmEvt.RoomID != encryptionRoomID { - return nil, WrongRoom + return nil, ErrWrongRoom } if evt.StateKey != nil && megolmEvt.StateKey != nil && mach.AllowEncryptedState { megolmEvt.Type.Class = event.StateEventType @@ -201,19 +212,19 @@ func (mach *OlmMachine) checkUndecryptableMessageIndexDuplication(ctx context.Co messageIndex, decodeErr := ParseMegolmMessageIndex(content.MegolmCiphertext) if decodeErr != nil { log.Warn().Err(decodeErr).Msg("Failed to parse message index to check if it's a duplicate for message that failed to decrypt") - return 0, fmt.Errorf("%w (also failed to parse message index)", olm.UnknownMessageIndex) + return 0, fmt.Errorf("%w (also failed to parse message index)", olm.ErrUnknownMessageIndex) } firstKnown := sess.Internal.FirstKnownIndex() log = log.With().Uint("message_index", messageIndex).Uint32("first_known_index", firstKnown).Logger() if ok, err := mach.CryptoStore.ValidateMessageIndex(ctx, sess.SenderKey, content.SessionID, evt.ID, messageIndex, evt.Timestamp); err != nil { log.Debug().Err(err).Msg("Failed to check if message index is duplicate") - return messageIndex, fmt.Errorf("%w (failed to check if index is duplicate; received: %d, earliest known: %d)", olm.UnknownMessageIndex, messageIndex, firstKnown) + return messageIndex, fmt.Errorf("%w (failed to check if index is duplicate; received: %d, earliest known: %d)", olm.ErrUnknownMessageIndex, messageIndex, firstKnown) } else if !ok { log.Debug().Msg("Failed to decrypt message due to unknown index and found duplicate") - return messageIndex, fmt.Errorf("%w %d (also failed to decrypt because earliest known index is %d)", DuplicateMessageIndex, messageIndex, firstKnown) + return messageIndex, fmt.Errorf("%w %d (also failed to decrypt because earliest known index is %d)", ErrDuplicateMessageIndex, messageIndex, firstKnown) } log.Debug().Msg("Failed to decrypt message due to unknown index, but index is not duplicate") - return messageIndex, fmt.Errorf("%w (not duplicate index; received: %d, earliest known: %d)", olm.UnknownMessageIndex, messageIndex, firstKnown) + return messageIndex, fmt.Errorf("%w (not duplicate index; received: %d, earliest known: %d)", olm.ErrUnknownMessageIndex, messageIndex, firstKnown) } func (mach *OlmMachine) actuallyDecryptMegolmEvent(ctx context.Context, evt *event.Event, encryptionRoomID id.RoomID, content *event.EncryptedEventContent) (*InboundGroupSession, []byte, uint, error) { @@ -224,13 +235,13 @@ func (mach *OlmMachine) actuallyDecryptMegolmEvent(ctx context.Context, evt *eve if err != nil { return nil, nil, 0, fmt.Errorf("failed to get group session: %w", err) } else if sess == nil { - return nil, nil, 0, fmt.Errorf("%w (ID %s)", NoSessionFound, content.SessionID) + return nil, nil, 0, fmt.Errorf("%w (ID %s)", ErrNoSessionFound, content.SessionID) } else if content.SenderKey != "" && content.SenderKey != sess.SenderKey { - return sess, nil, 0, SenderKeyMismatch + return sess, nil, 0, ErrSenderKeyMismatch } plaintext, messageIndex, err := sess.Internal.Decrypt(content.MegolmCiphertext) if err != nil { - if errors.Is(err, olm.UnknownMessageIndex) && mach.RatchetKeysOnDecrypt { + if errors.Is(err, olm.ErrUnknownMessageIndex) && mach.RatchetKeysOnDecrypt { messageIndex, err = mach.checkUndecryptableMessageIndexDuplication(ctx, sess, evt, content) return sess, nil, messageIndex, fmt.Errorf("failed to decrypt megolm event: %w", err) } @@ -238,7 +249,7 @@ func (mach *OlmMachine) actuallyDecryptMegolmEvent(ctx context.Context, evt *eve } else if ok, err := mach.CryptoStore.ValidateMessageIndex(ctx, sess.SenderKey, content.SessionID, evt.ID, messageIndex, evt.Timestamp); err != nil { return sess, nil, messageIndex, fmt.Errorf("failed to check if message index is duplicate: %w", err) } else if !ok { - return sess, nil, messageIndex, fmt.Errorf("%w %d", DuplicateMessageIndex, messageIndex) + return sess, nil, messageIndex, fmt.Errorf("%w %d", ErrDuplicateMessageIndex, messageIndex) } // Normal clients don't care about tracking the ratchet state, so let them bypass the rest of the function @@ -290,24 +301,24 @@ func (mach *OlmMachine) actuallyDecryptMegolmEvent(ctx context.Context, evt *eve err = mach.CryptoStore.RedactGroupSession(ctx, sess.RoomID, sess.ID(), "maximum messages reached") if err != nil { log.Err(err).Msg("Failed to delete fully used session") - return sess, plaintext, messageIndex, RatchetError + return sess, plaintext, messageIndex, ErrRatchetError } else { log.Info().Msg("Deleted fully used session") } } else if ratchetCurrentIndex < ratchetTargetIndex && mach.RatchetKeysOnDecrypt { if err = sess.RatchetTo(ratchetTargetIndex); err != nil { log.Err(err).Msg("Failed to ratchet session") - return sess, plaintext, messageIndex, RatchetError + return sess, plaintext, messageIndex, ErrRatchetError } else if err = mach.CryptoStore.PutGroupSession(ctx, sess); err != nil { log.Err(err).Msg("Failed to store ratcheted session") - return sess, plaintext, messageIndex, RatchetError + return sess, plaintext, messageIndex, ErrRatchetError } else { log.Info().Msg("Ratcheted session forward") } } else if didModify { if err = mach.CryptoStore.PutGroupSession(ctx, sess); err != nil { log.Err(err).Msg("Failed to store updated ratchet safety data") - return sess, plaintext, messageIndex, RatchetError + return sess, plaintext, messageIndex, ErrRatchetError } else { log.Debug().Msg("Ratchet safety data changed (ratchet state didn't change)") } diff --git a/crypto/decryptolm.go b/crypto/decryptolm.go index 30cc4cfe..cd02726d 100644 --- a/crypto/decryptolm.go +++ b/crypto/decryptolm.go @@ -26,15 +26,27 @@ import ( ) var ( - UnsupportedAlgorithm = errors.New("unsupported event encryption algorithm") - NotEncryptedForMe = errors.New("olm event doesn't contain ciphertext for this device") - UnsupportedOlmMessageType = errors.New("unsupported olm message type") - DecryptionFailedWithMatchingSession = errors.New("decryption failed with matching session") - DecryptionFailedForNormalMessage = errors.New("decryption failed for normal message") - SenderMismatch = errors.New("mismatched sender in olm payload") - RecipientMismatch = errors.New("mismatched recipient in olm payload") - RecipientKeyMismatch = errors.New("mismatched recipient key in olm payload") - ErrDuplicateMessage = errors.New("duplicate olm message") + ErrUnsupportedAlgorithm = errors.New("unsupported event encryption algorithm") + ErrNotEncryptedForMe = errors.New("olm event doesn't contain ciphertext for this device") + ErrUnsupportedOlmMessageType = errors.New("unsupported olm message type") + ErrDecryptionFailedWithMatchingSession = errors.New("decryption failed with matching session") + ErrDecryptionFailedForNormalMessage = errors.New("decryption failed for normal message") + ErrSenderMismatch = errors.New("mismatched sender in olm payload") + ErrRecipientMismatch = errors.New("mismatched recipient in olm payload") + ErrRecipientKeyMismatch = errors.New("mismatched recipient key in olm payload") + ErrDuplicateMessage = errors.New("duplicate olm message") +) + +// Deprecated: use variables prefixed with Err +var ( + UnsupportedAlgorithm = ErrUnsupportedAlgorithm + NotEncryptedForMe = ErrNotEncryptedForMe + UnsupportedOlmMessageType = ErrUnsupportedOlmMessageType + DecryptionFailedWithMatchingSession = ErrDecryptionFailedWithMatchingSession + DecryptionFailedForNormalMessage = ErrDecryptionFailedForNormalMessage + SenderMismatch = ErrSenderMismatch + RecipientMismatch = ErrRecipientMismatch + RecipientKeyMismatch = ErrRecipientKeyMismatch ) // DecryptedOlmEvent represents an event that was decrypted from an event encrypted with the m.olm.v1.curve25519-aes-sha2 algorithm. @@ -56,13 +68,13 @@ type DecryptedOlmEvent struct { func (mach *OlmMachine) decryptOlmEvent(ctx context.Context, evt *event.Event) (*DecryptedOlmEvent, error) { content, ok := evt.Content.Parsed.(*event.EncryptedEventContent) if !ok { - return nil, IncorrectEncryptedContentType + return nil, ErrIncorrectEncryptedContentType } else if content.Algorithm != id.AlgorithmOlmV1 { - return nil, UnsupportedAlgorithm + return nil, ErrUnsupportedAlgorithm } ownContent, ok := content.OlmCiphertext[mach.account.IdentityKey()] if !ok { - return nil, NotEncryptedForMe + return nil, ErrNotEncryptedForMe } decrypted, err := mach.decryptAndParseOlmCiphertext(ctx, evt, content.SenderKey, ownContent.Type, ownContent.Body) if err != nil { @@ -78,7 +90,7 @@ type OlmEventKeys struct { func (mach *OlmMachine) decryptAndParseOlmCiphertext(ctx context.Context, evt *event.Event, senderKey id.SenderKey, olmType id.OlmMsgType, ciphertext string) (*DecryptedOlmEvent, error) { if olmType != id.OlmMsgTypePreKey && olmType != id.OlmMsgTypeMsg { - return nil, UnsupportedOlmMessageType + return nil, ErrUnsupportedOlmMessageType } log := mach.machOrContextLog(ctx).With(). @@ -102,11 +114,11 @@ func (mach *OlmMachine) decryptAndParseOlmCiphertext(ctx context.Context, evt *e } olmEvt.Type.Class = evt.Type.Class if evt.Sender != olmEvt.Sender { - return nil, SenderMismatch + return nil, ErrSenderMismatch } else if mach.Client.UserID != olmEvt.Recipient { - return nil, RecipientMismatch + return nil, ErrRecipientMismatch } else if mach.account.SigningKey() != olmEvt.RecipientKeys.Ed25519 { - return nil, RecipientKeyMismatch + return nil, ErrRecipientKeyMismatch } if len(olmEvt.Content.VeryRaw) > 0 { @@ -151,7 +163,7 @@ func (mach *OlmMachine) tryDecryptOlmCiphertext(ctx context.Context, sender id.U plaintext, err := mach.tryDecryptOlmCiphertextWithExistingSession(ctx, senderKey, olmType, ciphertext, ciphertextHash) if err != nil { - if err == DecryptionFailedWithMatchingSession { + if err == ErrDecryptionFailedWithMatchingSession { log.Warn().Msg("Found matching session, but decryption failed") go mach.unwedgeDevice(log, sender, senderKey) } @@ -169,10 +181,10 @@ func (mach *OlmMachine) tryDecryptOlmCiphertext(ctx context.Context, sender id.U // if it isn't one at this point in time anymore, so return early. if olmType != id.OlmMsgTypePreKey { go mach.unwedgeDevice(log, sender, senderKey) - return nil, DecryptionFailedForNormalMessage + return nil, ErrDecryptionFailedForNormalMessage } - accountBackup, err := mach.account.Internal.Pickle([]byte("tmp")) + accountBackup, _ := mach.account.Internal.Pickle([]byte("tmp")) log.Trace().Msg("Trying to create inbound session") endTimeTrace = mach.timeTrace(ctx, "creating inbound olm session", time.Second) session, err := mach.createInboundSession(ctx, senderKey, ciphertext) @@ -302,7 +314,7 @@ func (mach *OlmMachine) tryDecryptOlmCiphertextWithExistingSession( Str("session_description", session.Describe()). Msg("Failed to decrypt olm message") if olmType == id.OlmMsgTypePreKey { - return nil, DecryptionFailedWithMatchingSession + return nil, ErrDecryptionFailedWithMatchingSession } } else { endTimeTrace = mach.timeTrace(ctx, "updating session in database", time.Second) @@ -345,7 +357,7 @@ func (mach *OlmMachine) unwedgeDevice(log zerolog.Logger, sender id.UserID, send ctx := log.WithContext(mach.backgroundCtx) mach.recentlyUnwedgedLock.Lock() prevUnwedge, ok := mach.recentlyUnwedged[senderKey] - delta := time.Now().Sub(prevUnwedge) + delta := time.Since(prevUnwedge) if ok && delta < MinUnwedgeInterval { log.Debug(). Str("previous_recreation", delta.String()). diff --git a/crypto/devicelist.go b/crypto/devicelist.go index 61a22522..f0d2b129 100644 --- a/crypto/devicelist.go +++ b/crypto/devicelist.go @@ -22,14 +22,23 @@ import ( ) var ( - MismatchingDeviceID = errors.New("mismatching device ID in parameter and keys object") - MismatchingUserID = errors.New("mismatching user ID in parameter and keys object") - MismatchingSigningKey = errors.New("received update for device with different signing key") - NoSigningKeyFound = errors.New("didn't find ed25519 signing key") - NoIdentityKeyFound = errors.New("didn't find curve25519 identity key") - InvalidKeySignature = errors.New("invalid signature on device keys") + ErrMismatchingDeviceID = errors.New("mismatching device ID in parameter and keys object") + ErrMismatchingUserID = errors.New("mismatching user ID in parameter and keys object") + ErrMismatchingSigningKey = errors.New("received update for device with different signing key") + ErrNoSigningKeyFound = errors.New("didn't find ed25519 signing key") + ErrNoIdentityKeyFound = errors.New("didn't find curve25519 identity key") + ErrInvalidKeySignature = errors.New("invalid signature on device keys") + ErrUserNotTracked = errors.New("user is not tracked") +) - ErrUserNotTracked = errors.New("user is not tracked") +// Deprecated: use variables prefixed with Err +var ( + MismatchingDeviceID = ErrMismatchingDeviceID + MismatchingUserID = ErrMismatchingUserID + MismatchingSigningKey = ErrMismatchingSigningKey + NoSigningKeyFound = ErrNoSigningKeyFound + NoIdentityKeyFound = ErrNoIdentityKeyFound + InvalidKeySignature = ErrInvalidKeySignature ) func (mach *OlmMachine) LoadDevices(ctx context.Context, user id.UserID) (keys map[id.DeviceID]*id.Device) { @@ -312,28 +321,28 @@ func (mach *OlmMachine) OnDevicesChanged(ctx context.Context, userID id.UserID) func (mach *OlmMachine) validateDevice(userID id.UserID, deviceID id.DeviceID, deviceKeys mautrix.DeviceKeys, existing *id.Device) (*id.Device, error) { if deviceID != deviceKeys.DeviceID { - return nil, fmt.Errorf("%w (expected %s, got %s)", MismatchingDeviceID, deviceID, deviceKeys.DeviceID) + return nil, fmt.Errorf("%w (expected %s, got %s)", ErrMismatchingDeviceID, deviceID, deviceKeys.DeviceID) } else if userID != deviceKeys.UserID { - return nil, fmt.Errorf("%w (expected %s, got %s)", MismatchingUserID, userID, deviceKeys.UserID) + return nil, fmt.Errorf("%w (expected %s, got %s)", ErrMismatchingUserID, userID, deviceKeys.UserID) } signingKey := deviceKeys.Keys.GetEd25519(deviceID) identityKey := deviceKeys.Keys.GetCurve25519(deviceID) if signingKey == "" { - return nil, NoSigningKeyFound + return nil, ErrNoSigningKeyFound } else if identityKey == "" { - return nil, NoIdentityKeyFound + return nil, ErrNoIdentityKeyFound } if existing != nil && existing.SigningKey != signingKey { - return existing, fmt.Errorf("%w (expected %s, got %s)", MismatchingSigningKey, existing.SigningKey, signingKey) + return existing, fmt.Errorf("%w (expected %s, got %s)", ErrMismatchingSigningKey, existing.SigningKey, signingKey) } ok, err := signatures.VerifySignatureJSON(deviceKeys, userID, deviceID.String(), signingKey) if err != nil { return existing, fmt.Errorf("failed to verify signature: %w", err) } else if !ok { - return existing, InvalidKeySignature + return existing, ErrInvalidKeySignature } name, ok := deviceKeys.Unsigned["device_display_name"].(string) diff --git a/crypto/encryptmegolm.go b/crypto/encryptmegolm.go index b3d19618..ea97f767 100644 --- a/crypto/encryptmegolm.go +++ b/crypto/encryptmegolm.go @@ -25,7 +25,12 @@ import ( ) var ( - NoGroupSession = errors.New("no group session created") + ErrNoGroupSession = errors.New("no group session created") +) + +// Deprecated: use variables prefixed with Err +var ( + NoGroupSession = ErrNoGroupSession ) func getRawJSON[T any](content json.RawMessage, path ...string) *T { @@ -82,7 +87,7 @@ type rawMegolmEvent struct { // IsShareError returns true if the error is caused by the lack of an outgoing megolm session and can be solved with OlmMachine.ShareGroupSession func IsShareError(err error) bool { - return err == SessionExpired || err == SessionNotShared || err == NoGroupSession + return err == ErrSessionExpired || err == ErrSessionNotShared || err == ErrNoGroupSession } func ParseMegolmMessageIndex(ciphertext []byte) (uint, error) { @@ -120,7 +125,7 @@ func (mach *OlmMachine) EncryptMegolmEventWithStateKey(ctx context.Context, room if err != nil { return nil, fmt.Errorf("failed to get outbound group session: %w", err) } else if session == nil { - return nil, NoGroupSession + return nil, ErrNoGroupSession } plaintext, err := json.Marshal(&rawMegolmEvent{ RoomID: roomID, diff --git a/crypto/goolm/account/account.go b/crypto/goolm/account/account.go index 4da08a73..b48843a4 100644 --- a/crypto/goolm/account/account.go +++ b/crypto/goolm/account/account.go @@ -334,7 +334,7 @@ func (a *Account) UnpickleLibOlm(buf []byte) error { if err != nil { return err } else if pickledVersion != accountPickleVersionLibOLM && pickledVersion != 3 && pickledVersion != 2 { - return fmt.Errorf("unpickle account: %w (found version %d)", olm.ErrBadVersion, pickledVersion) + return fmt.Errorf("unpickle account: %w (found version %d)", olm.ErrUnknownOlmPickleVersion, pickledVersion) } else if err = a.IdKeys.Ed25519.UnpickleLibOlm(decoder); err != nil { // read the ed25519 key pair return err } else if err = a.IdKeys.Curve25519.UnpickleLibOlm(decoder); err != nil { // read curve25519 key pair diff --git a/crypto/goolm/account/account_test.go b/crypto/goolm/account/account_test.go index e1c9b452..d0dec5f0 100644 --- a/crypto/goolm/account/account_test.go +++ b/crypto/goolm/account/account_test.go @@ -124,7 +124,7 @@ func TestOldAccountPickle(t *testing.T) { account, err := account.NewAccount() assert.NoError(t, err) err = account.Unpickle(pickled, pickleKey) - assert.ErrorIs(t, err, olm.ErrBadVersion) + assert.ErrorIs(t, err, olm.ErrUnknownOlmPickleVersion) } func TestLoopback(t *testing.T) { diff --git a/crypto/goolm/goolmbase64/base64.go b/crypto/goolm/goolmbase64/base64.go index 061a052a..58ee26f7 100644 --- a/crypto/goolm/goolmbase64/base64.go +++ b/crypto/goolm/goolmbase64/base64.go @@ -4,7 +4,8 @@ import ( "encoding/base64" ) -// Deprecated: base64.RawStdEncoding should be used directly +// These methods should only be used for raw byte operations, never with string conversion + func Decode(input []byte) ([]byte, error) { decoded := make([]byte, base64.RawStdEncoding.DecodedLen(len(input))) writtenBytes, err := base64.RawStdEncoding.Decode(decoded, input) @@ -14,7 +15,6 @@ func Decode(input []byte) ([]byte, error) { return decoded[:writtenBytes], nil } -// Deprecated: base64.RawStdEncoding should be used directly func Encode(input []byte) []byte { encoded := make([]byte, base64.RawStdEncoding.EncodedLen(len(input))) base64.RawStdEncoding.Encode(encoded, input) diff --git a/crypto/goolm/libolmpickle/picklejson.go b/crypto/goolm/libolmpickle/picklejson.go index 308e472c..f765391f 100644 --- a/crypto/goolm/libolmpickle/picklejson.go +++ b/crypto/goolm/libolmpickle/picklejson.go @@ -50,7 +50,7 @@ func UnpickleAsJSON(object any, pickled, key []byte, pickleVersion byte) error { } } if decrypted[0] != pickleVersion { - return fmt.Errorf("unpickle: %w", olm.ErrWrongPickleVersion) + return fmt.Errorf("unpickle: %w", olm.ErrUnknownJSONPickleVersion) } err = json.Unmarshal(decrypted[1:], object) if err != nil { diff --git a/crypto/goolm/message/session_export.go b/crypto/goolm/message/session_export.go index 956868b2..d58dbb21 100644 --- a/crypto/goolm/message/session_export.go +++ b/crypto/goolm/message/session_export.go @@ -35,7 +35,7 @@ func (s *MegolmSessionExport) Decode(input []byte) error { return fmt.Errorf("decrypt: %w", olm.ErrBadInput) } if input[0] != sessionExportVersion { - return fmt.Errorf("decrypt: %w", olm.ErrBadVersion) + return fmt.Errorf("decrypt: %w", olm.ErrUnknownOlmPickleVersion) } s.Counter = binary.BigEndian.Uint32(input[1:5]) copy(s.RatchetData[:], input[5:133]) diff --git a/crypto/goolm/message/session_sharing.go b/crypto/goolm/message/session_sharing.go index 16240945..d04ef15a 100644 --- a/crypto/goolm/message/session_sharing.go +++ b/crypto/goolm/message/session_sharing.go @@ -42,7 +42,7 @@ func (s *MegolmSessionSharing) VerifyAndDecode(input []byte) error { } s.PublicKey = publicKey if input[0] != sessionSharingVersion { - return fmt.Errorf("verify: %w", olm.ErrBadVersion) + return fmt.Errorf("verify: %w", olm.ErrUnknownOlmPickleVersion) } s.Counter = binary.BigEndian.Uint32(input[1:5]) copy(s.RatchetData[:], input[5:133]) diff --git a/crypto/goolm/pk/decryption.go b/crypto/goolm/pk/decryption.go index afb01f74..cdb20eb1 100644 --- a/crypto/goolm/pk/decryption.go +++ b/crypto/goolm/pk/decryption.go @@ -103,7 +103,7 @@ func (a *Decryption) UnpickleLibOlm(unpickled []byte) error { if pickledVersion == decryptionPickleVersionLibOlm { return a.KeyPair.UnpickleLibOlm(decoder) } else { - return fmt.Errorf("unpickle olmSession: %w (found %d, expected %d)", olm.ErrBadVersion, pickledVersion, decryptionPickleVersionLibOlm) + return fmt.Errorf("unpickle olmSession: %w (found %d, expected %d)", olm.ErrUnknownOlmPickleVersion, pickledVersion, decryptionPickleVersionLibOlm) } } diff --git a/crypto/goolm/pk/encryption.go b/crypto/goolm/pk/encryption.go index 23f67ddf..2897d9b0 100644 --- a/crypto/goolm/pk/encryption.go +++ b/crypto/goolm/pk/encryption.go @@ -37,6 +37,9 @@ func (e Encryption) Encrypt(plaintext []byte, privateKey crypto.Curve25519Privat return nil, nil, err } cipher, err := aessha2.NewAESSHA2(sharedSecret, nil) + if err != nil { + return nil, nil, err + } ciphertext, err = cipher.Encrypt(plaintext) if err != nil { return nil, nil, err diff --git a/crypto/goolm/session/megolm_inbound_session.go b/crypto/goolm/session/megolm_inbound_session.go index 80dd71cc..fb88b73c 100644 --- a/crypto/goolm/session/megolm_inbound_session.go +++ b/crypto/goolm/session/megolm_inbound_session.go @@ -99,7 +99,7 @@ func (o *MegolmInboundSession) getRatchet(messageIndex uint32) (*megolm.Ratchet, } if (messageIndex - o.InitialRatchet.Counter) >= uint32(1<<31) { // the counter is before our initial ratchet - we can't decode this - return nil, fmt.Errorf("decrypt: %w", olm.ErrRatchetNotAvailable) + return nil, fmt.Errorf("decrypt: %w", olm.ErrUnknownMessageIndex) } // otherwise, start from the initial ratchet. Take a copy so that we don't overwrite the initial ratchet copiedRatchet := o.InitialRatchet @@ -206,7 +206,7 @@ func (o *MegolmInboundSession) UnpickleLibOlm(value []byte) error { return err } if pickledVersion != megolmInboundSessionPickleVersionLibOlm && pickledVersion != 1 { - return fmt.Errorf("unpickle MegolmInboundSession: %w (found version %d)", olm.ErrBadVersion, pickledVersion) + return fmt.Errorf("unpickle MegolmInboundSession: %w (found version %d)", olm.ErrUnknownOlmPickleVersion, pickledVersion) } if err = o.InitialRatchet.UnpickleLibOlm(decoder); err != nil { diff --git a/crypto/goolm/session/megolm_outbound_session.go b/crypto/goolm/session/megolm_outbound_session.go index 2b8e1c84..7f923534 100644 --- a/crypto/goolm/session/megolm_outbound_session.go +++ b/crypto/goolm/session/megolm_outbound_session.go @@ -101,8 +101,10 @@ func (o *MegolmOutboundSession) Unpickle(pickled, key []byte) error { func (o *MegolmOutboundSession) UnpickleLibOlm(buf []byte) error { decoder := libolmpickle.NewDecoder(buf) pickledVersion, err := decoder.ReadUInt32() - if pickledVersion != megolmOutboundSessionPickleVersionLibOlm { - return fmt.Errorf("unpickle MegolmInboundSession: %w (found version %d)", olm.ErrBadVersion, pickledVersion) + if err != nil { + return fmt.Errorf("unpickle MegolmOutboundSession: failed to read version: %w", err) + } else if pickledVersion != megolmOutboundSessionPickleVersionLibOlm { + return fmt.Errorf("unpickle MegolmInboundSession: %w (found version %d)", olm.ErrUnknownOlmPickleVersion, pickledVersion) } if err = o.Ratchet.UnpickleLibOlm(decoder); err != nil { return err diff --git a/crypto/goolm/session/olm_session.go b/crypto/goolm/session/olm_session.go index b99ab630..a1cb8d66 100644 --- a/crypto/goolm/session/olm_session.go +++ b/crypto/goolm/session/olm_session.go @@ -168,11 +168,11 @@ func NewInboundOlmSession(identityKeyAlice *crypto.Curve25519PublicKey, received msg := message.Message{} err = msg.Decode(oneTimeMsg.Message) if err != nil { - return nil, fmt.Errorf("Message decode: %w", err) + return nil, fmt.Errorf("message decode: %w", err) } if len(msg.RatchetKey) == 0 { - return nil, fmt.Errorf("Message missing ratchet key: %w", olm.ErrBadMessageFormat) + return nil, fmt.Errorf("message missing ratchet key: %w", olm.ErrBadMessageFormat) } //Init Ratchet s.Ratchet.InitializeAsBob(secret, msg.RatchetKey) @@ -203,7 +203,7 @@ func (s *OlmSession) ID() id.SessionID { copy(message[crypto.Curve25519PrivateKeyLength:], s.AliceBaseKey) copy(message[2*crypto.Curve25519PrivateKeyLength:], s.BobOneTimeKey) hash := sha256.Sum256(message) - res := id.SessionID(goolmbase64.Encode(hash[:])) + res := id.SessionID(base64.RawStdEncoding.EncodeToString(hash[:])) return res } @@ -325,7 +325,7 @@ func (s *OlmSession) Decrypt(crypttext string, msgType id.OlmMsgType) ([]byte, e if len(crypttext) == 0 { return nil, fmt.Errorf("decrypt: %w", olm.ErrEmptyInput) } - decodedCrypttext, err := goolmbase64.Decode([]byte(crypttext)) + decodedCrypttext, err := base64.RawStdEncoding.DecodeString(crypttext) if err != nil { return nil, err } @@ -365,6 +365,9 @@ func (o *OlmSession) Unpickle(pickled, key []byte) error { func (o *OlmSession) UnpickleLibOlm(buf []byte) error { decoder := libolmpickle.NewDecoder(buf) pickledVersion, err := decoder.ReadUInt32() + if err != nil { + return fmt.Errorf("unpickle olmSession: failed to read version: %w", err) + } var includesChainIndex bool switch pickledVersion { @@ -373,7 +376,7 @@ func (o *OlmSession) UnpickleLibOlm(buf []byte) error { case uint32(0x80000001): includesChainIndex = true default: - return fmt.Errorf("unpickle olmSession: %w (found version %d)", olm.ErrBadVersion, pickledVersion) + return fmt.Errorf("unpickle olmSession: %w (found version %d)", olm.ErrUnknownOlmPickleVersion, pickledVersion) } if o.ReceivedMessage, err = decoder.ReadBool(); err != nil { diff --git a/crypto/goolm/session/register.go b/crypto/goolm/session/register.go index a88d12f6..b95a44ac 100644 --- a/crypto/goolm/session/register.go +++ b/crypto/goolm/session/register.go @@ -14,7 +14,7 @@ func Register() { // Inbound Session olm.InitInboundGroupSessionFromPickled = func(pickled, key []byte) (olm.InboundGroupSession, error) { if len(pickled) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } if len(key) == 0 { key = []byte(" ") @@ -23,13 +23,13 @@ func Register() { } olm.InitNewInboundGroupSession = func(sessionKey []byte) (olm.InboundGroupSession, error) { if len(sessionKey) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } return NewMegolmInboundSession(sessionKey) } olm.InitInboundGroupSessionImport = func(sessionKey []byte) (olm.InboundGroupSession, error) { if len(sessionKey) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } return NewMegolmInboundSessionFromExport(sessionKey) } @@ -40,7 +40,7 @@ func Register() { // Outbound Session olm.InitNewOutboundGroupSessionFromPickled = func(pickled, key []byte) (olm.OutboundGroupSession, error) { if len(pickled) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } lenKey := len(key) if lenKey == 0 { diff --git a/crypto/keybackup.go b/crypto/keybackup.go index d8b3d715..ceec1d58 100644 --- a/crypto/keybackup.go +++ b/crypto/keybackup.go @@ -56,11 +56,12 @@ func (mach *OlmMachine) GetAndVerifyLatestKeyBackupVersion(ctx context.Context, // ...by deriving the public key from a private key that it obtained from a trusted source. Trusted sources for the private // key include the user entering the key, retrieving the key stored in secret storage, or obtaining the key via secret sharing // from a verified device belonging to the same user." - megolmBackupDerivedPublicKey := id.Ed25519(base64.RawStdEncoding.EncodeToString(megolmBackupKey.PublicKey().Bytes())) - if megolmBackupKey != nil && versionInfo.AuthData.PublicKey == megolmBackupDerivedPublicKey { - log.Debug().Msg("key backup is trusted based on derived public key") - return versionInfo, nil - } else { + if megolmBackupKey != nil { + megolmBackupDerivedPublicKey := id.Ed25519(base64.RawStdEncoding.EncodeToString(megolmBackupKey.PublicKey().Bytes())) + if versionInfo.AuthData.PublicKey == megolmBackupDerivedPublicKey { + log.Debug().Msg("Key backup is trusted based on derived public key") + return versionInfo, nil + } log.Debug(). Stringer("expected_key", megolmBackupDerivedPublicKey). Stringer("actual_key", versionInfo.AuthData.PublicKey). diff --git a/crypto/libolm/account.go b/crypto/libolm/account.go index f6f916e7..0350f083 100644 --- a/crypto/libolm/account.go +++ b/crypto/libolm/account.go @@ -33,7 +33,7 @@ var _ olm.Account = (*Account)(nil) // "INVALID_BASE64". func AccountFromPickled(pickled, key []byte) (*Account, error) { if len(pickled) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } a := NewBlankAccount() return a, a.Unpickle(pickled, key) @@ -53,7 +53,7 @@ func NewAccount() (*Account, error) { random := make([]byte, a.createRandomLen()+1) _, err := rand.Read(random) if err != nil { - panic(olm.NotEnoughGoRandom) + panic(olm.ErrNotEnoughGoRandom) } ret := C.olm_create_account( (*C.OlmAccount)(a.int), @@ -128,7 +128,7 @@ func (a *Account) genOneTimeKeysRandomLen(num uint) uint { // supplied key. func (a *Account) Pickle(key []byte) ([]byte, error) { if len(key) == 0 { - return nil, olm.NoKeyProvided + return nil, olm.ErrNoKeyProvided } pickled := make([]byte, a.pickleLen()) r := C.olm_pickle_account( @@ -145,7 +145,7 @@ func (a *Account) Pickle(key []byte) ([]byte, error) { func (a *Account) Unpickle(pickled, key []byte) error { if len(key) == 0 { - return olm.NoKeyProvided + return olm.ErrNoKeyProvided } r := C.olm_unpickle_account( (*C.OlmAccount)(a.int), @@ -198,7 +198,7 @@ func (a *Account) MarshalJSON() ([]byte, error) { // Deprecated func (a *Account) UnmarshalJSON(data []byte) error { if len(data) == 0 || data[0] != '"' || data[len(data)-1] != '"' { - return olm.InputNotJSONString + return olm.ErrInputNotJSONString } if a.int == nil { *a = *NewBlankAccount() @@ -235,7 +235,7 @@ func (a *Account) IdentityKeys() (id.Ed25519, id.Curve25519, error) { // Account. func (a *Account) Sign(message []byte) ([]byte, error) { if len(message) == 0 { - panic(olm.EmptyInput) + panic(olm.ErrEmptyInput) } signature := make([]byte, a.signatureLen()) r := C.olm_account_sign( @@ -299,7 +299,7 @@ func (a *Account) GenOneTimeKeys(num uint) error { random := make([]byte, a.genOneTimeKeysRandomLen(num)+1) _, err := rand.Read(random) if err != nil { - return olm.NotEnoughGoRandom + return olm.ErrNotEnoughGoRandom } r := C.olm_account_generate_one_time_keys( (*C.OlmAccount)(a.int), @@ -319,13 +319,13 @@ func (a *Account) GenOneTimeKeys(num uint) error { // keys couldn't be decoded as base64 then the error will be "INVALID_BASE64" func (a *Account) NewOutboundSession(theirIdentityKey, theirOneTimeKey id.Curve25519) (olm.Session, error) { if len(theirIdentityKey) == 0 || len(theirOneTimeKey) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } s := NewBlankSession() random := make([]byte, s.createOutboundRandomLen()+1) _, err := rand.Read(random) if err != nil { - panic(olm.NotEnoughGoRandom) + panic(olm.ErrNotEnoughGoRandom) } theirIdentityKeyCopy := []byte(theirIdentityKey) theirOneTimeKeyCopy := []byte(theirOneTimeKey) @@ -357,7 +357,7 @@ func (a *Account) NewOutboundSession(theirIdentityKey, theirOneTimeKey id.Curve2 // time key then the error will be "BAD_MESSAGE_KEY_ID". func (a *Account) NewInboundSession(oneTimeKeyMsg string) (olm.Session, error) { if len(oneTimeKeyMsg) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } s := NewBlankSession() oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) @@ -383,7 +383,7 @@ func (a *Account) NewInboundSession(oneTimeKeyMsg string) (olm.Session, error) { // time key then the error will be "BAD_MESSAGE_KEY_ID". func (a *Account) NewInboundSessionFrom(theirIdentityKey *id.Curve25519, oneTimeKeyMsg string) (olm.Session, error) { if theirIdentityKey == nil || len(oneTimeKeyMsg) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } theirIdentityKeyCopy := []byte(*theirIdentityKey) oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) diff --git a/crypto/libolm/error.go b/crypto/libolm/error.go index 9ca415ee..6fb5512b 100644 --- a/crypto/libolm/error.go +++ b/crypto/libolm/error.go @@ -11,21 +11,21 @@ import ( ) var errorMap = map[string]error{ - "NOT_ENOUGH_RANDOM": olm.NotEnoughRandom, - "OUTPUT_BUFFER_TOO_SMALL": olm.OutputBufferTooSmall, - "BAD_MESSAGE_VERSION": olm.BadMessageVersion, - "BAD_MESSAGE_FORMAT": olm.BadMessageFormat, - "BAD_MESSAGE_MAC": olm.BadMessageMAC, - "BAD_MESSAGE_KEY_ID": olm.BadMessageKeyID, - "INVALID_BASE64": olm.InvalidBase64, - "BAD_ACCOUNT_KEY": olm.BadAccountKey, - "UNKNOWN_PICKLE_VERSION": olm.UnknownPickleVersion, - "CORRUPTED_PICKLE": olm.CorruptedPickle, - "BAD_SESSION_KEY": olm.BadSessionKey, - "UNKNOWN_MESSAGE_INDEX": olm.UnknownMessageIndex, - "BAD_LEGACY_ACCOUNT_PICKLE": olm.BadLegacyAccountPickle, - "BAD_SIGNATURE": olm.BadSignature, - "INPUT_BUFFER_TOO_SMALL": olm.InputBufferTooSmall, + "NOT_ENOUGH_RANDOM": olm.ErrLibolmNotEnoughRandom, + "OUTPUT_BUFFER_TOO_SMALL": olm.ErrLibolmOutputBufferTooSmall, + "BAD_MESSAGE_VERSION": olm.ErrWrongProtocolVersion, + "BAD_MESSAGE_FORMAT": olm.ErrBadMessageFormat, + "BAD_MESSAGE_MAC": olm.ErrBadMAC, + "BAD_MESSAGE_KEY_ID": olm.ErrBadMessageKeyID, + "INVALID_BASE64": olm.ErrLibolmInvalidBase64, + "BAD_ACCOUNT_KEY": olm.ErrLibolmBadAccountKey, + "UNKNOWN_PICKLE_VERSION": olm.ErrUnknownOlmPickleVersion, + "CORRUPTED_PICKLE": olm.ErrLibolmCorruptedPickle, + "BAD_SESSION_KEY": olm.ErrLibolmBadSessionKey, + "UNKNOWN_MESSAGE_INDEX": olm.ErrUnknownMessageIndex, + "BAD_LEGACY_ACCOUNT_PICKLE": olm.ErrLibolmBadLegacyAccountPickle, + "BAD_SIGNATURE": olm.ErrBadSignature, + "INPUT_BUFFER_TOO_SMALL": olm.ErrInputToSmall, } func convertError(errCode string) error { diff --git a/crypto/libolm/inboundgroupsession.go b/crypto/libolm/inboundgroupsession.go index 5606475d..8815ac32 100644 --- a/crypto/libolm/inboundgroupsession.go +++ b/crypto/libolm/inboundgroupsession.go @@ -31,7 +31,7 @@ var _ olm.InboundGroupSession = (*InboundGroupSession)(nil) // base64 couldn't be decoded then the error will be "INVALID_BASE64". func InboundGroupSessionFromPickled(pickled, key []byte) (*InboundGroupSession, error) { if len(pickled) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } lenKey := len(key) if lenKey == 0 { @@ -48,7 +48,7 @@ func InboundGroupSessionFromPickled(pickled, key []byte) (*InboundGroupSession, // "OLM_BAD_SESSION_KEY". func NewInboundGroupSession(sessionKey []byte) (*InboundGroupSession, error) { if len(sessionKey) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } s := NewBlankInboundGroupSession() r := C.olm_init_inbound_group_session( @@ -69,7 +69,7 @@ func NewInboundGroupSession(sessionKey []byte) (*InboundGroupSession, error) { // error will be "OLM_BAD_SESSION_KEY". func InboundGroupSessionImport(sessionKey []byte) (*InboundGroupSession, error) { if len(sessionKey) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } s := NewBlankInboundGroupSession() r := C.olm_import_inbound_group_session( @@ -124,7 +124,7 @@ func (s *InboundGroupSession) pickleLen() uint { // InboundGroupSession using the supplied key. func (s *InboundGroupSession) Pickle(key []byte) ([]byte, error) { if len(key) == 0 { - return nil, olm.NoKeyProvided + return nil, olm.ErrNoKeyProvided } pickled := make([]byte, s.pickleLen()) r := C.olm_pickle_inbound_group_session( @@ -143,9 +143,9 @@ func (s *InboundGroupSession) Pickle(key []byte) ([]byte, error) { func (s *InboundGroupSession) Unpickle(pickled, key []byte) error { if len(key) == 0 { - return olm.NoKeyProvided + return olm.ErrNoKeyProvided } else if len(pickled) == 0 { - return olm.EmptyInput + return olm.ErrEmptyInput } r := C.olm_unpickle_inbound_group_session( (*C.OlmInboundGroupSession)(s.int), @@ -200,7 +200,7 @@ func (s *InboundGroupSession) MarshalJSON() ([]byte, error) { // Deprecated func (s *InboundGroupSession) UnmarshalJSON(data []byte) error { if len(data) == 0 || data[0] != '"' || data[len(data)-1] != '"' { - return olm.InputNotJSONString + return olm.ErrInputNotJSONString } if s == nil || s.int == nil { *s = *NewBlankInboundGroupSession() @@ -217,7 +217,7 @@ func (s *InboundGroupSession) UnmarshalJSON(data []byte) error { // will be "BAD_MESSAGE_FORMAT". func (s *InboundGroupSession) decryptMaxPlaintextLen(message []byte) (uint, error) { if len(message) == 0 { - return 0, olm.EmptyInput + return 0, olm.ErrEmptyInput } // olm_group_decrypt_max_plaintext_length destroys the input, so we have to clone it messageCopy := bytes.Clone(message) @@ -244,7 +244,7 @@ func (s *InboundGroupSession) decryptMaxPlaintextLen(message []byte) (uint, erro // was shared with us) the error will be "OLM_UNKNOWN_MESSAGE_INDEX". func (s *InboundGroupSession) Decrypt(message []byte) ([]byte, uint, error) { if len(message) == 0 { - return nil, 0, olm.EmptyInput + return nil, 0, olm.ErrEmptyInput } decryptMaxPlaintextLen, err := s.decryptMaxPlaintextLen(message) if err != nil { diff --git a/crypto/libolm/outboundgroupsession.go b/crypto/libolm/outboundgroupsession.go index 646929eb..ca5b68f7 100644 --- a/crypto/libolm/outboundgroupsession.go +++ b/crypto/libolm/outboundgroupsession.go @@ -84,7 +84,7 @@ func (s *OutboundGroupSession) pickleLen() uint { // OutboundGroupSession using the supplied key. func (s *OutboundGroupSession) Pickle(key []byte) ([]byte, error) { if len(key) == 0 { - return nil, olm.NoKeyProvided + return nil, olm.ErrNoKeyProvided } pickled := make([]byte, s.pickleLen()) r := C.olm_pickle_outbound_group_session( @@ -103,7 +103,7 @@ func (s *OutboundGroupSession) Pickle(key []byte) ([]byte, error) { func (s *OutboundGroupSession) Unpickle(pickled, key []byte) error { if len(key) == 0 { - return olm.NoKeyProvided + return olm.ErrNoKeyProvided } r := C.olm_unpickle_outbound_group_session( (*C.OlmOutboundGroupSession)(s.int), @@ -159,7 +159,7 @@ func (s *OutboundGroupSession) MarshalJSON() ([]byte, error) { // Deprecated func (s *OutboundGroupSession) UnmarshalJSON(data []byte) error { if len(data) == 0 || data[0] != '"' || data[len(data)-1] != '"' { - return olm.InputNotJSONString + return olm.ErrInputNotJSONString } if s == nil || s.int == nil { *s = *NewBlankOutboundGroupSession() @@ -183,7 +183,7 @@ func (s *OutboundGroupSession) encryptMsgLen(plainTextLen int) uint { // as base64. func (s *OutboundGroupSession) Encrypt(plaintext []byte) ([]byte, error) { if len(plaintext) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } message := make([]byte, s.encryptMsgLen(len(plaintext))) r := C.olm_group_encrypt( diff --git a/crypto/libolm/pk.go b/crypto/libolm/pk.go index 35532140..2683cf15 100644 --- a/crypto/libolm/pk.go +++ b/crypto/libolm/pk.go @@ -86,7 +86,7 @@ func NewPKSigning() (*PKSigning, error) { seed := make([]byte, pkSigningSeedLength()) _, err := rand.Read(seed) if err != nil { - panic(olm.NotEnoughGoRandom) + panic(olm.ErrNotEnoughGoRandom) } pk, err := NewPKSigningFromSeed(seed) return pk, err diff --git a/crypto/libolm/register.go b/crypto/libolm/register.go index f091d822..ddf84613 100644 --- a/crypto/libolm/register.go +++ b/crypto/libolm/register.go @@ -65,7 +65,7 @@ func Register() { olm.InitNewOutboundGroupSessionFromPickled = func(pickled, key []byte) (olm.OutboundGroupSession, error) { if len(pickled) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } s := NewBlankOutboundGroupSession() return s, s.Unpickle(pickled, key) diff --git a/crypto/libolm/session.go b/crypto/libolm/session.go index 57e631c3..1441df26 100644 --- a/crypto/libolm/session.go +++ b/crypto/libolm/session.go @@ -51,7 +51,7 @@ func sessionSize() uint { // "INVALID_BASE64". func SessionFromPickled(pickled, key []byte) (*Session, error) { if len(pickled) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } s := NewBlankSession() return s, s.Unpickle(pickled, key) @@ -118,7 +118,7 @@ func (s *Session) encryptMsgLen(plainTextLen int) uint { // will be "BAD_MESSAGE_FORMAT". func (s *Session) decryptMaxPlaintextLen(message string, msgType id.OlmMsgType) (uint, error) { if len(message) == 0 { - return 0, olm.EmptyInput + return 0, olm.ErrEmptyInput } messageCopy := []byte(message) r := C.olm_decrypt_max_plaintext_length( @@ -138,7 +138,7 @@ func (s *Session) decryptMaxPlaintextLen(message string, msgType id.OlmMsgType) // supplied key. func (s *Session) Pickle(key []byte) ([]byte, error) { if len(key) == 0 { - return nil, olm.NoKeyProvided + return nil, olm.ErrNoKeyProvided } pickled := make([]byte, s.pickleLen()) r := C.olm_pickle_session( @@ -158,7 +158,7 @@ func (s *Session) Pickle(key []byte) ([]byte, error) { // provided key. This function mutates the input pickled data slice. func (s *Session) Unpickle(pickled, key []byte) error { if len(key) == 0 { - return olm.NoKeyProvided + return olm.ErrNoKeyProvided } r := C.olm_unpickle_session( (*C.OlmSession)(s.int), @@ -213,7 +213,7 @@ func (s *Session) MarshalJSON() ([]byte, error) { // Deprecated func (s *Session) UnmarshalJSON(data []byte) error { if len(data) == 0 || data[0] != '"' || data[len(data)-1] != '"' { - return olm.InputNotJSONString + return olm.ErrInputNotJSONString } if s == nil || s.int == nil { *s = *NewBlankSession() @@ -256,7 +256,7 @@ func (s *Session) HasReceivedMessage() bool { // decoded then then the error will be "BAD_MESSAGE_FORMAT". func (s *Session) MatchesInboundSession(oneTimeKeyMsg string) (bool, error) { if len(oneTimeKeyMsg) == 0 { - return false, olm.EmptyInput + return false, olm.ErrEmptyInput } oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) r := C.olm_matches_inbound_session( @@ -284,7 +284,7 @@ func (s *Session) MatchesInboundSession(oneTimeKeyMsg string) (bool, error) { // decoded then then the error will be "BAD_MESSAGE_FORMAT". func (s *Session) MatchesInboundSessionFrom(theirIdentityKey, oneTimeKeyMsg string) (bool, error) { if len(theirIdentityKey) == 0 || len(oneTimeKeyMsg) == 0 { - return false, olm.EmptyInput + return false, olm.ErrEmptyInput } theirIdentityKeyCopy := []byte(theirIdentityKey) oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) @@ -325,14 +325,14 @@ func (s *Session) EncryptMsgType() id.OlmMsgType { // as base64. func (s *Session) Encrypt(plaintext []byte) (id.OlmMsgType, []byte, error) { if len(plaintext) == 0 { - return 0, nil, olm.EmptyInput + return 0, nil, olm.ErrEmptyInput } // Make the slice be at least length 1 random := make([]byte, s.encryptRandomLen()+1) _, err := rand.Read(random) if err != nil { // TODO can we just return err here? - return 0, nil, olm.NotEnoughGoRandom + return 0, nil, olm.ErrNotEnoughGoRandom } messageType := s.EncryptMsgType() message := make([]byte, s.encryptMsgLen(len(plaintext))) @@ -362,7 +362,7 @@ func (s *Session) Encrypt(plaintext []byte) (id.OlmMsgType, []byte, error) { // "BAD_MESSAGE_MAC". func (s *Session) Decrypt(message string, msgType id.OlmMsgType) ([]byte, error) { if len(message) == 0 { - return nil, olm.EmptyInput + return nil, olm.ErrEmptyInput } decryptMaxPlaintextLen, err := s.decryptMaxPlaintextLen(message, msgType) if err != nil { diff --git a/crypto/machine.go b/crypto/machine.go index 4d2e3880..f8ebe909 100644 --- a/crypto/machine.go +++ b/crypto/machine.go @@ -205,7 +205,7 @@ func (mach *OlmMachine) FlushStore(ctx context.Context) error { func (mach *OlmMachine) timeTrace(ctx context.Context, thing string, expectedDuration time.Duration) func() { start := time.Now() return func() { - duration := time.Now().Sub(start) + duration := time.Since(start) if duration > expectedDuration { zerolog.Ctx(ctx).Warn(). Str("action", thing). diff --git a/crypto/olm/errors.go b/crypto/olm/errors.go index 957d7928..9e522b2a 100644 --- a/crypto/olm/errors.go +++ b/crypto/olm/errors.go @@ -10,50 +10,67 @@ import "errors" // Those are the most common used errors var ( - ErrBadSignature = errors.New("bad signature") - ErrBadMAC = errors.New("bad mac") - ErrBadMessageFormat = errors.New("bad message format") - ErrBadVerification = errors.New("bad verification") - ErrWrongProtocolVersion = errors.New("wrong protocol version") - ErrEmptyInput = errors.New("empty input") - ErrNoKeyProvided = errors.New("no key") - ErrBadMessageKeyID = errors.New("bad message key id") - ErrRatchetNotAvailable = errors.New("ratchet not available: attempt to decode a message whose index is earlier than our earliest known session key") - ErrMsgIndexTooHigh = errors.New("message index too high") - ErrProtocolViolation = errors.New("not protocol message order") - ErrMessageKeyNotFound = errors.New("message key not found") - ErrChainTooHigh = errors.New("chain index too high") - ErrBadInput = errors.New("bad input") - ErrBadVersion = errors.New("wrong version") - ErrWrongPickleVersion = errors.New("wrong pickle version") - ErrInputToSmall = errors.New("input too small (truncated?)") - ErrOverflow = errors.New("overflow") + ErrBadSignature = errors.New("bad signature") + ErrBadMAC = errors.New("the message couldn't be decrypted (bad mac)") + ErrBadMessageFormat = errors.New("the message couldn't be decoded") + ErrBadVerification = errors.New("bad verification") + ErrWrongProtocolVersion = errors.New("wrong protocol version") + ErrEmptyInput = errors.New("empty input") + ErrNoKeyProvided = errors.New("no key provided") + ErrBadMessageKeyID = errors.New("the message references an unknown key ID") + ErrUnknownMessageIndex = errors.New("attempt to decode a message whose index is earlier than our earliest known session key") + ErrMsgIndexTooHigh = errors.New("message index too high") + ErrProtocolViolation = errors.New("not protocol message order") + ErrMessageKeyNotFound = errors.New("message key not found") + ErrChainTooHigh = errors.New("chain index too high") + ErrBadInput = errors.New("bad input") + ErrUnknownOlmPickleVersion = errors.New("unknown olm pickle version") + ErrUnknownJSONPickleVersion = errors.New("unknown JSON pickle version") + ErrInputToSmall = errors.New("input too small (truncated?)") ) // Error codes from go-olm var ( - EmptyInput = errors.New("empty input") - NoKeyProvided = errors.New("no pickle key provided") - NotEnoughGoRandom = errors.New("couldn't get enough randomness from crypto/rand") - SignatureNotFound = errors.New("input JSON doesn't contain signature from specified device") - InputNotJSONString = errors.New("input doesn't look like a JSON string") + ErrNotEnoughGoRandom = errors.New("couldn't get enough randomness from crypto/rand") + ErrInputNotJSONString = errors.New("input doesn't look like a JSON string") ) // Error codes from olm code var ( - NotEnoughRandom = errors.New("not enough entropy was supplied") - OutputBufferTooSmall = errors.New("supplied output buffer is too small") - BadMessageVersion = errors.New("the message version is unsupported") - BadMessageFormat = errors.New("the message couldn't be decoded") - BadMessageMAC = errors.New("the message couldn't be decrypted") - BadMessageKeyID = errors.New("the message references an unknown key ID") - InvalidBase64 = errors.New("the input base64 was invalid") - BadAccountKey = errors.New("the supplied account key is invalid") - UnknownPickleVersion = errors.New("the pickled object is too new") - CorruptedPickle = errors.New("the pickled object couldn't be decoded") - BadSessionKey = errors.New("attempt to initialise an inbound group session from an invalid session key") - UnknownMessageIndex = errors.New("attempt to decode a message whose index is earlier than our earliest known session key") - BadLegacyAccountPickle = errors.New("attempt to unpickle an account which uses pickle version 1") - BadSignature = errors.New("received message had a bad signature") - InputBufferTooSmall = errors.New("the input data was too small to be valid") + ErrLibolmInvalidBase64 = errors.New("the input base64 was invalid") + + ErrLibolmNotEnoughRandom = errors.New("not enough entropy was supplied") + ErrLibolmOutputBufferTooSmall = errors.New("supplied output buffer is too small") + ErrLibolmBadAccountKey = errors.New("the supplied account key is invalid") + ErrLibolmCorruptedPickle = errors.New("the pickled object couldn't be decoded") + ErrLibolmBadSessionKey = errors.New("attempt to initialise an inbound group session from an invalid session key") + ErrLibolmBadLegacyAccountPickle = errors.New("attempt to unpickle an account which uses pickle version 1") +) + +// Deprecated: use variables prefixed with Err +var ( + EmptyInput = ErrEmptyInput + BadSignature = ErrBadSignature + InvalidBase64 = ErrLibolmInvalidBase64 + BadMessageKeyID = ErrBadMessageKeyID + BadMessageFormat = ErrBadMessageFormat + BadMessageVersion = ErrWrongProtocolVersion + BadMessageMAC = ErrBadMAC + UnknownPickleVersion = ErrUnknownOlmPickleVersion + NotEnoughRandom = ErrLibolmNotEnoughRandom + OutputBufferTooSmall = ErrLibolmOutputBufferTooSmall + BadAccountKey = ErrLibolmBadAccountKey + CorruptedPickle = ErrLibolmCorruptedPickle + BadSessionKey = ErrLibolmBadSessionKey + UnknownMessageIndex = ErrUnknownMessageIndex + BadLegacyAccountPickle = ErrLibolmBadLegacyAccountPickle + InputBufferTooSmall = ErrInputToSmall + NoKeyProvided = ErrNoKeyProvided + + NotEnoughGoRandom = ErrNotEnoughGoRandom + InputNotJSONString = ErrInputNotJSONString + + ErrBadVersion = ErrUnknownJSONPickleVersion + ErrWrongPickleVersion = ErrUnknownJSONPickleVersion + ErrRatchetNotAvailable = ErrUnknownMessageIndex ) diff --git a/crypto/sessions.go b/crypto/sessions.go index aecb0416..6b90c998 100644 --- a/crypto/sessions.go +++ b/crypto/sessions.go @@ -18,8 +18,14 @@ import ( ) var ( - SessionNotShared = errors.New("session has not been shared") - SessionExpired = errors.New("session has expired") + ErrSessionNotShared = errors.New("session has not been shared") + ErrSessionExpired = errors.New("session has expired") +) + +// Deprecated: use variables prefixed with Err +var ( + SessionNotShared = ErrSessionNotShared + SessionExpired = ErrSessionExpired ) // OlmSessionList is a list of OlmSessions. @@ -255,9 +261,9 @@ func (ogs *OutboundGroupSession) Expired() bool { func (ogs *OutboundGroupSession) Encrypt(plaintext []byte) ([]byte, error) { if !ogs.Shared { - return nil, SessionNotShared + return nil, ErrSessionNotShared } else if ogs.Expired() { - return nil, SessionExpired + return nil, ErrSessionExpired } ogs.MessageCount++ ogs.LastEncryptedTime = time.Now() diff --git a/event/encryption.go b/event/encryption.go index cf9c2814..e07944af 100644 --- a/event/encryption.go +++ b/event/encryption.go @@ -63,7 +63,7 @@ func (content *EncryptedEventContent) UnmarshalJSON(data []byte) error { return json.Unmarshal(content.Ciphertext, &content.OlmCiphertext) case id.AlgorithmMegolmV1: if len(content.Ciphertext) == 0 || content.Ciphertext[0] != '"' || content.Ciphertext[len(content.Ciphertext)-1] != '"' { - return id.InputNotJSONString + return id.ErrInputNotJSONString } content.MegolmCiphertext = content.Ciphertext[1 : len(content.Ciphertext)-1] } diff --git a/federation/resolution.go b/federation/resolution.go index 81e19cfb..a3188266 100644 --- a/federation/resolution.go +++ b/federation/resolution.go @@ -80,7 +80,10 @@ func ResolveServerName(ctx context.Context, serverName string, opts ...*ResolveS } else if wellKnown != nil { output.Expires = expiry output.HostHeader = wellKnown.Server - hostname, port, ok = ParseServerName(wellKnown.Server) + wkHost, wkPort, ok := ParseServerName(wellKnown.Server) + if ok { + hostname, port = wkHost, wkPort + } // Step 3.1 and 3.2: IP literals and hostnames with port inside .well-known if net.ParseIP(hostname) != nil || port != 0 { if port == 0 { diff --git a/filter.go b/filter.go index c6c8211b..54973dab 100644 --- a/filter.go +++ b/filter.go @@ -57,7 +57,7 @@ type FilterPart struct { // Validate checks if the filter contains valid property values func (filter *Filter) Validate() error { if filter.EventFormat != EventFormatClient && filter.EventFormat != EventFormatFederation { - return errors.New("Bad event_format value. Must be one of [\"client\", \"federation\"]") + return errors.New("bad event_format value") } return nil } diff --git a/id/contenturi.go b/id/contenturi.go index e6a313f5..be45eb2b 100644 --- a/id/contenturi.go +++ b/id/contenturi.go @@ -17,8 +17,14 @@ import ( ) var ( - InvalidContentURI = errors.New("invalid Matrix content URI") - InputNotJSONString = errors.New("input doesn't look like a JSON string") + ErrInvalidContentURI = errors.New("invalid Matrix content URI") + ErrInputNotJSONString = errors.New("input doesn't look like a JSON string") +) + +// Deprecated: use variables prefixed with Err +var ( + InvalidContentURI = ErrInvalidContentURI + InputNotJSONString = ErrInputNotJSONString ) // ContentURIString is a string that's expected to be a Matrix content URI. @@ -55,9 +61,9 @@ func ParseContentURI(uri string) (parsed ContentURI, err error) { if len(uri) == 0 { return } else if !strings.HasPrefix(uri, "mxc://") { - err = InvalidContentURI + err = ErrInvalidContentURI } else if index := strings.IndexRune(uri[6:], '/'); index == -1 || index == len(uri)-7 { - err = InvalidContentURI + err = ErrInvalidContentURI } else { parsed.Homeserver = uri[6 : 6+index] parsed.FileID = uri[6+index+1:] @@ -71,9 +77,9 @@ func ParseContentURIBytes(uri []byte) (parsed ContentURI, err error) { if len(uri) == 0 { return } else if !bytes.HasPrefix(uri, mxcBytes) { - err = InvalidContentURI + err = ErrInvalidContentURI } else if index := bytes.IndexRune(uri[6:], '/'); index == -1 || index == len(uri)-7 { - err = InvalidContentURI + err = ErrInvalidContentURI } else { parsed.Homeserver = string(uri[6 : 6+index]) parsed.FileID = string(uri[6+index+1:]) @@ -86,7 +92,7 @@ func (uri *ContentURI) UnmarshalJSON(raw []byte) (err error) { *uri = ContentURI{} return nil } else if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' { - return InputNotJSONString + return ErrInputNotJSONString } parsed, err := ParseContentURIBytes(raw[1 : len(raw)-1]) if err != nil { diff --git a/id/matrixuri.go b/id/matrixuri.go index 8f5ec849..d5c78bc7 100644 --- a/id/matrixuri.go +++ b/id/matrixuri.go @@ -54,7 +54,7 @@ var SigilToPathSegment = map[rune]string{ func (uri *MatrixURI) getQuery() url.Values { q := make(url.Values) - if uri.Via != nil && len(uri.Via) > 0 { + if len(uri.Via) > 0 { q["via"] = uri.Via } if len(uri.Action) > 0 { diff --git a/id/userid.go b/id/userid.go index 859d2358..726a0d58 100644 --- a/id/userid.go +++ b/id/userid.go @@ -219,15 +219,15 @@ func DecodeUserLocalpart(str string) (string, error) { for i := 0; i < len(strBytes); i++ { b := strBytes[i] if !isValidByte(b) { - return "", fmt.Errorf("Byte pos %d: Invalid byte", i) + return "", fmt.Errorf("invalid encoded byte at position %d: %c", i, b) } if b == '_' { // next byte is a-z and should be upper-case or is another _ and should be a literal _ if i+1 >= len(strBytes) { - return "", fmt.Errorf("Byte pos %d: expected _[a-z_] encoding but ran out of string", i) + return "", fmt.Errorf("unexpected end of string after underscore at %d", i) } if !isValidEscapedChar(strBytes[i+1]) { // invalid escaping - return "", fmt.Errorf("Byte pos %d: expected _[a-z_] encoding", i) + return "", fmt.Errorf("unexpected byte %c after underscore at %d", strBytes[i+1], i) } if strBytes[i+1] == '_' { outputBuffer.WriteByte('_') @@ -237,7 +237,7 @@ func DecodeUserLocalpart(str string) (string, error) { i++ // skip next byte since we just handled it } else if b == '=' { // next 2 bytes are hex and should be buffered ready to be read as utf8 if i+2 >= len(strBytes) { - return "", fmt.Errorf("Byte pos: %d: expected quote-printable encoding but ran out of string", i) + return "", fmt.Errorf("unexpected end of string after equals sign at %d", i) } dst := make([]byte, 1) _, err := hex.Decode(dst, strBytes[i+1:i+3]) diff --git a/pushrules/action.go b/pushrules/action.go index 9838e88b..b5a884b2 100644 --- a/pushrules/action.go +++ b/pushrules/action.go @@ -105,7 +105,7 @@ func (action *PushAction) UnmarshalJSON(raw []byte) error { if ok { action.Action = ActionSetTweak action.Tweak = PushActionTweak(tweak) - action.Value, _ = val["value"] + action.Value = val["value"] } } return nil diff --git a/pushrules/condition_test.go b/pushrules/condition_test.go index 0d3eaf7a..37af3e34 100644 --- a/pushrules/condition_test.go +++ b/pushrules/condition_test.go @@ -102,14 +102,6 @@ func newEventPropertyIsPushCondition(key string, value any) *pushrules.PushCondi } } -func newEventPropertyContainsPushCondition(key string, value any) *pushrules.PushCondition { - return &pushrules.PushCondition{ - Kind: pushrules.KindEventPropertyContains, - Key: key, - Value: value, - } -} - func TestPushCondition_Match_InvalidKind(t *testing.T) { condition := &pushrules.PushCondition{ Kind: pushrules.PushCondKind("invalid"), diff --git a/room.go b/room.go index c3ddb7e6..4292bff5 100644 --- a/room.go +++ b/room.go @@ -5,8 +5,6 @@ import ( "maunium.net/go/mautrix/id" ) -type RoomStateMap = map[event.Type]map[string]*event.Event - // Room represents a single Matrix room. type Room struct { ID id.RoomID @@ -25,8 +23,8 @@ func (room Room) UpdateState(evt *event.Event) { // GetStateEvent returns the state event for the given type/state_key combo, or nil. func (room Room) GetStateEvent(eventType event.Type, stateKey string) *event.Event { - stateEventMap, _ := room.State[eventType] - evt, _ := stateEventMap[stateKey] + stateEventMap := room.State[eventType] + evt := stateEventMap[stateKey] return evt } diff --git a/synapseadmin/roomapi.go b/synapseadmin/roomapi.go index c360acab..0925b748 100644 --- a/synapseadmin/roomapi.go +++ b/synapseadmin/roomapi.go @@ -75,8 +75,7 @@ type RespListRooms struct { // https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#list-room-api func (cli *Client) ListRooms(ctx context.Context, req ReqListRoom) (RespListRooms, error) { var resp RespListRooms - var reqURL string - reqURL = cli.Client.BuildURLWithQuery(mautrix.SynapseAdminURLPath{"v1", "rooms"}, req.BuildQuery()) + reqURL := cli.Client.BuildURLWithQuery(mautrix.SynapseAdminURLPath{"v1", "rooms"}, req.BuildQuery()) _, err := cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) return resp, err } diff --git a/url.go b/url.go index d888956a..91b3d49d 100644 --- a/url.go +++ b/url.go @@ -98,10 +98,8 @@ func (saup SynapseAdminURLPath) FullPath() []any { // and appservice user ID set already. func (cli *Client) BuildURLWithQuery(urlPath PrefixableURLPath, urlQuery map[string]string) string { return cli.BuildURLWithFullQuery(urlPath, func(q url.Values) { - if urlQuery != nil { - for k, v := range urlQuery { - q.Set(k, v) - } + for k, v := range urlQuery { + q.Set(k, v) } }) } From e7a95b7f9732419e224843fc862b37e4359e726f Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 8 Dec 2025 14:33:02 +0000 Subject: [PATCH 479/581] client: backoff before retrying external upload requests --- client.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client.go b/client.go index b740cba6..90737581 100644 --- a/client.go +++ b/client.go @@ -2021,8 +2021,16 @@ func (cli *Client) uploadMediaToURL(ctx context.Context, data ReqUploadMedia) (* Msg("Error uploading media to external URL, not retrying") return nil, err } - cli.Log.Warn().Str("url", data.UnstableUploadURL).Err(err). + backoff := time.Second * time.Duration(cli.DefaultHTTPRetries-retries) + cli.Log.Warn().Err(err). + Str("url", data.UnstableUploadURL). + Int("retry_in_seconds", int(backoff.Seconds())). Msg("Error uploading media to external URL, retrying") + select { + case <-time.After(backoff): + case <-ctx.Done(): + return nil, ctx.Err() + } retries-- _, err = readerSeeker.Seek(0, io.SeekStart) if err != nil { From 31579be20ad8b53f442d49bdfecd061b8982df1d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 9 Dec 2025 16:37:17 +0200 Subject: [PATCH 480/581] bridgev2,event: add interface for message requests --- bridgev2/database/portal.go | 49 ++++++------- bridgev2/database/upgrades/00-latest.sql | 3 +- .../database/upgrades/25-message-requests.sql | 2 + bridgev2/errors.go | 69 ++++++++++--------- bridgev2/matrix/connector.go | 1 + bridgev2/networkinterface.go | 9 +++ bridgev2/portal.go | 60 ++++++++++++++-- event/beeper.go | 6 +- event/capabilities.d.ts | 5 ++ event/capabilities.go | 23 +++++++ event/content.go | 9 +-- event/state.go | 3 +- event/type.go | 11 +-- 13 files changed, 173 insertions(+), 77 deletions(-) create mode 100644 bridgev2/database/upgrades/25-message-requests.sql diff --git a/bridgev2/database/portal.go b/bridgev2/database/portal.go index f6868be6..0e6be286 100644 --- a/bridgev2/database/portal.go +++ b/bridgev2/database/portal.go @@ -56,30 +56,31 @@ type Portal struct { networkid.PortalKey MXID id.RoomID - ParentKey networkid.PortalKey - RelayLoginID networkid.UserLoginID - OtherUserID networkid.UserID - Name string - Topic string - AvatarID networkid.AvatarID - AvatarHash [32]byte - AvatarMXC id.ContentURIString - NameSet bool - TopicSet bool - AvatarSet bool - NameIsCustom bool - InSpace bool - RoomType RoomType - Disappear DisappearingSetting - CapState CapabilityState - Metadata any + ParentKey networkid.PortalKey + RelayLoginID networkid.UserLoginID + OtherUserID networkid.UserID + Name string + Topic string + AvatarID networkid.AvatarID + AvatarHash [32]byte + AvatarMXC id.ContentURIString + NameSet bool + TopicSet bool + AvatarSet bool + NameIsCustom bool + InSpace bool + MessageRequest bool + RoomType RoomType + Disappear DisappearingSetting + CapState CapabilityState + Metadata any } const ( getPortalBaseQuery = ` SELECT bridge_id, id, receiver, mxid, parent_id, parent_receiver, relay_login_id, other_user_id, name, topic, avatar_id, avatar_hash, avatar_mxc, - name_set, topic_set, avatar_set, name_is_custom, in_space, + name_set, topic_set, avatar_set, name_is_custom, in_space, message_request, room_type, disappear_type, disappear_timer, cap_state, metadata FROM portal @@ -101,11 +102,11 @@ const ( bridge_id, id, receiver, mxid, parent_id, parent_receiver, relay_login_id, other_user_id, name, topic, avatar_id, avatar_hash, avatar_mxc, - name_set, avatar_set, topic_set, name_is_custom, in_space, + name_set, avatar_set, topic_set, name_is_custom, in_space, message_request, room_type, disappear_type, disappear_timer, cap_state, metadata, relay_bridge_id ) VALUES ( - $1, $2, $3, $4, $5, $6, cast($7 AS TEXT), $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, + $1, $2, $3, $4, $5, $6, cast($7 AS TEXT), $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, CASE WHEN cast($7 AS TEXT) IS NULL THEN NULL ELSE $1 END ) ` @@ -114,8 +115,8 @@ const ( SET mxid=$4, parent_id=$5, parent_receiver=$6, relay_login_id=cast($7 AS TEXT), relay_bridge_id=CASE WHEN cast($7 AS TEXT) IS NULL THEN NULL ELSE bridge_id END, other_user_id=$8, name=$9, topic=$10, avatar_id=$11, avatar_hash=$12, avatar_mxc=$13, - name_set=$14, avatar_set=$15, topic_set=$16, name_is_custom=$17, in_space=$18, - room_type=$19, disappear_type=$20, disappear_timer=$21, cap_state=$22, metadata=$23 + name_set=$14, avatar_set=$15, topic_set=$16, name_is_custom=$17, in_space=$18, message_request=$19, + room_type=$20, disappear_type=$21, disappear_timer=$22, cap_state=$23, metadata=$24 WHERE bridge_id=$1 AND id=$2 AND receiver=$3 ` deletePortalQuery = ` @@ -241,7 +242,7 @@ func (p *Portal) Scan(row dbutil.Scannable) (*Portal, error) { &p.BridgeID, &p.ID, &p.Receiver, &mxid, &parentID, &parentReceiver, &relayLoginID, &otherUserID, &p.Name, &p.Topic, &p.AvatarID, &avatarHash, &p.AvatarMXC, - &p.NameSet, &p.TopicSet, &p.AvatarSet, &p.NameIsCustom, &p.InSpace, + &p.NameSet, &p.TopicSet, &p.AvatarSet, &p.NameIsCustom, &p.InSpace, &p.MessageRequest, &p.RoomType, &disappearType, &disappearTimer, dbutil.JSON{Data: &p.CapState}, dbutil.JSON{Data: p.Metadata}, ) @@ -288,7 +289,7 @@ func (p *Portal) sqlVariables() []any { p.BridgeID, p.ID, p.Receiver, dbutil.StrPtr(p.MXID), dbutil.StrPtr(p.ParentKey.ID), p.ParentKey.Receiver, dbutil.StrPtr(p.RelayLoginID), dbutil.StrPtr(p.OtherUserID), p.Name, p.Topic, p.AvatarID, avatarHash, p.AvatarMXC, - p.NameSet, p.TopicSet, p.AvatarSet, p.NameIsCustom, p.InSpace, + p.NameSet, p.TopicSet, p.AvatarSet, p.NameIsCustom, p.InSpace, p.MessageRequest, p.RoomType, dbutil.StrPtr(p.Disappear.Type), dbutil.NumPtr(p.Disappear.Timer), dbutil.JSON{Data: p.CapState}, dbutil.JSON{Data: p.Metadata}, } diff --git a/bridgev2/database/upgrades/00-latest.sql b/bridgev2/database/upgrades/00-latest.sql index efde8816..b01cca44 100644 --- a/bridgev2/database/upgrades/00-latest.sql +++ b/bridgev2/database/upgrades/00-latest.sql @@ -1,4 +1,4 @@ --- v0 -> v24 (compatible with v9+): Latest revision +-- v0 -> v25 (compatible with v9+): Latest revision CREATE TABLE "user" ( bridge_id TEXT NOT NULL, mxid TEXT NOT NULL, @@ -48,6 +48,7 @@ CREATE TABLE portal ( topic_set BOOLEAN NOT NULL, name_is_custom BOOLEAN NOT NULL DEFAULT false, in_space BOOLEAN NOT NULL, + message_request BOOLEAN NOT NULL DEFAULT false, room_type TEXT NOT NULL, disappear_type TEXT, disappear_timer BIGINT, diff --git a/bridgev2/database/upgrades/25-message-requests.sql b/bridgev2/database/upgrades/25-message-requests.sql new file mode 100644 index 00000000..b9d82a7a --- /dev/null +++ b/bridgev2/database/upgrades/25-message-requests.sql @@ -0,0 +1,2 @@ +-- v25 (compatible with v9+): Flag for message request portals +ALTER TABLE portal ADD COLUMN message_request BOOLEAN NOT NULL DEFAULT false; diff --git a/bridgev2/errors.go b/bridgev2/errors.go index e81b8953..a6cf4ceb 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -40,40 +40,41 @@ var ErrDirectMediaNotEnabled = errors.New("direct media is not enabled") // Common message status errors var ( - ErrPanicInEventHandler error = WrapErrorInStatus(errors.New("panic in event handler")).WithSendNotice(true).WithErrorAsMessage() - ErrNoPortal error = WrapErrorInStatus(errors.New("room is not a portal")).WithIsCertain(true).WithSendNotice(false) - ErrIgnoringReactionFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring reaction event from relayed user")).WithIsCertain(true).WithSendNotice(false) - ErrIgnoringPollFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring poll event from relayed user")).WithIsCertain(true).WithSendNotice(false) - ErrIgnoringDeleteChatRelayedUser error = WrapErrorInStatus(errors.New("ignoring delete chat event from relayed user")).WithIsCertain(true).WithSendNotice(false) - ErrEditsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support edits")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrEditsNotSupportedInPortal error = WrapErrorInStatus(errors.New("edits are not allowed in this chat")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrCaptionsNotAllowed error = WrapErrorInStatus(errors.New("captions are not supported here")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrLocationMessagesNotAllowed error = WrapErrorInStatus(errors.New("location messages are not supported here")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrEditTargetTooOld error = WrapErrorInStatus(errors.New("the message is too old to be edited")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrEditTargetTooManyEdits error = WrapErrorInStatus(errors.New("the message has been edited too many times")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrReactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support reactions")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrPollsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support polls")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrRoomMetadataNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing room metadata")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) - ErrRoomMetadataNotAllowed error = WrapErrorInStatus(errors.New("changes are not allowed here")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) - ErrRedactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting messages")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrUnexpectedParsedContentType error = WrapErrorInStatus(errors.New("unexpected parsed content type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) - ErrInvalidStateKey error = WrapErrorInStatus(errors.New("room metadata state key is unset or non-empty")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) - ErrDatabaseError error = WrapErrorInStatus(errors.New("database error")).WithMessage("internal database error").WithIsCertain(true).WithSendNotice(true) - ErrTargetMessageNotFound error = WrapErrorInStatus(errors.New("target message not found")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) - ErrUnsupportedMessageType error = WrapErrorInStatus(errors.New("unsupported message type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) - ErrUnsupportedMediaType error = WrapErrorInStatus(errors.New("unsupported media type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) - ErrMediaDurationTooLong error = WrapErrorInStatus(errors.New("media duration too long")).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) - ErrVoiceMessageDurationTooLong error = WrapErrorInStatus(errors.New("voice message too long")).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) - ErrMediaTooLarge error = WrapErrorInStatus(errors.New("media too large")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) - ErrIgnoringMNotice error = WrapErrorInStatus(errors.New("ignoring m.notice message")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) - ErrMediaDownloadFailed error = WrapErrorInStatus(errors.New("failed to download media")).WithMessage("failed to download media").WithIsCertain(true).WithSendNotice(true) - ErrMediaReuploadFailed error = WrapErrorInStatus(errors.New("failed to reupload media")).WithMessage("failed to reupload media").WithIsCertain(true).WithSendNotice(true) - ErrMediaConvertFailed error = WrapErrorInStatus(errors.New("failed to convert media")).WithMessage("failed to convert media").WithIsCertain(true).WithSendNotice(true) - ErrMembershipNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group membership")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) - ErrDeleteChatNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting chats")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) - ErrPowerLevelsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group power levels")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) - ErrRemoteEchoTimeout = WrapErrorInStatus(errors.New("remote echo timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) - ErrRemoteAckTimeout = WrapErrorInStatus(errors.New("remote ack timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) + ErrPanicInEventHandler error = WrapErrorInStatus(errors.New("panic in event handler")).WithSendNotice(true).WithErrorAsMessage() + ErrNoPortal error = WrapErrorInStatus(errors.New("room is not a portal")).WithIsCertain(true).WithSendNotice(false) + ErrIgnoringReactionFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring reaction event from relayed user")).WithIsCertain(true).WithSendNotice(false) + ErrIgnoringPollFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring poll event from relayed user")).WithIsCertain(true).WithSendNotice(false) + ErrIgnoringDeleteChatRelayedUser error = WrapErrorInStatus(errors.New("ignoring delete chat event from relayed user")).WithIsCertain(true).WithSendNotice(false) + ErrIgnoringAcceptRequestRelayedUser error = WrapErrorInStatus(errors.New("ignoring accept message request event from relayed user")).WithIsCertain(true).WithSendNotice(false) + ErrEditsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support edits")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrEditsNotSupportedInPortal error = WrapErrorInStatus(errors.New("edits are not allowed in this chat")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrCaptionsNotAllowed error = WrapErrorInStatus(errors.New("captions are not supported here")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrLocationMessagesNotAllowed error = WrapErrorInStatus(errors.New("location messages are not supported here")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrEditTargetTooOld error = WrapErrorInStatus(errors.New("the message is too old to be edited")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrEditTargetTooManyEdits error = WrapErrorInStatus(errors.New("the message has been edited too many times")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrReactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support reactions")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrPollsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support polls")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrRoomMetadataNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing room metadata")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrRoomMetadataNotAllowed error = WrapErrorInStatus(errors.New("changes are not allowed here")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrRedactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting messages")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrUnexpectedParsedContentType error = WrapErrorInStatus(errors.New("unexpected parsed content type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) + ErrInvalidStateKey error = WrapErrorInStatus(errors.New("room metadata state key is unset or non-empty")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) + ErrDatabaseError error = WrapErrorInStatus(errors.New("database error")).WithMessage("internal database error").WithIsCertain(true).WithSendNotice(true) + ErrTargetMessageNotFound error = WrapErrorInStatus(errors.New("target message not found")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) + ErrUnsupportedMessageType error = WrapErrorInStatus(errors.New("unsupported message type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + ErrUnsupportedMediaType error = WrapErrorInStatus(errors.New("unsupported media type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + ErrMediaDurationTooLong error = WrapErrorInStatus(errors.New("media duration too long")).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + ErrVoiceMessageDurationTooLong error = WrapErrorInStatus(errors.New("voice message too long")).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + ErrMediaTooLarge error = WrapErrorInStatus(errors.New("media too large")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + ErrIgnoringMNotice error = WrapErrorInStatus(errors.New("ignoring m.notice message")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) + ErrMediaDownloadFailed error = WrapErrorInStatus(errors.New("failed to download media")).WithMessage("failed to download media").WithIsCertain(true).WithSendNotice(true) + ErrMediaReuploadFailed error = WrapErrorInStatus(errors.New("failed to reupload media")).WithMessage("failed to reupload media").WithIsCertain(true).WithSendNotice(true) + ErrMediaConvertFailed error = WrapErrorInStatus(errors.New("failed to convert media")).WithMessage("failed to convert media").WithIsCertain(true).WithSendNotice(true) + ErrMembershipNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group membership")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrDeleteChatNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting chats")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrPowerLevelsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group power levels")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrRemoteEchoTimeout = WrapErrorInStatus(errors.New("remote echo timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) + ErrRemoteAckTimeout = WrapErrorInStatus(errors.New("remote ack timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) ErrPublicMediaDisabled = WrapErrorInStatus(errors.New("public media is not enabled in the bridge config")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported).WithSendNotice(true) ErrPublicMediaDatabaseDisabled = WrapErrorInStatus(errors.New("public media database storage is disabled")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported).WithSendNotice(true) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index e34e3252..cdfc2568 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -151,6 +151,7 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) { br.EventProcessor.On(event.StateTombstone, br.handleRoomEvent) br.EventProcessor.On(event.StateBeeperDisappearingTimer, br.handleRoomEvent) br.EventProcessor.On(event.BeeperDeleteChat, br.handleRoomEvent) + br.EventProcessor.On(event.BeeperAcceptMessageRequest, br.handleRoomEvent) br.EventProcessor.On(event.EphemeralEventReceipt, br.handleEphemeralEvent) br.EventProcessor.On(event.EphemeralEventTyping, br.handleEphemeralEvent) br.Bot = br.AS.BotIntent() diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 9c3f7d71..adbd3155 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -712,6 +712,14 @@ type DeleteChatHandlingNetworkAPI interface { HandleMatrixDeleteChat(ctx context.Context, msg *MatrixDeleteChat) error } +// MessageRequestAcceptingNetworkAPI is an optional interface that network connectors +// can implement to accept message requests from the remote network. +type MessageRequestAcceptingNetworkAPI interface { + NetworkAPI + // HandleMatrixAcceptMessageRequest is called when the user accepts a message request. + HandleMatrixAcceptMessageRequest(ctx context.Context, msg *MatrixAcceptMessageRequest) error +} + type ResolveIdentifierResponse struct { // Ghost is the ghost of the user that the identifier resolves to. // This field should be set whenever possible. However, it is not required, @@ -1419,6 +1427,7 @@ type MatrixViewingChat struct { } type MatrixDeleteChat = MatrixEventBase[*event.BeeperChatDeleteEventContent] +type MatrixAcceptMessageRequest = MatrixEventBase[*event.BeeperAcceptMessageRequestEventContent] type MatrixMarkedUnread = MatrixRoomMeta[*event.MarkedUnreadEventContent] type MatrixMute = MatrixRoomMeta[*event.BeeperMuteEventContent] type MatrixRoomTag = MatrixRoomMeta[*event.TagEventContent] diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 0d71535d..7ca3ffab 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -800,6 +800,8 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * return portal.handleMatrixPowerLevels(ctx, login, origSender, evt, isStateRequest) case event.BeeperDeleteChat: return portal.handleMatrixDeleteChat(ctx, login, origSender, evt) + case event.BeeperAcceptMessageRequest: + return portal.handleMatrixAcceptMessageRequest(ctx, login, origSender, evt) default: return EventHandlingResultIgnored } @@ -1749,6 +1751,45 @@ func (portal *Portal) getTargetUser(ctx context.Context, userID id.UserID) (Ghos } } +func (portal *Portal) handleMatrixAcceptMessageRequest( + ctx context.Context, + sender *UserLogin, + origSender *OrigSender, + evt *event.Event, +) EventHandlingResult { + if origSender != nil { + return EventHandlingResultFailed.WithMSSError(ErrIgnoringAcceptRequestRelayedUser) + } + log := zerolog.Ctx(ctx) + content, ok := evt.Content.Parsed.(*event.BeeperAcceptMessageRequestEventContent) + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + api, ok := sender.Client.(MessageRequestAcceptingNetworkAPI) + if !ok { + return EventHandlingResultIgnored.WithMSSError(ErrDeleteChatNotSupported) + } + err := api.HandleMatrixAcceptMessageRequest(ctx, &MatrixAcceptMessageRequest{ + Event: evt, + Content: content, + Portal: portal, + }) + if err != nil { + log.Err(err).Msg("Failed to handle Matrix accept message request") + return EventHandlingResultFailed.WithMSSError(err) + } + if portal.MessageRequest { + portal.MessageRequest = false + portal.UpdateBridgeInfo(ctx) + err = portal.Save(ctx) + if err != nil { + log.Err(err).Msg("Failed to save portal after accepting message request") + } + } + return EventHandlingResultSuccess.WithMSS() +} + func (portal *Portal) handleMatrixDeleteChat( ctx context.Context, sender *UserLogin, @@ -3948,9 +3989,9 @@ type ChatInfo struct { Disappear *database.DisappearingSetting ParentID *networkid.PortalID - UserLocal *UserLocalPortalInfo - - CanBackfill bool + UserLocal *UserLocalPortalInfo + MessageRequest *bool + CanBackfill bool ExcludeChangesFromTimeline bool @@ -4070,10 +4111,11 @@ func (portal *Portal) getBridgeInfo() (string, event.BridgeEventContent) { Creator: portal.Bridge.Bot.GetMXID(), Protocol: portal.Bridge.Network.GetName().AsBridgeInfoSection(), Channel: event.BridgeInfoSection{ - ID: string(portal.ID), - DisplayName: portal.Name, - AvatarURL: portal.AvatarMXC, - Receiver: string(portal.Receiver), + ID: string(portal.ID), + DisplayName: portal.Name, + AvatarURL: portal.AvatarMXC, + Receiver: string(portal.Receiver), + MessageRequest: portal.MessageRequest, // TODO external URL? }, BeeperRoomTypeV2: string(portal.RoomType), @@ -4815,6 +4857,10 @@ func (portal *Portal) UpdateInfo(ctx context.Context, info *ChatInfo, source *Us portal.RoomType = *info.Type } } + if info.MessageRequest != nil && *info.MessageRequest != portal.MessageRequest { + changed = true + portal.MessageRequest = *info.MessageRequest + } if info.Members != nil && portal.MXID != "" && source != nil { err := portal.syncParticipants(ctx, info.Members, source, nil, time.Time{}) if err != nil { diff --git a/event/beeper.go b/event/beeper.go index 94892de7..75c18aa7 100644 --- a/event/beeper.go +++ b/event/beeper.go @@ -89,7 +89,11 @@ type BeeperRoomKeyAckEventContent struct { } type BeeperChatDeleteEventContent struct { - DeleteForEveryone bool `json:"delete_for_everyone,omitempty"` + DeleteForEveryone bool `json:"delete_for_everyone,omitempty"` + FromMessageRequest bool `json:"from_message_request,omitempty"` +} + +type BeeperAcceptMessageRequestEventContent struct { } type BeeperSendStateEventContent struct { diff --git a/event/capabilities.d.ts b/event/capabilities.d.ts index 1fbc9610..26aeb347 100644 --- a/event/capabilities.d.ts +++ b/event/capabilities.d.ts @@ -77,6 +77,11 @@ export interface RoomFeatures { delete_chat?: boolean /** Whether deleting the chat for all participants is supported. */ delete_chat_for_everyone?: boolean + /** What can be done with message requests? */ + message_request?: { + accept_with_message?: CapabilitySupportLevel + accept_with_button?: CapabilitySupportLevel + } } declare type integer = number diff --git a/event/capabilities.go b/event/capabilities.go index 4b7ff186..a86c726b 100644 --- a/event/capabilities.go +++ b/event/capabilities.go @@ -61,6 +61,8 @@ type RoomFeatures struct { DeleteChat bool `json:"delete_chat,omitempty"` DeleteChatForEveryone bool `json:"delete_chat_for_everyone,omitempty"` + MessageRequest *MessageRequestFeatures `json:"message_request,omitempty"` + PerMessageProfileRelay bool `json:"-"` } @@ -84,6 +86,7 @@ func (rf *RoomFeatures) Clone() *RoomFeatures { clone.DeleteMaxAge = ptr.Clone(clone.DeleteMaxAge) clone.DisappearingTimer = clone.DisappearingTimer.Clone() clone.AllowedReactions = slices.Clone(clone.AllowedReactions) + clone.MessageRequest = clone.MessageRequest.Clone() return &clone } @@ -165,6 +168,25 @@ func (dtc *DisappearingTimerCapability) Supports(content *BeeperDisappearingTime return slices.Contains(dtc.Types, content.Type) && (dtc.Timers == nil || slices.Contains(dtc.Timers, content.Timer)) } +type MessageRequestFeatures struct { + AcceptWithMessage CapabilitySupportLevel `json:"accept_with_message,omitempty"` + AcceptWithButton CapabilitySupportLevel `json:"accept_with_button,omitempty"` +} + +func (mrf *MessageRequestFeatures) Clone() *MessageRequestFeatures { + return ptr.Clone(mrf) +} + +func (mrf *MessageRequestFeatures) Hash() []byte { + if mrf == nil { + return nil + } + hasher := sha256.New() + hashValue(hasher, "accept_with_message", mrf.AcceptWithMessage) + hashValue(hasher, "accept_with_button", mrf.AcceptWithButton) + return hasher.Sum(nil) +} + type CapabilityMsgType = MessageType // Message types which are used for event capability signaling, but aren't real values for the msgtype field. @@ -347,6 +369,7 @@ func (rf *RoomFeatures) Hash() []byte { hashBool(hasher, "mark_as_unread", rf.MarkAsUnread) hashBool(hasher, "delete_chat", rf.DeleteChat) hashBool(hasher, "delete_chat_for_everyone", rf.DeleteChatForEveryone) + hashValue(hasher, "message_request", rf.MessageRequest) return hasher.Sum(nil) } diff --git a/event/content.go b/event/content.go index 73fb0db5..4929c6a5 100644 --- a/event/content.go +++ b/event/content.go @@ -61,10 +61,11 @@ var TypeMap = map[Type]reflect.Type{ EventUnstablePollStart: reflect.TypeOf(PollStartEventContent{}), EventUnstablePollResponse: reflect.TypeOf(PollResponseEventContent{}), - BeeperMessageStatus: reflect.TypeOf(BeeperMessageStatusEventContent{}), - BeeperTranscription: reflect.TypeOf(BeeperTranscriptionEventContent{}), - BeeperDeleteChat: reflect.TypeOf(BeeperChatDeleteEventContent{}), - BeeperSendState: reflect.TypeOf(BeeperSendStateEventContent{}), + BeeperMessageStatus: reflect.TypeOf(BeeperMessageStatusEventContent{}), + BeeperTranscription: reflect.TypeOf(BeeperTranscriptionEventContent{}), + BeeperDeleteChat: reflect.TypeOf(BeeperChatDeleteEventContent{}), + BeeperAcceptMessageRequest: reflect.TypeOf(BeeperAcceptMessageRequestEventContent{}), + BeeperSendState: reflect.TypeOf(BeeperSendStateEventContent{}), AccountDataRoomTags: reflect.TypeOf(TagEventContent{}), AccountDataDirectChats: reflect.TypeOf(DirectChatsEventContent{}), diff --git a/event/state.go b/event/state.go index 6df3b143..29e0e524 100644 --- a/event/state.go +++ b/event/state.go @@ -231,7 +231,8 @@ type BridgeInfoSection struct { AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` ExternalURL string `json:"external_url,omitempty"` - Receiver string `json:"fi.mau.receiver,omitempty"` + Receiver string `json:"fi.mau.receiver,omitempty"` + MessageRequest bool `json:"com.beeper.message_request,omitempty"` } // BridgeEventContent represents the content of a m.bridge state event. diff --git a/event/type.go b/event/type.go index 4fca07ea..f4d7592c 100644 --- a/event/type.go +++ b/event/type.go @@ -128,7 +128,7 @@ func (et *Type) GuessClass() TypeClass { InRoomVerificationKey.Type, InRoomVerificationMAC.Type, InRoomVerificationCancel.Type, CallInvite.Type, CallCandidates.Type, CallAnswer.Type, CallReject.Type, CallSelectAnswer.Type, CallNegotiate.Type, CallHangup.Type, BeeperMessageStatus.Type, EventUnstablePollStart.Type, EventUnstablePollResponse.Type, - EventUnstablePollEnd.Type, BeeperTranscription.Type, BeeperDeleteChat.Type: + EventUnstablePollEnd.Type, BeeperTranscription.Type, BeeperDeleteChat.Type, BeeperAcceptMessageRequest.Type: return MessageEventType case ToDeviceRoomKey.Type, ToDeviceRoomKeyRequest.Type, ToDeviceForwardedRoomKey.Type, ToDeviceRoomKeyWithheld.Type, ToDeviceBeeperRoomKeyAck.Type: @@ -234,10 +234,11 @@ var ( CallNegotiate = Type{"m.call.negotiate", MessageEventType} CallHangup = Type{"m.call.hangup", MessageEventType} - BeeperMessageStatus = Type{"com.beeper.message_send_status", MessageEventType} - BeeperTranscription = Type{"com.beeper.transcription", MessageEventType} - BeeperDeleteChat = Type{"com.beeper.delete_chat", MessageEventType} - BeeperSendState = Type{"com.beeper.send_state", MessageEventType} + BeeperMessageStatus = Type{"com.beeper.message_send_status", MessageEventType} + BeeperTranscription = Type{"com.beeper.transcription", MessageEventType} + BeeperDeleteChat = Type{"com.beeper.delete_chat", MessageEventType} + BeeperAcceptMessageRequest = Type{"com.beeper.accept_message_request", MessageEventType} + BeeperSendState = Type{"com.beeper.send_state", MessageEventType} EventUnstablePollStart = Type{Type: "org.matrix.msc3381.poll.start", Class: MessageEventType} EventUnstablePollResponse = Type{Type: "org.matrix.msc3381.poll.response", Class: MessageEventType} From 2c62641c739c36edf0c197efe89506d0c67c8c0c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 10 Dec 2025 13:15:33 +0200 Subject: [PATCH 481/581] bridgev2/portal: make queueEvent slightly safer when deleting portals --- bridgev2/errors.go | 2 ++ bridgev2/portal.go | 39 ++++++++++++++++++++++++++------------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/bridgev2/errors.go b/bridgev2/errors.go index a6cf4ceb..c39f8707 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -38,6 +38,8 @@ var ErrNotLoggedIn = errors.New("not logged in") // but direct media is not enabled. var ErrDirectMediaNotEnabled = errors.New("direct media is not enabled") +var ErrPortalIsDeleted = errors.New("portal is deleted") + // Common message status errors var ( ErrPanicInEventHandler error = WrapErrorInStatus(errors.New("panic in event handler")).WithSendNotice(true).WithErrorAsMessage() diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 7ca3ffab..273b1fd3 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -93,7 +93,7 @@ type Portal struct { functionalMembersCache *event.ElementFunctionalMembersContent events chan portalEvent - deleted bool + deleted *exsync.Event eventsLock sync.Mutex eventIdx int @@ -127,6 +127,7 @@ func (br *Bridge) loadPortal(ctx context.Context, dbPortal *database.Portal, que outgoingMessages: make(map[networkid.TransactionID]*outgoingMessage), RoomCreated: exsync.NewEvent(), + deleted: exsync.NewEvent(), } if portal.MXID != "" { portal.RoomCreated.Set() @@ -335,6 +336,9 @@ func (br *Bridge) GetExistingPortalByKey(ctx context.Context, key networkid.Port } func (portal *Portal) queueEvent(ctx context.Context, evt portalEvent) EventHandlingResult { + if portal.deleted.IsSet() { + return EventHandlingResultIgnored + } if PortalEventBuffer == 0 { portal.eventsLock.Lock() defer portal.eventsLock.Unlock() @@ -347,6 +351,8 @@ func (portal *Portal) queueEvent(ctx context.Context, evt portalEvent) EventHand select { case portal.events <- evt: return EventHandlingResultQueued + case <-portal.deleted.GetChan(): + return EventHandlingResultIgnored default: zerolog.Ctx(ctx).Error(). Str("portal_id", string(portal.ID)). @@ -371,16 +377,16 @@ func (portal *Portal) eventLoop() { go portal.pendingMessageTimeoutLoop(ctx, cfg) defer cancel() } - i := 0 - for rawEvt := range portal.events { - if portal.deleted { - return - } - i++ - if portal.Bridge.Config.AsyncEvents { - go portal.handleSingleEventWithDelayLogging(i, rawEvt) - } else { - portal.handleSingleEventWithDelayLogging(i, rawEvt) + deleteCh := portal.deleted.GetChan() + for i := 0; ; i++ { + select { + case rawEvt := <-portal.events: + if portal.Bridge.Config.AsyncEvents { + go portal.handleSingleEventWithDelayLogging(i, rawEvt) + } else { + portal.handleSingleEventWithDelayLogging(i, rawEvt) + } + case <-deleteCh: } } } @@ -4902,6 +4908,9 @@ func (portal *Portal) CreateMatrixRoom(ctx context.Context, source *UserLogin, i } return nil } + if portal.deleted.IsSet() { + return ErrPortalIsDeleted + } waiter := make(chan struct{}) closed := false evt := &portalCreateEvent{ @@ -4919,7 +4928,11 @@ func (portal *Portal) CreateMatrixRoom(ctx context.Context, source *UserLogin, i if PortalEventBuffer == 0 { go portal.queueEvent(ctx, evt) } else { - portal.events <- evt + select { + case portal.events <- evt: + case <-portal.deleted.GetChan(): + return ErrPortalIsDeleted + } } select { case <-ctx.Done(): @@ -5245,11 +5258,11 @@ func (portal *Portal) unlockedDeleteCache() { if portal.MXID != "" { delete(portal.Bridge.portalsByMXID, portal.MXID) } + portal.deleted.Set() if portal.events != nil { // TODO there's a small risk of this racing with a queueEvent call close(portal.events) } - portal.deleted = true } func (portal *Portal) Save(ctx context.Context) error { From efd4136c7a9361090dbbac69d9a7af59a568e68e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 11 Dec 2025 14:17:45 +0200 Subject: [PATCH 482/581] dependencies: update --- go.mod | 16 ++++++++-------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index bf56a014..0b86e5da 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module maunium.net/go/mautrix go 1.24.0 -toolchain go1.25.4 +toolchain go1.25.5 require ( filippo.io/edwards25519 v1.1.0 @@ -17,12 +17,12 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.13 - go.mau.fi/util v0.9.4-0.20251206205611-85e6fd6551e0 + go.mau.fi/util v0.9.4-0.20251211121531-f6527b4882ae go.mau.fi/zeroconfig v0.2.0 - golang.org/x/crypto v0.45.0 - golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 - golang.org/x/net v0.47.0 - golang.org/x/sync v0.18.0 + golang.org/x/crypto v0.46.0 + golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 + golang.org/x/net v0.48.0 + golang.org/x/sync v0.19.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 ) @@ -36,7 +36,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index 6ea3f378..5e9eded0 100644 --- a/go.sum +++ b/go.sum @@ -51,26 +51,26 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.4-0.20251206205611-85e6fd6551e0 h1:ESebxPGULuuxxcZigjcBFyyU62tiyY6ivtX17P4BkvY= -go.mau.fi/util v0.9.4-0.20251206205611-85e6fd6551e0/go.mod h1:viDmhBOAFfcqDdKSk53EPJV3N4Mi8Jst5/ahGJ/vwsA= +go.mau.fi/util v0.9.4-0.20251211121531-f6527b4882ae h1:tocQOutgT+Z/V6w668Jpk3D5942K5p25XmRAvXg8s2E= +go.mau.fi/util v0.9.4-0.20251211121531-f6527b4882ae/go.mod h1:OwI76F1QINxtH/TOydGAAj5/VvtPG0RnZzB41rtnKcA= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= -golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= From 9e3fa96fb42f287ace7369637ba4973883133df3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 12 Dec 2025 17:31:56 +0200 Subject: [PATCH 483/581] bridgev2/portal: handle portal deletion edge cases --- bridgev2/errors.go | 1 + bridgev2/portal.go | 6 ++++++ bridgev2/queue.go | 6 +++--- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/bridgev2/errors.go b/bridgev2/errors.go index c39f8707..514dc238 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -39,6 +39,7 @@ var ErrNotLoggedIn = errors.New("not logged in") var ErrDirectMediaNotEnabled = errors.New("direct media is not enabled") var ErrPortalIsDeleted = errors.New("portal is deleted") +var ErrPortalNotFoundInEventHandler = errors.New("portal not found to handle remote event") // Common message status errors var ( diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 273b1fd3..7d479be2 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -5195,6 +5195,9 @@ func (portal *Portal) addToUserSpaces(ctx context.Context) { } func (portal *Portal) Delete(ctx context.Context) error { + if portal.deleted.IsSet() { + return nil + } portal.removeInPortalCache(ctx) err := portal.Bridge.DB.Portal.Delete(ctx, portal.PortalKey) if err != nil { @@ -5254,6 +5257,9 @@ func (portal *Portal) unlockedDelete(ctx context.Context) error { } func (portal *Portal) unlockedDeleteCache() { + if portal.deleted.IsSet() { + return + } delete(portal.Bridge.portalsByKey, portal.PortalKey) if portal.MXID != "" { delete(portal.Bridge.portalsByMXID, portal.MXID) diff --git a/bridgev2/queue.go b/bridgev2/queue.go index 6667caea..3775c825 100644 --- a/bridgev2/queue.go +++ b/bridgev2/queue.go @@ -220,7 +220,7 @@ func (ul *UserLogin) QueueRemoteEvent(evt RemoteEvent) EventHandlingResult { return ul.Bridge.QueueRemoteEvent(ul, evt) } -func (br *Bridge) QueueRemoteEvent(login *UserLogin, evt RemoteEvent) (res EventHandlingResult) { +func (br *Bridge) QueueRemoteEvent(login *UserLogin, evt RemoteEvent) EventHandlingResult { log := login.Log ctx := log.WithContext(br.BackgroundCtx) maybeUncertain, ok := evt.(RemoteEventWithUncertainPortalReceiver) @@ -236,14 +236,14 @@ func (br *Bridge) QueueRemoteEvent(login *UserLogin, evt RemoteEvent) (res Event if err != nil { log.Err(err).Object("portal_key", key).Bool("uncertain_receiver", isUncertain). Msg("Failed to get portal to handle remote event") - return + return EventHandlingResultFailed.WithError(fmt.Errorf("failed to get portal: %w", err)) } else if portal == nil { log.Warn(). Stringer("event_type", evt.GetType()). Object("portal_key", key). Bool("uncertain_receiver", isUncertain). Msg("Portal not found to handle remote event") - return + return EventHandlingResultFailed.WithError(ErrPortalNotFoundInEventHandler) } // TODO put this in a better place, and maybe cache to avoid constant db queries login.MarkInPortal(ctx, portal) From de52a753be8aad17ccfd4ec89ad4a16bd222be14 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 13 Dec 2025 10:47:37 +0200 Subject: [PATCH 484/581] bridgev2: remove hardcoded room version --- bridgev2/matrix/connector.go | 17 +++++++++++++++++ bridgev2/matrix/intent.go | 34 ++++++++++++++++++++++++++++++++++ bridgev2/portal.go | 1 - bridgev2/space.go | 3 +-- bridgev2/user.go | 5 ++--- 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index cdfc2568..aed6d3bd 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -81,6 +81,8 @@ type Connector struct { MediaConfig mautrix.RespMediaConfig SpecVersions *mautrix.RespVersions + SpecCaps *mautrix.RespCapabilities + specCapsLock sync.Mutex Capabilities *bridgev2.MatrixCapabilities IgnoreUnsupportedServer bool @@ -409,6 +411,21 @@ func (br *Connector) ensureConnection(ctx context.Context) { br.Bot.EnsureAppserviceConnection(ctx) } +func (br *Connector) fetchCapabilities(ctx context.Context) *mautrix.RespCapabilities { + br.specCapsLock.Lock() + defer br.specCapsLock.Unlock() + if br.SpecCaps != nil { + return br.SpecCaps + } + caps, err := br.Bot.Capabilities(ctx) + if err != nil { + br.Log.Err(err).Msg("Failed to fetch capabilities from homeserver") + return nil + } + br.SpecCaps = caps + return caps +} + func (br *Connector) fetchMediaConfig(ctx context.Context) { cfg, err := br.Bot.GetMediaConfig(ctx) if err != nil { diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index 1f82f77f..44dcbc5b 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -512,6 +512,39 @@ func (br *Connector) getDefaultEncryptionEvent() *event.EncryptionEventContent { return content } +func (as *ASIntent) filterCreateRequestForV12(ctx context.Context, req *mautrix.ReqCreateRoom) { + if as.Connector.Config.Homeserver.Software == bridgeconfig.SoftwareHungry { + // Hungryserv doesn't override the capabilities endpoint nor do room versions + return + } + caps := as.Connector.fetchCapabilities(ctx) + roomVer := req.RoomVersion + if roomVer == "" && caps != nil && caps.RoomVersions != nil { + roomVer = id.RoomVersion(caps.RoomVersions.Default) + } + if roomVer != "" && !roomVer.PrivilegedRoomCreators() { + return + } + creators, _ := req.CreationContent["additional_creators"].([]id.UserID) + creators = append(slices.Clone(creators), as.GetMXID()) + if req.PowerLevelOverride != nil { + for _, creator := range creators { + delete(req.PowerLevelOverride.Users, creator) + } + } + for _, evt := range req.InitialState { + if evt.Type != event.StatePowerLevels { + continue + } + content, ok := evt.Content.Parsed.(*event.PowerLevelsEventContent) + if ok { + for _, creator := range creators { + delete(content.Users, creator) + } + } + } +} + func (as *ASIntent) CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom) (id.RoomID, error) { if as.Connector.Config.Encryption.Default { req.InitialState = append(req.InitialState, &event.Event{ @@ -527,6 +560,7 @@ func (as *ASIntent) CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom) } req.CreationContent["m.federate"] = false } + as.filterCreateRequestForV12(ctx, req) resp, err := as.Matrix.CreateRoom(ctx, req) if err != nil { return "", err diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 7d479be2..8bd66b6a 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4999,7 +4999,6 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo IsDirect: portal.RoomType == database.RoomTypeDM, PowerLevelOverride: powerLevels, BeeperLocalRoomID: portal.Bridge.Matrix.GenerateDeterministicRoomID(portal.PortalKey), - RoomVersion: id.RoomV11, } autoJoinInvites := portal.Bridge.Matrix.GetCapabilities().AutoJoinInvites if autoJoinInvites { diff --git a/bridgev2/space.go b/bridgev2/space.go index f6d07922..2ca2bce3 100644 --- a/bridgev2/space.go +++ b/bridgev2/space.go @@ -164,8 +164,7 @@ func (ul *UserLogin) GetSpaceRoom(ctx context.Context) (id.RoomID, error) { ul.UserMXID: 50, }, }, - RoomVersion: id.RoomV11, - Invite: []id.UserID{ul.UserMXID}, + Invite: []id.UserID{ul.UserMXID}, } if autoJoin { req.BeeperInitialMembers = []id.UserID{ul.UserMXID} diff --git a/bridgev2/user.go b/bridgev2/user.go index af9e9694..9a7896d6 100644 --- a/bridgev2/user.go +++ b/bridgev2/user.go @@ -229,9 +229,8 @@ func (user *User) GetManagementRoom(ctx context.Context) (id.RoomID, error) { user.MXID: 50, }, }, - RoomVersion: id.RoomV11, - Invite: []id.UserID{user.MXID}, - IsDirect: true, + Invite: []id.UserID{user.MXID}, + IsDirect: true, } if autoJoin { req.BeeperInitialMembers = []id.UserID{user.MXID} From 9dc3772c47bc3a89fee85903f44b5b84fa1676dc Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 13 Dec 2025 10:54:58 +0200 Subject: [PATCH 485/581] ci: update actions and pre-commit hooks --- .github/workflows/go.yml | 12 ++++++------ .github/workflows/stale.yml | 2 +- .pre-commit-config.yaml | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 8bce4484..deaa1f1d 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -10,10 +10,10 @@ jobs: runs-on: ubuntu-latest name: Lint (latest) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: "1.25" cache: true @@ -39,10 +39,10 @@ jobs: name: Build (${{ matrix.go-version == '1.25' && 'latest' || 'old' }}, libolm) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Go ${{ matrix.go-version }} - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} cache: true @@ -76,10 +76,10 @@ jobs: name: Build (${{ matrix.go-version == '1.25' && 'latest' || 'old' }}, goolm) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Go ${{ matrix.go-version }} - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} cache: true diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 578349c9..9a9e7375 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: lock-stale: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v5 + - uses: dessant/lock-threads@v6 id: lock with: issue-inactive-days: 90 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f769e56..616fccb2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: trailing-whitespace exclude_types: [markdown] @@ -9,7 +9,7 @@ repos: - id: check-added-large-files - repo: https://github.com/tekwizely/pre-commit-golang - rev: v1.0.0-rc.1 + rev: v1.0.0-rc.4 hooks: - id: go-imports-repo args: From cb6f673e7a700eed2e70147c5338fd06b183ba5d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 13 Dec 2025 11:09:09 +0200 Subject: [PATCH 486/581] bridgev2/portal: fix event loop not stopping --- bridgev2/portal.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 8bd66b6a..9ee277b3 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -381,12 +381,16 @@ func (portal *Portal) eventLoop() { for i := 0; ; i++ { select { case rawEvt := <-portal.events: + if rawEvt == nil { + return + } if portal.Bridge.Config.AsyncEvents { go portal.handleSingleEventWithDelayLogging(i, rawEvt) } else { portal.handleSingleEventWithDelayLogging(i, rawEvt) } case <-deleteCh: + return } } } From 4be256229706fa06a220bfe0d75cef1414da1cb0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 14 Dec 2025 14:37:57 +0200 Subject: [PATCH 487/581] changelog: update --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b30e055e..fa31c025 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ +## v0.26.1 (unreleased) + +* **Breaking change *(mediaproxy)*** Changed `GetMediaResponseFile` to return + the mime type from the callback rather than in the return get media return + value. The callback can now also redirect the caller to a different file. +* *(federation)* Added join/knock/leave functions + (thanks to [@nexy7574] in [#422]). +* *(federation/eventauth)* Fixed various incorrect checks. +* *(client)* Added backoff for retrying media uploads to external URLs + (with MSC3870). +* *(bridgev2/config)* Added support for overriding config fields using + environment variables. +* *(bridgev2/commands)* Added command to mute chat on remote network. +* *(bridgev2)* Added interface for network connectors to redirect to a different + user ID when handling an invite from Matrix. +* *(bridgev2)* Added interface for signaling message request status of portals. +* *(bridgev2)* Changed portal creation to not backfill unless `CanBackfill` flag + is set in chat info. +* *(bridgev2)* Changed Matrix reaction handling to only delete old reaction if + bridging the new one is successful. +* *(bridgev2/mxmain)* Improved error message when trying to run bridge with + pre-megabridge database when no database migration exists. +* *(bridgev2)* Improved reliability of database migration when enabling split + portals. +* *(bridgev2)* Improved detection of orphaned DM rooms when starting new chats. +* *(bridgev2)* Stopped sending redundant invites when joining ghosts to public + portal rooms. +* *(bridgev2)* Stopped hardcoding room versions in favor of checking + server capabilities to determine appropriate `/createRoom` parameters. + +[#422]: https://github.com/mautrix/go/pull/422 + ## v0.26.0 (2025-11-16) * *(client,appservice)* Deprecated `SendMassagedStateEvent` as `SendStateEvent` From 950ce6636e10f59484593df957dabe660c2804db Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 15 Dec 2025 15:18:40 +0200 Subject: [PATCH 488/581] crypto/goolm: include version number in version mismatches --- crypto/goolm/message/group_message.go | 2 +- crypto/goolm/message/message.go | 2 +- crypto/goolm/message/prekey_message.go | 2 +- crypto/goolm/ratchet/olm.go | 2 +- crypto/goolm/session/megolm_inbound_session.go | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crypto/goolm/message/group_message.go b/crypto/goolm/message/group_message.go index f3d22500..c83540c1 100644 --- a/crypto/goolm/message/group_message.go +++ b/crypto/goolm/message/group_message.go @@ -39,7 +39,7 @@ func (r *GroupMessage) Decode(input []byte) (err error) { return } if r.Version != protocolVersion { - return fmt.Errorf("GroupMessage.Decode: %w", olm.ErrWrongProtocolVersion) + return fmt.Errorf("GroupMessage.Decode: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, r.Version, protocolVersion) } for { diff --git a/crypto/goolm/message/message.go b/crypto/goolm/message/message.go index 9ef93630..b161a2d1 100644 --- a/crypto/goolm/message/message.go +++ b/crypto/goolm/message/message.go @@ -43,7 +43,7 @@ func (r *Message) Decode(input []byte) (err error) { return } if r.Version != protocolVersion { - return fmt.Errorf("Message.Decode: %w", olm.ErrWrongProtocolVersion) + return fmt.Errorf("Message.Decode: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, r.Version, protocolVersion) } for { diff --git a/crypto/goolm/message/prekey_message.go b/crypto/goolm/message/prekey_message.go index 760be4c9..4e3d495d 100644 --- a/crypto/goolm/message/prekey_message.go +++ b/crypto/goolm/message/prekey_message.go @@ -48,7 +48,7 @@ func (r *PreKeyMessage) Decode(input []byte) (err error) { return } if r.Version != protocolVersion { - return fmt.Errorf("PreKeyMessage.Decode: %w", olm.ErrWrongProtocolVersion) + return fmt.Errorf("PreKeyMessage.Decode: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, r.Version, protocolVersion) } for { diff --git a/crypto/goolm/ratchet/olm.go b/crypto/goolm/ratchet/olm.go index 229c9bd2..9901ada8 100644 --- a/crypto/goolm/ratchet/olm.go +++ b/crypto/goolm/ratchet/olm.go @@ -142,7 +142,7 @@ func (r *Ratchet) Decrypt(input []byte) ([]byte, error) { return nil, err } if message.Version != protocolVersion { - return nil, fmt.Errorf("decrypt: %w", olm.ErrWrongProtocolVersion) + return nil, fmt.Errorf("decrypt: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, message.Version, protocolVersion) } if !message.HasCounter || len(message.RatchetKey) == 0 || len(message.Ciphertext) == 0 { return nil, fmt.Errorf("decrypt: %w", olm.ErrBadMessageFormat) diff --git a/crypto/goolm/session/megolm_inbound_session.go b/crypto/goolm/session/megolm_inbound_session.go index fb88b73c..7ccbd26d 100644 --- a/crypto/goolm/session/megolm_inbound_session.go +++ b/crypto/goolm/session/megolm_inbound_session.go @@ -126,7 +126,7 @@ func (o *MegolmInboundSession) Decrypt(ciphertext []byte) ([]byte, uint, error) return nil, 0, err } if msg.Version != protocolVersion { - return nil, 0, fmt.Errorf("decrypt: %w", olm.ErrWrongProtocolVersion) + return nil, 0, fmt.Errorf("decrypt: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, msg.Version, protocolVersion) } if msg.Ciphertext == nil || !msg.HasMessageIndex { return nil, 0, fmt.Errorf("decrypt: %w", olm.ErrBadMessageFormat) From b9635964a5e36349a9ff7364f5b1173cce7aedb6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 16 Dec 2025 12:20:42 +0200 Subject: [PATCH 489/581] Bump version to v0.26.1 --- CHANGELOG.md | 2 +- go.mod | 4 ++-- go.sum | 7 ++++--- version.go | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa31c025..0fb1a105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## v0.26.1 (unreleased) +## v0.26.1 (2025-12-16) * **Breaking change *(mediaproxy)*** Changed `GetMediaResponseFile` to return the mime type from the callback rather than in the return get media return diff --git a/go.mod b/go.mod index 0b86e5da..cdb62f20 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.13 - go.mau.fi/util v0.9.4-0.20251211121531-f6527b4882ae + go.mau.fi/util v0.9.4 go.mau.fi/zeroconfig v0.2.0 golang.org/x/crypto v0.46.0 golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 @@ -28,7 +28,7 @@ require ( ) require ( - github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/coreos/go-systemd/v22 v22.6.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 5e9eded0..a55f0661 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,9 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= +github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -51,8 +52,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.4-0.20251211121531-f6527b4882ae h1:tocQOutgT+Z/V6w668Jpk3D5942K5p25XmRAvXg8s2E= -go.mau.fi/util v0.9.4-0.20251211121531-f6527b4882ae/go.mod h1:OwI76F1QINxtH/TOydGAAj5/VvtPG0RnZzB41rtnKcA= +go.mau.fi/util v0.9.4 h1:gWdUff+K2rCynRPysXalqqQyr2ahkSWaestH6YhSpso= +go.mau.fi/util v0.9.4/go.mod h1:647nVfwUvuhlZFOnro3aRNPmRd2y3iDha9USb8aKSmM= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= diff --git a/version.go b/version.go index f6d20c3f..46d3342c 100644 --- a/version.go +++ b/version.go @@ -8,7 +8,7 @@ import ( "strings" ) -const Version = "v0.26.0" +const Version = "v0.26.1" var GoModVersion = "" var Commit = "" From e9b262e67162251198e83bc863374e26d8546db5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 16 Dec 2025 16:23:44 +0200 Subject: [PATCH 490/581] bridgev2/database: add index for disappearing messages and portal parents --- bridgev2/database/upgrades/00-latest.sql | 4 +++- .../upgrades/26-disappearing-message-portal-index.sql | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 bridgev2/database/upgrades/26-disappearing-message-portal-index.sql diff --git a/bridgev2/database/upgrades/00-latest.sql b/bridgev2/database/upgrades/00-latest.sql index b01cca44..b193d314 100644 --- a/bridgev2/database/upgrades/00-latest.sql +++ b/bridgev2/database/upgrades/00-latest.sql @@ -1,4 +1,4 @@ --- v0 -> v25 (compatible with v9+): Latest revision +-- v0 -> v26 (compatible with v9+): Latest revision CREATE TABLE "user" ( bridge_id TEXT NOT NULL, mxid TEXT NOT NULL, @@ -65,6 +65,7 @@ CREATE TABLE portal ( ON DELETE SET NULL ON UPDATE CASCADE ); CREATE UNIQUE INDEX portal_bridge_mxid_idx ON portal (bridge_id, mxid); +CREATE INDEX portal_parent_idx ON portal (bridge_id, parent_id, parent_receiver); CREATE TABLE ghost ( bridge_id TEXT NOT NULL, @@ -139,6 +140,7 @@ CREATE TABLE disappearing_message ( REFERENCES portal (bridge_id, mxid) ON DELETE CASCADE ); +CREATE INDEX disappearing_message_portal_idx ON disappearing_message (bridge_id, mx_room); CREATE TABLE reaction ( bridge_id TEXT NOT NULL, diff --git a/bridgev2/database/upgrades/26-disappearing-message-portal-index.sql b/bridgev2/database/upgrades/26-disappearing-message-portal-index.sql new file mode 100644 index 00000000..ae5d8cad --- /dev/null +++ b/bridgev2/database/upgrades/26-disappearing-message-portal-index.sql @@ -0,0 +1,3 @@ +-- v26 (compatible with v9+): Add room index for disappearing message table and portal parents +CREATE INDEX disappearing_message_portal_idx ON disappearing_message (bridge_id, mx_room); +CREATE INDEX portal_parent_idx ON portal (bridge_id, parent_id, parent_receiver); From e38d758a525a0b59786497f221b52684ea468e23 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 16 Dec 2025 16:59:54 +0200 Subject: [PATCH 491/581] bridgev2/database: delete messages in chunks if portal has too many --- bridgev2/database/message.go | 75 ++++++++++++++++++++++++++++++++++++ bridgev2/portal.go | 14 +++++-- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/bridgev2/database/message.go b/bridgev2/database/message.go index 9b3b1493..a1af1556 100644 --- a/bridgev2/database/message.go +++ b/bridgev2/database/message.go @@ -11,9 +11,11 @@ import ( "crypto/sha256" "database/sql" "encoding/base64" + "fmt" "strings" "time" + "github.com/rs/zerolog" "go.mau.fi/util/dbutil" "maunium.net/go/mautrix/bridgev2/networkid" @@ -96,6 +98,10 @@ const ( deleteMessagePartByRowIDQuery = ` DELETE FROM message WHERE bridge_id=$1 AND rowid=$2 ` + deleteMessageChunkQuery = ` + DELETE FROM message WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND rowid > $4 AND rowid <= $5 + ` + getMaxMessageRowIDQuery = `SELECT MAX(rowid) FROM message WHERE bridge_id=$1` ) func (mq *MessageQuery) GetAllPartsByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) ([]*Message, error) { @@ -180,6 +186,75 @@ func (mq *MessageQuery) Delete(ctx context.Context, rowID int64) error { return mq.Exec(ctx, deleteMessagePartByRowIDQuery, mq.BridgeID, rowID) } +func (mq *MessageQuery) deleteChunk(ctx context.Context, portal networkid.PortalKey, minRowID, maxRowID int64) (int64, error) { + res, err := mq.GetDB().Exec(ctx, deleteMessageChunkQuery, mq.BridgeID, portal.ID, portal.Receiver, minRowID, maxRowID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (mq *MessageQuery) getMaxRowID(ctx context.Context) (maxRowID int64, err error) { + err = mq.GetDB().QueryRow(ctx, getMaxMessageRowIDQuery, mq.BridgeID).Scan(&maxRowID) + return +} + +const deleteChunkSize = 100_000 + +func (mq *MessageQuery) DeleteInChunks(ctx context.Context, portal networkid.PortalKey) error { + if mq.GetDB().Dialect != dbutil.SQLite { + return nil + } + total, err := mq.CountMessagesInPortal(ctx, portal) + if err != nil { + return fmt.Errorf("failed to count messages in portal: %w", err) + } else if total < deleteChunkSize { + return nil + } + globalMaxRowID, err := mq.getMaxRowID(ctx) + if err != nil { + return fmt.Errorf("failed to get max row ID: %w", err) + } + zerolog.Ctx(ctx).Debug(). + Int("total_count", total). + Int64("global_max_row_id", globalMaxRowID). + Msg("Portal has lots of messages, deleting in chunks to avoid database locks") + maxRowID := int64(deleteChunkSize) + globalMaxRowID += deleteChunkSize * 1.2 + var dbTimeUsed time.Duration + globalStart := time.Now() + for total > 500 && maxRowID < globalMaxRowID { + start := time.Now() + count, err := mq.deleteChunk(ctx, portal, maxRowID-deleteChunkSize, maxRowID) + duration := time.Since(start) + dbTimeUsed += duration + if err != nil { + return fmt.Errorf("failed to delete chunk of messages before %d: %w", maxRowID, err) + } + total -= int(count) + maxRowID += deleteChunkSize + sleepTime := max(10*time.Millisecond, min(250*time.Millisecond, time.Duration(count/100)*time.Millisecond)) + zerolog.Ctx(ctx).Debug(). + Int64("max_row_id", maxRowID). + Int64("deleted_count", count). + Int("remaining_count", total). + Dur("duration", duration). + Dur("sleep_time", sleepTime). + Msg("Deleted chunk of messages") + select { + case <-time.After(sleepTime): + case <-ctx.Done(): + return ctx.Err() + } + } + zerolog.Ctx(ctx).Debug(). + Int("remaining_count", total). + Dur("db_time_used", dbTimeUsed). + Dur("total_duration", time.Since(globalStart)). + Msg("Finished chunked delete of messages in portal") + return nil +} + func (mq *MessageQuery) CountMessagesInPortal(ctx context.Context, key networkid.PortalKey) (count int, err error) { err = mq.GetDB().QueryRow(ctx, countMessagesInPortalQuery, mq.BridgeID, key.ID, key.Receiver).Scan(&count) return diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 9ee277b3..87b50c84 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -5202,7 +5202,7 @@ func (portal *Portal) Delete(ctx context.Context) error { return nil } portal.removeInPortalCache(ctx) - err := portal.Bridge.DB.Portal.Delete(ctx, portal.PortalKey) + err := portal.safeDBDelete(ctx) if err != nil { return err } @@ -5212,6 +5212,15 @@ func (portal *Portal) Delete(ctx context.Context) error { return nil } +func (portal *Portal) safeDBDelete(ctx context.Context) error { + err := portal.Bridge.DB.Message.DeleteInChunks(ctx, portal.PortalKey) + if err != nil { + return fmt.Errorf("failed to delete messages in portal: %w", err) + } + // TODO delete child portals? + return portal.Bridge.DB.Portal.Delete(ctx, portal.PortalKey) +} + func (portal *Portal) RemoveMXID(ctx context.Context) error { if portal.MXID == "" { return nil @@ -5250,8 +5259,7 @@ func (portal *Portal) removeInPortalCache(ctx context.Context) { } func (portal *Portal) unlockedDelete(ctx context.Context) error { - // TODO delete child portals? - err := portal.Bridge.DB.Portal.Delete(ctx, portal.PortalKey) + err := portal.safeDBDelete(ctx) if err != nil { return err } From b44f81d114cab8b153b78c454329365d9d684547 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 16 Dec 2025 18:57:39 +0200 Subject: [PATCH 492/581] bridgev2/database: only allow one chunked portal deletion at a time --- bridgev2/database/message.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/bridgev2/database/message.go b/bridgev2/database/message.go index a1af1556..2172c224 100644 --- a/bridgev2/database/message.go +++ b/bridgev2/database/message.go @@ -13,6 +13,7 @@ import ( "encoding/base64" "fmt" "strings" + "sync" "time" "github.com/rs/zerolog" @@ -26,6 +27,7 @@ type MessageQuery struct { BridgeID networkid.BridgeID MetaType MetaTypeCreator *dbutil.QueryHelper[*Message] + chunkDeleteLock sync.Mutex } type Message struct { @@ -205,6 +207,16 @@ func (mq *MessageQuery) DeleteInChunks(ctx context.Context, portal networkid.Por if mq.GetDB().Dialect != dbutil.SQLite { return nil } + log := zerolog.Ctx(ctx).With(). + Str("action", "delete messages in chunks"). + Stringer("portal_key", portal). + Logger() + if !mq.chunkDeleteLock.TryLock() { + log.Warn().Msg("Portal deletion lock is being held, waiting...") + mq.chunkDeleteLock.Lock() + log.Debug().Msg("Acquired portal deletion lock after waiting") + } + defer mq.chunkDeleteLock.Unlock() total, err := mq.CountMessagesInPortal(ctx, portal) if err != nil { return fmt.Errorf("failed to count messages in portal: %w", err) @@ -215,7 +227,7 @@ func (mq *MessageQuery) DeleteInChunks(ctx context.Context, portal networkid.Por if err != nil { return fmt.Errorf("failed to get max row ID: %w", err) } - zerolog.Ctx(ctx).Debug(). + log.Debug(). Int("total_count", total). Int64("global_max_row_id", globalMaxRowID). Msg("Portal has lots of messages, deleting in chunks to avoid database locks") @@ -234,7 +246,7 @@ func (mq *MessageQuery) DeleteInChunks(ctx context.Context, portal networkid.Por total -= int(count) maxRowID += deleteChunkSize sleepTime := max(10*time.Millisecond, min(250*time.Millisecond, time.Duration(count/100)*time.Millisecond)) - zerolog.Ctx(ctx).Debug(). + log.Debug(). Int64("max_row_id", maxRowID). Int64("deleted_count", count). Int("remaining_count", total). @@ -247,7 +259,7 @@ func (mq *MessageQuery) DeleteInChunks(ctx context.Context, portal networkid.Por return ctx.Err() } } - zerolog.Ctx(ctx).Debug(). + log.Debug(). Int("remaining_count", total). Dur("db_time_used", dbTimeUsed). Dur("total_duration", time.Since(globalStart)). From 33eb00fde0e21158361b3412784089b4bd14b20c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 16 Dec 2025 19:29:26 +0200 Subject: [PATCH 493/581] bridgev2/database: reduce limit for using chunked deletion --- bridgev2/database/message.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/database/message.go b/bridgev2/database/message.go index 2172c224..43f33666 100644 --- a/bridgev2/database/message.go +++ b/bridgev2/database/message.go @@ -220,7 +220,7 @@ func (mq *MessageQuery) DeleteInChunks(ctx context.Context, portal networkid.Por total, err := mq.CountMessagesInPortal(ctx, portal) if err != nil { return fmt.Errorf("failed to count messages in portal: %w", err) - } else if total < deleteChunkSize { + } else if total < deleteChunkSize/3 { return nil } globalMaxRowID, err := mq.getMaxRowID(ctx) From 80b4201ff1a6a6b7e7896b6a9f22f4ea2dc368c1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Dec 2025 13:03:19 +0200 Subject: [PATCH 494/581] bridgev2/portalreid: add more logs --- bridgev2/portalreid.go | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/bridgev2/portalreid.go b/bridgev2/portalreid.go index d1a9d5a6..e133b224 100644 --- a/bridgev2/portalreid.go +++ b/bridgev2/portalreid.go @@ -32,13 +32,22 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta if source == target { return ReIDResultError, nil, fmt.Errorf("illegal re-ID call: source and target are the same") } - log := zerolog.Ctx(ctx) - log.Debug().Msg("Re-ID'ing portal") + log := zerolog.Ctx(ctx).With(). + Str("action", "re-id portal"). + Stringer("source_portal_key", source). + Stringer("target_portal_key", target). + Logger() + ctx = log.WithContext(ctx) + if !br.cacheLock.TryLock() { + log.Debug().Msg("Waiting for cache lock") + br.cacheLock.Lock() + log.Debug().Msg("Acquired cache lock after waiting") + } defer func() { + br.cacheLock.Unlock() log.Debug().Msg("Finished handling portal re-ID") }() - br.cacheLock.Lock() - defer br.cacheLock.Unlock() + log.Debug().Msg("Re-ID'ing portal") sourcePortal, err := br.UnlockedGetPortalByKey(ctx, source, true) if err != nil { return ReIDResultError, nil, fmt.Errorf("failed to get source portal: %w", err) @@ -46,7 +55,11 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta log.Debug().Msg("Source portal not found, re-ID is no-op") return ReIDResultNoOp, nil, nil } - sourcePortal.roomCreateLock.Lock() + if !sourcePortal.roomCreateLock.TryLock() { + log.Debug().Msg("Waiting for source portal room creation lock") + sourcePortal.roomCreateLock.Lock() + log.Debug().Msg("Acquired source portal room creation lock after waiting") + } defer sourcePortal.roomCreateLock.Unlock() if sourcePortal.MXID == "" { log.Info().Msg("Source portal doesn't have Matrix room, deleting row") @@ -71,7 +84,11 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta } return ReIDResultSourceReIDd, sourcePortal, nil } - targetPortal.roomCreateLock.Lock() + if !targetPortal.roomCreateLock.TryLock() { + log.Debug().Msg("Waiting for target portal room creation lock") + targetPortal.roomCreateLock.Lock() + log.Debug().Msg("Acquired target portal room creation lock after waiting") + } defer targetPortal.roomCreateLock.Unlock() if targetPortal.MXID == "" { log.Info().Msg("Target portal row exists, but doesn't have a Matrix room. Deleting target portal row and re-ID'ing source portal") From af06098723ba6016a65f69a94bee630230b76829 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Dec 2025 13:06:34 +0200 Subject: [PATCH 495/581] bridgev2/simplevent: add method to merge log contexts --- bridgev2/simplevent/meta.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bridgev2/simplevent/meta.go b/bridgev2/simplevent/meta.go index 8aa91866..449a8773 100644 --- a/bridgev2/simplevent/meta.go +++ b/bridgev2/simplevent/meta.go @@ -101,6 +101,18 @@ func (evt EventMeta) WithLogContext(f func(c zerolog.Context) zerolog.Context) E return evt } +func (evt EventMeta) WithMoreLogContext(f func(c zerolog.Context) zerolog.Context) EventMeta { + origFunc := evt.LogContext + if origFunc == nil { + evt.LogContext = f + return evt + } + evt.LogContext = func(c zerolog.Context) zerolog.Context { + return f(origFunc(c)) + } + return evt +} + func (evt EventMeta) WithPortalKey(p networkid.PortalKey) EventMeta { evt.PortalKey = p return evt From 4825e41d5c5f57a3b2d9f628cf391aa1b2b6540b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Dec 2025 13:32:55 +0200 Subject: [PATCH 496/581] bridgev2/portalreid: try to cancel room creation --- bridgev2/portal.go | 25 +++++++++++++++++-------- bridgev2/portalreid.go | 6 ++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 87b50c84..e9feb448 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -86,8 +86,9 @@ type Portal struct { lastCapUpdate time.Time - roomCreateLock sync.Mutex - RoomCreated *exsync.Event + roomCreateLock sync.Mutex + cancelRoomCreate atomic.Pointer[context.CancelFunc] + RoomCreated *exsync.Event functionalMembersLock sync.Mutex functionalMembersCache *event.ElementFunctionalMembersContent @@ -4947,7 +4948,11 @@ func (portal *Portal) CreateMatrixRoom(ctx context.Context, source *UserLogin, i } func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLogin, info *ChatInfo, backfillBundle any) error { + cancellableCtx, cancel := context.WithCancel(ctx) + defer cancel() + portal.cancelRoomCreate.CompareAndSwap(nil, &cancel) portal.roomCreateLock.Lock() + portal.cancelRoomCreate.Store(&cancel) defer portal.roomCreateLock.Unlock() if portal.MXID != "" { if source != nil { @@ -4958,6 +4963,7 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo log := zerolog.Ctx(ctx).With(). Str("action", "create matrix room"). Logger() + cancellableCtx = log.WithContext(cancellableCtx) ctx = log.WithContext(ctx) log.Info().Msg("Creating Matrix room") @@ -4966,16 +4972,16 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo if info != nil { log.Warn().Msg("CreateMatrixRoom got info without members. Refetching info") } - info, err = source.Client.GetChatInfo(ctx, portal) + info, err = source.Client.GetChatInfo(cancellableCtx, portal) if err != nil { log.Err(err).Msg("Failed to update portal info for creation") return err } } - portal.UpdateInfo(ctx, info, source, nil, time.Time{}) - if ctx.Err() != nil { - return ctx.Err() + portal.UpdateInfo(cancellableCtx, info, source, nil, time.Time{}) + if cancellableCtx.Err() != nil { + return cancellableCtx.Err() } powerLevels := &event.PowerLevelsEventContent{ @@ -4988,7 +4994,7 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo portal.Bridge.Bot.GetMXID(): 9001, }, } - initialMembers, extraFunctionalMembers, err := portal.getInitialMemberList(ctx, info.Members, source, powerLevels) + initialMembers, extraFunctionalMembers, err := portal.getInitialMemberList(cancellableCtx, info.Members, source, powerLevels) if err != nil { log.Err(err).Msg("Failed to process participant list for portal creation") return err @@ -5015,7 +5021,7 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo req.CreationContent["type"] = event.RoomTypeSpace } bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() - roomFeatures := source.Client.GetCapabilities(ctx, portal) + roomFeatures := source.Client.GetCapabilities(cancellableCtx, portal) portal.CapState = database.CapabilityState{ Source: source.ID, ID: roomFeatures.GetID(), @@ -5097,6 +5103,9 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo Content: event.Content{Parsed: info.JoinRule}, }) } + if cancellableCtx.Err() != nil { + return cancellableCtx.Err() + } roomID, err := portal.Bridge.Bot.CreateRoom(ctx, &req) if err != nil { log.Err(err).Msg("Failed to create Matrix room") diff --git a/bridgev2/portalreid.go b/bridgev2/portalreid.go index e133b224..6a5091fc 100644 --- a/bridgev2/portalreid.go +++ b/bridgev2/portalreid.go @@ -56,6 +56,9 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta return ReIDResultNoOp, nil, nil } if !sourcePortal.roomCreateLock.TryLock() { + if cancelCreate := sourcePortal.cancelRoomCreate.Swap(nil); cancelCreate != nil { + (*cancelCreate)() + } log.Debug().Msg("Waiting for source portal room creation lock") sourcePortal.roomCreateLock.Lock() log.Debug().Msg("Acquired source portal room creation lock after waiting") @@ -85,6 +88,9 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta return ReIDResultSourceReIDd, sourcePortal, nil } if !targetPortal.roomCreateLock.TryLock() { + if cancelCreate := targetPortal.cancelRoomCreate.Swap(nil); cancelCreate != nil { + (*cancelCreate)() + } log.Debug().Msg("Waiting for target portal room creation lock") targetPortal.roomCreateLock.Lock() log.Debug().Msg("Acquired target portal room creation lock after waiting") From 59ec890dcb92844825c9ebce4b80173db1e18ee2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 19 Dec 2025 15:15:23 +0200 Subject: [PATCH 497/581] changelog: add missing link --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb1a105..8017ef97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -392,6 +392,7 @@ [MSC4156]: https://github.com/matrix-org/matrix-spec-proposals/pull/4156 [MSC4190]: https://github.com/matrix-org/matrix-spec-proposals/pull/4190 [#288]: https://github.com/mautrix/go/pull/288 +[@onestacked]: https://github.com/onestacked ## v0.22.0 (2024-11-16) From 788151bc505028ce7e50b217c06d86c3b5f0a246 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 30 Dec 2025 22:53:27 +0200 Subject: [PATCH 498/581] client: error if Download parameter is empty --- client.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client.go b/client.go index 90737581..e12e45d3 100644 --- a/client.go +++ b/client.go @@ -1835,6 +1835,9 @@ func (cli *Client) UploadLink(ctx context.Context, link string) (*RespMediaUploa } func (cli *Client) Download(ctx context.Context, mxcURL id.ContentURI) (*http.Response, error) { + if mxcURL.IsEmpty() { + return nil, fmt.Errorf("empty mxc uri provided to Download") + } _, resp, err := cli.MakeFullRequestWithResp(ctx, FullRequest{ Method: http.MethodGet, URL: cli.BuildClientURL("v1", "media", "download", mxcURL.Homeserver, mxcURL.FileID), @@ -1849,6 +1852,9 @@ type DownloadThumbnailExtra struct { } func (cli *Client) DownloadThumbnail(ctx context.Context, mxcURL id.ContentURI, height, width int, extras ...DownloadThumbnailExtra) (*http.Response, error) { + if mxcURL.IsEmpty() { + return nil, fmt.Errorf("empty mxc uri provided to DownloadThumbnail") + } if len(extras) > 1 { panic(fmt.Errorf("invalid number of arguments to DownloadThumbnail: %d", len(extras))) } From 3a2c6ae865ca2b4384d0d28bb50f5b3069c68d51 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 5 Jan 2026 14:58:29 +0200 Subject: [PATCH 499/581] client: stabilize MSC4323 --- client.go | 45 +++++++++++++++++++++++++++++++++------------ versions.go | 22 ++++++++++++---------- 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/client.go b/client.go index e12e45d3..87b6d87e 100644 --- a/client.go +++ b/client.go @@ -2724,30 +2724,51 @@ func (cli *Client) AdminWhoIs(ctx context.Context, userID id.UserID) (resp RespW return } -// UnstableGetSuspendedStatus uses MSC4323 to check if a user is suspended. -func (cli *Client) UnstableGetSuspendedStatus(ctx context.Context, userID id.UserID) (res *RespSuspended, err error) { - urlPath := cli.BuildClientURL("unstable", "uk.timedout.msc4323", "admin", "suspend", userID) +func (cli *Client) makeMSC4323URL(action string, target id.UserID) string { + if cli.SpecVersions.Supports(FeatureUnstableAccountModeration) { + return cli.BuildClientURL("unstable", "uk.timedout.msc4323", "admin", action, target) + } else if cli.SpecVersions.Supports(FeatureStableAccountModeration) { + return cli.BuildClientURL("v1", "admin", action, target) + } + return "" +} + +// GetSuspendedStatus uses MSC4323 to check if a user is suspended. +func (cli *Client) GetSuspendedStatus(ctx context.Context, userID id.UserID) (res *RespSuspended, err error) { + urlPath := cli.makeMSC4323URL("suspend", userID) + if urlPath == "" { + return nil, MUnrecognized.WithMessage("Homeserver does not advertise MSC4323 support") + } _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, res) return } -// UnstableGetLockStatus uses MSC4323 to check if a user is locked. -func (cli *Client) UnstableGetLockStatus(ctx context.Context, userID id.UserID) (res *RespLocked, err error) { - urlPath := cli.BuildClientURL("unstable", "uk.timedout.msc4323", "admin", "lock", userID) +// GetLockStatus uses MSC4323 to check if a user is locked. +func (cli *Client) GetLockStatus(ctx context.Context, userID id.UserID) (res *RespLocked, err error) { + urlPath := cli.makeMSC4323URL("lock", userID) + if urlPath == "" { + return nil, MUnrecognized.WithMessage("Homeserver does not advertise MSC4323 support") + } _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, res) return } -// UnstableSetSuspendedStatus uses MSC4323 to set whether a user account is suspended. -func (cli *Client) UnstableSetSuspendedStatus(ctx context.Context, userID id.UserID, suspended bool) (res *RespSuspended, err error) { - urlPath := cli.BuildClientURL("unstable", "uk.timedout.msc4323", "admin", "suspend", userID) +// SetSuspendedStatus uses MSC4323 to set whether a user account is suspended. +func (cli *Client) SetSuspendedStatus(ctx context.Context, userID id.UserID, suspended bool) (res *RespSuspended, err error) { + urlPath := cli.makeMSC4323URL("suspend", userID) + if urlPath == "" { + return nil, MUnrecognized.WithMessage("Homeserver does not advertise MSC4323 support") + } _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &ReqSuspend{Suspended: suspended}, res) return } -// UnstableSetLockStatus uses MSC4323 to set whether a user account is locked. -func (cli *Client) UnstableSetLockStatus(ctx context.Context, userID id.UserID, locked bool) (res *RespLocked, err error) { - urlPath := cli.BuildClientURL("unstable", "uk.timedout.msc4323", "admin", "lock", userID) +// SetLockStatus uses MSC4323 to set whether a user account is locked. +func (cli *Client) SetLockStatus(ctx context.Context, userID id.UserID, locked bool) (res *RespLocked, err error) { + urlPath := cli.makeMSC4323URL("lock", userID) + if urlPath == "" { + return nil, MUnrecognized.WithMessage("Homeserver does not advertise MSC4323 support") + } _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &ReqLocked{Locked: locked}, res) return } diff --git a/versions.go b/versions.go index 2aaf6399..8ae82a06 100644 --- a/versions.go +++ b/versions.go @@ -60,16 +60,17 @@ type UnstableFeature struct { } var ( - FeatureAsyncUploads = UnstableFeature{UnstableFlag: "fi.mau.msc2246.stable", SpecVersion: SpecV17} - FeatureAppservicePing = UnstableFeature{UnstableFlag: "fi.mau.msc2659.stable", SpecVersion: SpecV17} - FeatureAuthenticatedMedia = UnstableFeature{UnstableFlag: "org.matrix.msc3916.stable", SpecVersion: SpecV111} - FeatureMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms"} - FeatureUserRedaction = UnstableFeature{UnstableFlag: "org.matrix.msc4194"} - FeatureViewRedactedContent = UnstableFeature{UnstableFlag: "fi.mau.msc2815"} - FeatureAccountModeration = UnstableFeature{UnstableFlag: "uk.timedout.msc4323"} - FeatureUnstableProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133"} - FeatureArbitraryProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133.stable", SpecVersion: SpecV116} - FeatureRedactSendAsEvent = UnstableFeature{UnstableFlag: "com.beeper.msc4169"} + FeatureAsyncUploads = UnstableFeature{UnstableFlag: "fi.mau.msc2246.stable", SpecVersion: SpecV17} + FeatureAppservicePing = UnstableFeature{UnstableFlag: "fi.mau.msc2659.stable", SpecVersion: SpecV17} + FeatureAuthenticatedMedia = UnstableFeature{UnstableFlag: "org.matrix.msc3916.stable", SpecVersion: SpecV111} + FeatureMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms"} + FeatureUserRedaction = UnstableFeature{UnstableFlag: "org.matrix.msc4194"} + FeatureViewRedactedContent = UnstableFeature{UnstableFlag: "fi.mau.msc2815"} + FeatureUnstableAccountModeration = UnstableFeature{UnstableFlag: "uk.timedout.msc4323"} + FeatureStableAccountModeration = UnstableFeature{UnstableFlag: "uk.timedout.msc4323.stable" /*, SpecVersion: SpecV118*/} + FeatureUnstableProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133"} + FeatureArbitraryProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133.stable", SpecVersion: SpecV116} + FeatureRedactSendAsEvent = UnstableFeature{UnstableFlag: "com.beeper.msc4169"} BeeperFeatureHungry = UnstableFeature{UnstableFlag: "com.beeper.hungry"} BeeperFeatureBatchSending = UnstableFeature{UnstableFlag: "com.beeper.batch_sending"} @@ -123,6 +124,7 @@ var ( SpecV114 = MustParseSpecVersion("v1.14") SpecV115 = MustParseSpecVersion("v1.15") SpecV116 = MustParseSpecVersion("v1.16") + SpecV117 = MustParseSpecVersion("v1.17") ) func (svf SpecVersionFormat) String() string { From f4434b33c638af2dc8eb8b186ba8e180a407c81c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 7 Jan 2026 19:22:32 +0200 Subject: [PATCH 500/581] crypto,bridgev2: add option to encrypt reactions and replies (#445) --- bridgev2/bridgeconfig/encryption.go | 1 + bridgev2/bridgeconfig/upgrade.go | 1 + bridgev2/matrix/intent.go | 2 +- bridgev2/matrix/mxmain/example-config.yaml | 2 ++ crypto/encryptmegolm.go | 9 +++++++++ crypto/machine.go | 1 + 6 files changed, 15 insertions(+), 1 deletion(-) diff --git a/bridgev2/bridgeconfig/encryption.go b/bridgev2/bridgeconfig/encryption.go index 5a19b3ad..934613ca 100644 --- a/bridgev2/bridgeconfig/encryption.go +++ b/bridgev2/bridgeconfig/encryption.go @@ -16,6 +16,7 @@ type EncryptionConfig struct { Require bool `yaml:"require"` Appservice bool `yaml:"appservice"` MSC4190 bool `yaml:"msc4190"` + MSC4392 bool `yaml:"msc4392"` SelfSign bool `yaml:"self_sign"` PlaintextMentions bool `yaml:"plaintext_mentions"` diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index 960e2fb4..a0278672 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -161,6 +161,7 @@ func doUpgrade(helper up.Helper) { } else { helper.Copy(up.Bool, "encryption", "msc4190") } + helper.Copy(up.Bool, "encryption", "msc4392") helper.Copy(up.Bool, "encryption", "self_sign") helper.Copy(up.Bool, "encryption", "allow_key_sharing") if secret, ok := helper.Get(up.Str, "encryption", "pickle_key"); !ok || secret == "generate" { diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index 44dcbc5b..a4f73e6b 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -56,7 +56,7 @@ func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType Extra: content.Raw, }) } - if eventType != event.EventReaction && eventType != event.EventRedaction { + if (eventType != event.EventReaction || as.Connector.Config.Encryption.MSC4392) && eventType != event.EventRedaction { msgContent, ok := content.Parsed.(*event.MessageEventContent) if ok { msgContent.AddPerMessageProfileFallback() diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 947d771b..b0e83696 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -378,6 +378,8 @@ encryption: # Only relevant when using end-to-bridge encryption, required when using encryption with next-gen auth (MSC3861). # Changing this option requires updating the appservice registration file. msc4190: false + # Whether to encrypt reactions and reply metadata as per MSC4392. + msc4392: false # Should the bridge bot generate a recovery key and cross-signing keys and verify itself? # Note that without the latest version of MSC4190, this will fail if you reset the bridge database. # The generated recovery key will be saved in the kv_store table under `recovery_key`. diff --git a/crypto/encryptmegolm.go b/crypto/encryptmegolm.go index ea97f767..8ce70ca0 100644 --- a/crypto/encryptmegolm.go +++ b/crypto/encryptmegolm.go @@ -169,6 +169,15 @@ func (mach *OlmMachine) EncryptMegolmEventWithStateKey(ctx context.Context, room SenderKey: mach.account.IdentityKey(), DeviceID: mach.Client.DeviceID, } + if mach.MSC4392Relations && encrypted.RelatesTo != nil { + // When MSC4392 mode is enabled, reply and reaction metadata is stripped from the unencrypted content. + // Other relations like threads are still left unencrypted. + encrypted.RelatesTo.InReplyTo = nil + encrypted.RelatesTo.IsFallingBack = false + if evtType == event.EventReaction || encrypted.RelatesTo.Type == "" { + encrypted.RelatesTo = nil + } + } if mach.PlaintextMentions { encrypted.Mentions = getMentions(content) } diff --git a/crypto/machine.go b/crypto/machine.go index f8ebe909..fa051f94 100644 --- a/crypto/machine.go +++ b/crypto/machine.go @@ -39,6 +39,7 @@ type OlmMachine struct { cancelBackgroundCtx context.CancelFunc PlaintextMentions bool + MSC4392Relations bool AllowEncryptedState bool // Never ask the server for keys automatically as a side effect during Megolm decryption. From 9f327602f675ce5e721b49df5afd481e1a116b1e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 7 Jan 2026 20:05:42 +0200 Subject: [PATCH 501/581] event/beeper: add blurhash for link previews --- event/beeper.go | 1 + 1 file changed, 1 insertion(+) diff --git a/event/beeper.go b/event/beeper.go index 75c18aa7..b46106ab 100644 --- a/event/beeper.go +++ b/event/beeper.go @@ -144,6 +144,7 @@ type BeeperLinkPreview struct { MatchedURL string `json:"matched_url,omitempty"` ImageEncryption *EncryptedFileInfo `json:"beeper:image:encryption,omitempty"` + ImageBlurhash string `json:"beeper:image:blurhash,omitempty"` } type BeeperProfileExtra struct { From 32da107299ecd1fef1f09fbffdb96d951347ac85 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 8 Jan 2026 22:52:25 +0200 Subject: [PATCH 502/581] bridgev2/matrix: fix decrypting events in GetEvent --- bridgev2/matrix/intent.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index a4f73e6b..3d2692f9 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -714,10 +714,10 @@ func (as *ASIntent) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.E } if evt.Type == event.EventEncrypted { - if as.Connector.Config.Encryption.DeleteKeys.RatchetOnDecrypt { + if as.Connector.Crypto == nil || as.Connector.Config.Encryption.DeleteKeys.RatchetOnDecrypt { return nil, errors.New("can't decrypt the event") } - return as.Matrix.Crypto.Decrypt(ctx, evt) + return as.Connector.Crypto.Decrypt(ctx, evt) } return evt, nil From 6da5f6b5d0ffc64244cea8b2c7160314103e6288 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 10 Jan 2026 14:18:57 +0200 Subject: [PATCH 503/581] federation: change serverauth test domains --- federation/serverauth_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/federation/serverauth_test.go b/federation/serverauth_test.go index 633a0f66..f99fc6cf 100644 --- a/federation/serverauth_test.go +++ b/federation/serverauth_test.go @@ -19,7 +19,7 @@ import ( func TestServerKeyResponse_VerifySelfSignature(t *testing.T) { cli := federation.NewClient("", nil, nil) ctx := context.Background() - for _, name := range []string{"matrix.org", "maunium.net", "continuwuity.org"} { + for _, name := range []string{"matrix.org", "maunium.net", "cd.mau.dev", "uwu.mau.dev"} { t.Run(name, func(t *testing.T) { resp, err := cli.ServerKeys(ctx, name) require.NoError(t, err) From c69518ab3c9c98f152d9e5db5793c150c1562754 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 10 Jan 2026 20:53:44 +0200 Subject: [PATCH 504/581] bridgev2/login: add default_value for user input fields --- bridgev2/login.go | 2 ++ bridgev2/matrix/provisioning.yaml | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/bridgev2/login.go b/bridgev2/login.go index 46dcf7da..4ddbf13e 100644 --- a/bridgev2/login.go +++ b/bridgev2/login.go @@ -190,6 +190,8 @@ type LoginInputDataField struct { Name string `json:"name"` // The description of the field shown to the user. Description string `json:"description"` + // A default value that the client can pre-fill the field with. + DefaultValue string `json:"default_value,omitempty"` // A regex pattern that the client can use to validate input client-side. Pattern string `json:"pattern,omitempty"` // For fields of type select, the valid options. diff --git a/bridgev2/matrix/provisioning.yaml b/bridgev2/matrix/provisioning.yaml index 50b73c66..d19a7e83 100644 --- a/bridgev2/matrix/provisioning.yaml +++ b/bridgev2/matrix/provisioning.yaml @@ -728,11 +728,14 @@ components: description: A more detailed description of the field shown to the user. examples: - Include the country code with a + + default_value: + type: string + description: A default value that the client can pre-fill the field with. pattern: type: string format: regex description: A regular expression that the field value must match. - select: + options: type: array description: For fields of type select, the valid options. items: From be22286000926cc5549b03caeb1e4cb120b37676 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 10 Jan 2026 18:28:23 +0200 Subject: [PATCH 505/581] event: drop MSC4332 support --- event/botcommand.go | 49 --------------------------------------------- event/content.go | 1 - event/message.go | 2 -- event/type.go | 4 +--- 4 files changed, 1 insertion(+), 55 deletions(-) delete mode 100644 event/botcommand.go diff --git a/event/botcommand.go b/event/botcommand.go deleted file mode 100644 index 2b208656..00000000 --- a/event/botcommand.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "encoding/json" -) - -type BotCommandsEventContent struct { - Sigil string `json:"sigil,omitempty"` - Commands []*BotCommand `json:"commands,omitempty"` -} - -type BotCommand struct { - Syntax string `json:"syntax"` - Aliases []string `json:"fi.mau.aliases,omitempty"` // Not in MSC (yet) - Arguments []*BotCommandArgument `json:"arguments,omitempty"` - Description *ExtensibleTextContainer `json:"description,omitempty"` -} - -type BotArgumentType string - -const ( - BotArgumentTypeString BotArgumentType = "string" - BotArgumentTypeEnum BotArgumentType = "enum" - BotArgumentTypeInteger BotArgumentType = "integer" - BotArgumentTypeBoolean BotArgumentType = "boolean" - BotArgumentTypeUserID BotArgumentType = "user_id" - BotArgumentTypeRoomID BotArgumentType = "room_id" - BotArgumentTypeRoomAlias BotArgumentType = "room_alias" - BotArgumentTypeEventID BotArgumentType = "event_id" -) - -type BotCommandArgument struct { - Type BotArgumentType `json:"type"` - DefaultValue any `json:"fi.mau.default_value,omitempty"` // Not in MSC (yet) - Description *ExtensibleTextContainer `json:"description,omitempty"` - Enum []string `json:"enum,omitempty"` - Variadic bool `json:"variadic,omitempty"` -} - -type BotCommandInput struct { - Syntax string `json:"syntax"` - Arguments json.RawMessage `json:"arguments,omitempty"` -} diff --git a/event/content.go b/event/content.go index 4929c6a5..d1ced268 100644 --- a/event/content.go +++ b/event/content.go @@ -50,7 +50,6 @@ var TypeMap = map[Type]reflect.Type{ StateElementFunctionalMembers: reflect.TypeOf(ElementFunctionalMembersContent{}), StateBeeperRoomFeatures: reflect.TypeOf(RoomFeatures{}), StateBeeperDisappearingTimer: reflect.TypeOf(BeeperDisappearingTimer{}), - StateBotCommands: reflect.TypeOf(BotCommandsEventContent{}), EventMessage: reflect.TypeOf(MessageEventContent{}), EventSticker: reflect.TypeOf(MessageEventContent{}), diff --git a/event/message.go b/event/message.go index 692382cf..0af3a2c9 100644 --- a/event/message.go +++ b/event/message.go @@ -142,8 +142,6 @@ type MessageEventContent struct { MSC1767Audio *MSC1767Audio `json:"org.matrix.msc1767.audio,omitempty"` MSC3245Voice *MSC3245Voice `json:"org.matrix.msc3245.voice,omitempty"` - - MSC4332BotCommand *BotCommandInput `json:"org.matrix.msc4332.command,omitempty"` } func (content *MessageEventContent) GetCapMsgType() CapabilityMsgType { diff --git a/event/type.go b/event/type.go index f4d7592c..2a9b382c 100644 --- a/event/type.go +++ b/event/type.go @@ -112,8 +112,7 @@ func (et *Type) GuessClass() TypeClass { StatePowerLevels.Type, StateRoomName.Type, StateRoomAvatar.Type, StateServerACL.Type, StateTopic.Type, StatePinnedEvents.Type, StateTombstone.Type, StateEncryption.Type, StateBridge.Type, StateHalfShotBridge.Type, StateSpaceParent.Type, StateSpaceChild.Type, StatePolicyRoom.Type, StatePolicyServer.Type, StatePolicyUser.Type, - StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type, StateBeeperDisappearingTimer.Type, - StateBotCommands.Type: + StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type, StateBeeperDisappearingTimer.Type: return StateEventType case EphemeralEventReceipt.Type, EphemeralEventTyping.Type, EphemeralEventPresence.Type: return EphemeralEventType @@ -205,7 +204,6 @@ var ( StateElementFunctionalMembers = Type{"io.element.functional_members", StateEventType} StateBeeperRoomFeatures = Type{"com.beeper.room_features", StateEventType} StateBeeperDisappearingTimer = Type{"com.beeper.disappearing_timer", StateEventType} - StateBotCommands = Type{"org.matrix.msc4332.commands", StateEventType} ) // Message events From 5ac73563b0af64a03e416d31199675eb2d1f7b35 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 10 Jan 2026 18:29:12 +0200 Subject: [PATCH 506/581] event/cmdschema: add MSC4391 types, parser and stringifier --- event/cmdschema/content.go | 64 +++ event/cmdschema/parameter.go | 286 +++++++++++ event/cmdschema/parse.go | 471 ++++++++++++++++++ event/cmdschema/parse_test.go | 118 +++++ event/cmdschema/roomid.go | 135 +++++ event/cmdschema/stringify.go | 122 +++++ event/cmdschema/testdata/commands/flags.json | 153 ++++++ .../testdata/commands/room_id_or_alias.json | 84 ++++ .../commands/room_reference_list.json | 105 ++++ event/cmdschema/testdata/commands/simple.json | 45 ++ event/cmdschema/testdata/data.go | 14 + event/cmdschema/testdata/parse_quote.json | 20 + event/message.go | 9 + event/state.go | 7 + event/type.go | 4 +- 15 files changed, 1636 insertions(+), 1 deletion(-) create mode 100644 event/cmdschema/content.go create mode 100644 event/cmdschema/parameter.go create mode 100644 event/cmdschema/parse.go create mode 100644 event/cmdschema/parse_test.go create mode 100644 event/cmdschema/roomid.go create mode 100644 event/cmdschema/stringify.go create mode 100644 event/cmdschema/testdata/commands/flags.json create mode 100644 event/cmdschema/testdata/commands/room_id_or_alias.json create mode 100644 event/cmdschema/testdata/commands/room_reference_list.json create mode 100644 event/cmdschema/testdata/commands/simple.json create mode 100644 event/cmdschema/testdata/data.go create mode 100644 event/cmdschema/testdata/parse_quote.json diff --git a/event/cmdschema/content.go b/event/cmdschema/content.go new file mode 100644 index 00000000..b69f0c1f --- /dev/null +++ b/event/cmdschema/content.go @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package cmdschema + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "reflect" + "slices" + + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type EventContent struct { + Command string `json:"command"` + Aliases []string `json:"aliases,omitempty"` + Parameters []*Parameter `json:"parameters,omitempty"` + Description *event.ExtensibleTextContainer `json:"description,omitempty"` +} + +func (ec *EventContent) Validate() error { + if ec == nil { + return fmt.Errorf("event content is nil") + } else if ec.Command == "" { + return fmt.Errorf("command is empty") + } + for i, p := range ec.Parameters { + if err := p.Validate(); err != nil { + return fmt.Errorf("parameter %q (#%d) is invalid: %w", ptr.Val(p).Key, i+1, err) + } + } + return nil +} + +func (ec *EventContent) IsValid() bool { + return ec.Validate() == nil +} + +func (ec *EventContent) StateKey(owner id.UserID) string { + hash := sha256.Sum256([]byte(ec.Command + owner.String())) + return base64.StdEncoding.EncodeToString(hash[:]) +} + +func (ec *EventContent) Equals(other *EventContent) bool { + if ec == nil || other == nil { + return ec == other + } + return ec.Command == other.Command && + slices.Equal(ec.Aliases, other.Aliases) && + slices.EqualFunc(ec.Parameters, other.Parameters, (*Parameter).Equals) && + ec.Description.Equals(other.Description) +} + +func init() { + event.TypeMap[event.StateMSC4391BotCommand] = reflect.TypeOf(EventContent{}) +} diff --git a/event/cmdschema/parameter.go b/event/cmdschema/parameter.go new file mode 100644 index 00000000..4193b297 --- /dev/null +++ b/event/cmdschema/parameter.go @@ -0,0 +1,286 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package cmdschema + +import ( + "encoding/json" + "fmt" + "slices" + + "go.mau.fi/util/exslices" + + "maunium.net/go/mautrix/event" +) + +type Parameter struct { + Key string `json:"key"` + Schema *ParameterSchema `json:"schema"` + Optional bool `json:"optional,omitempty"` + Description *event.ExtensibleTextContainer `json:"description,omitempty"` + DefaultValue any `json:"fi.mau.default_value,omitempty"` +} + +func (p *Parameter) Equals(other *Parameter) bool { + if p == nil || other == nil { + return p == other + } + return p.Key == other.Key && + p.Schema.Equals(other.Schema) && + p.Optional == other.Optional && + p.Description.Equals(other.Description) && + p.DefaultValue == other.DefaultValue // TODO this won't work for room/event ID values +} + +func (p *Parameter) Validate() error { + if p == nil { + return fmt.Errorf("parameter is nil") + } else if p.Key == "" { + return fmt.Errorf("key is empty") + } + return p.Schema.Validate() +} + +func (p *Parameter) IsValid() bool { + return p.Validate() == nil +} + +func (p *Parameter) GetDefaultValue() any { + if p != nil && p.DefaultValue != nil { + return p.DefaultValue + } else if p == nil || p.Optional { + return nil + } + return p.Schema.GetDefaultValue() +} + +type PrimitiveType string + +const ( + PrimitiveTypeString PrimitiveType = "string" + PrimitiveTypeInteger PrimitiveType = "integer" + PrimitiveTypeBoolean PrimitiveType = "boolean" + PrimitiveTypeServerName PrimitiveType = "server_name" + PrimitiveTypeUserID PrimitiveType = "user_id" + PrimitiveTypeRoomID PrimitiveType = "room_id" + PrimitiveTypeRoomAlias PrimitiveType = "room_alias" + PrimitiveTypeEventID PrimitiveType = "event_id" +) + +func (pt PrimitiveType) Schema() *ParameterSchema { + return &ParameterSchema{ + SchemaType: SchemaTypePrimitive, + Type: pt, + } +} + +func (pt PrimitiveType) IsValid() bool { + switch pt { + case PrimitiveTypeString, + PrimitiveTypeInteger, + PrimitiveTypeBoolean, + PrimitiveTypeServerName, + PrimitiveTypeUserID, + PrimitiveTypeRoomID, + PrimitiveTypeRoomAlias, + PrimitiveTypeEventID: + return true + default: + return false + } +} + +type SchemaType string + +const ( + SchemaTypePrimitive SchemaType = "primitive" + SchemaTypeArray SchemaType = "array" + SchemaTypeUnion SchemaType = "union" + SchemaTypeLiteral SchemaType = "literal" +) + +type ParameterSchema struct { + SchemaType SchemaType `json:"schema_type"` + Type PrimitiveType `json:"type,omitempty"` // Only for primitive + Items *ParameterSchema `json:"items,omitempty"` // Only for array + Variants []*ParameterSchema `json:"variants,omitempty"` // Only for union + Value any `json:"value,omitempty"` // Only for literal +} + +func Literal(value any) *ParameterSchema { + return &ParameterSchema{ + SchemaType: SchemaTypeLiteral, + Value: value, + } +} + +func Enum(values ...any) *ParameterSchema { + return Union(exslices.CastFunc(values, Literal)...) +} + +func flattenUnion(variants []*ParameterSchema) []*ParameterSchema { + var flattened []*ParameterSchema + for _, variant := range variants { + switch variant.SchemaType { + case SchemaTypeArray: + panic(fmt.Errorf("illegal array schema in union")) + case SchemaTypeUnion: + flattened = append(flattened, flattenUnion(variant.Variants)...) + default: + flattened = append(flattened, variant) + } + } + return flattened +} + +func Union(variants ...*ParameterSchema) *ParameterSchema { + needsFlattening := false + for _, variant := range variants { + if variant.SchemaType == SchemaTypeArray { + panic(fmt.Errorf("illegal array schema in union")) + } else if variant.SchemaType == SchemaTypeUnion { + needsFlattening = true + } + } + if needsFlattening { + variants = flattenUnion(variants) + } + return &ParameterSchema{ + SchemaType: SchemaTypeUnion, + Variants: variants, + } +} + +func Array(items *ParameterSchema) *ParameterSchema { + if items.SchemaType == SchemaTypeArray { + panic(fmt.Errorf("illegal array schema in array")) + } + return &ParameterSchema{ + SchemaType: SchemaTypeArray, + Items: items, + } +} + +func (ps *ParameterSchema) GetDefaultValue() any { + if ps == nil { + return nil + } + switch ps.SchemaType { + case SchemaTypePrimitive: + switch ps.Type { + case PrimitiveTypeInteger: + return 0 + case PrimitiveTypeBoolean: + return false + default: + return "" + } + case SchemaTypeArray: + return []any{} + case SchemaTypeUnion: + if len(ps.Variants) > 0 { + return ps.Variants[0].GetDefaultValue() + } + return nil + case SchemaTypeLiteral: + return ps.Value + default: + return nil + } +} + +func (ps *ParameterSchema) IsValid() bool { + return ps.validate("") == nil +} + +func (ps *ParameterSchema) Validate() error { + return ps.validate("") +} + +func (ps *ParameterSchema) validate(parent SchemaType) error { + if ps == nil { + return fmt.Errorf("schema is nil") + } + switch ps.SchemaType { + case SchemaTypePrimitive: + if !ps.Type.IsValid() { + return fmt.Errorf("invalid primitive type %s", ps.Type) + } else if ps.Items != nil || ps.Variants != nil || ps.Value != nil { + return fmt.Errorf("primitive schema has extra fields") + } + return nil + case SchemaTypeArray: + if parent != "" { + return fmt.Errorf("arrays can't be nested in other types") + } else if err := ps.Items.validate(ps.SchemaType); err != nil { + return fmt.Errorf("item schema is invalid: %w", err) + } else if ps.Type != "" || ps.Variants != nil || ps.Value != nil { + return fmt.Errorf("array schema has extra fields") + } + return nil + case SchemaTypeUnion: + if len(ps.Variants) == 0 { + return fmt.Errorf("no variants specified for union") + } else if parent != "" && parent != SchemaTypeArray { + return fmt.Errorf("unions can't be nested in anything other than arrays") + } + for i, v := range ps.Variants { + if err := v.validate(ps.SchemaType); err != nil { + return fmt.Errorf("variant #%d is invalid: %w", i+1, err) + } + } + if ps.Type != "" || ps.Items != nil || ps.Value != nil { + return fmt.Errorf("union schema has extra fields") + } + return nil + case SchemaTypeLiteral: + switch typedVal := ps.Value.(type) { + case string, float64, int, int64, json.Number, bool, RoomIDValue, *RoomIDValue: + // ok + case map[string]any: + if typedVal["type"] != "event_id" && typedVal["type"] != "room_id" { + return fmt.Errorf("literal value has invalid map data") + } + default: + return fmt.Errorf("literal value has unsupported type %T", ps.Value) + } + if ps.Type != "" || ps.Items != nil || ps.Variants != nil { + return fmt.Errorf("literal schema has extra fields") + } + return nil + default: + return fmt.Errorf("invalid schema type %s", ps.SchemaType) + } +} + +func (ps *ParameterSchema) Equals(other *ParameterSchema) bool { + if ps == nil || other == nil { + return ps == other + } + return ps.SchemaType == other.SchemaType && + ps.Type == other.Type && + ps.Items.Equals(other.Items) && + slices.EqualFunc(ps.Variants, other.Variants, (*ParameterSchema).Equals) && + ps.Value == other.Value // TODO this won't work for room/event ID values +} + +func (ps *ParameterSchema) AllowsPrimitive(prim PrimitiveType) bool { + switch ps.SchemaType { + case SchemaTypePrimitive: + return ps.Type == prim + case SchemaTypeUnion: + for _, variant := range ps.Variants { + if variant.AllowsPrimitive(prim) { + return true + } + } + return false + case SchemaTypeArray: + return ps.Items.AllowsPrimitive(prim) + default: + return false + } +} diff --git a/event/cmdschema/parse.go b/event/cmdschema/parse.go new file mode 100644 index 00000000..6536b410 --- /dev/null +++ b/event/cmdschema/parse.go @@ -0,0 +1,471 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package cmdschema + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +const botArrayOpener = "<" +const botArrayCloser = ">" + +func parseQuoted(val string) (parsed, remaining string, quoted bool) { + if len(val) == 0 { + return + } + if !strings.HasPrefix(val, `"`) { + spaceIdx := strings.IndexByte(val, ' ') + if spaceIdx == -1 { + parsed = val + } else { + parsed = val[:spaceIdx] + remaining = strings.TrimLeft(val[spaceIdx+1:], " ") + } + return + } + val = val[1:] + var buf strings.Builder + for { + quoteIdx := strings.IndexByte(val, '"') + escapeIdx := strings.IndexByte(val[:max(0, quoteIdx)], '\\') + if escapeIdx >= 0 { + buf.WriteString(val[:escapeIdx]) + if len(val) > escapeIdx+1 { + buf.WriteByte(val[escapeIdx+1]) + } + val = val[min(escapeIdx+2, len(val)):] + } else if quoteIdx >= 0 { + buf.WriteString(val[:quoteIdx]) + val = val[quoteIdx+1:] + break + } else if buf.Len() == 0 { + // Unterminated quote, no escape characters, val is the whole input + return val, "", true + } else { + // Unterminated quote, but there were escape characters previously + buf.WriteString(val) + val = "" + break + } + } + return buf.String(), strings.TrimLeft(val, " "), true +} + +// ParseInput tries to parse the given text into a bot command event matching this command definition. +// +// If the prefix doesn't match, this will return a nil content and nil error. +// If the prefix does match, some content is always returned, but there may still be an error if parsing failed. +func (ec *EventContent) ParseInput(owner id.UserID, sigils []string, input string) (content *event.MessageEventContent, err error) { + prefix := ec.parsePrefix(input, sigils, owner.String()) + if prefix == "" { + return nil, nil + } + content = &event.MessageEventContent{ + MsgType: event.MsgText, + Body: input, + Mentions: &event.Mentions{UserIDs: []id.UserID{owner}}, + MSC4391BotCommand: &event.MSC4391BotCommandInput{ + Command: ec.Command, + }, + } + content.MSC4391BotCommand.Arguments, err = ec.ParseArguments(input[len(prefix):]) + return content, err +} + +func (ec *EventContent) ParseArguments(input string) (json.RawMessage, error) { + args := make(map[string]any) + var retErr error + setError := func(err error) { + if err != nil && retErr == nil { + retErr = err + } + } + processParameter := func(param *Parameter, isLast, isNamed bool) { + origInput := input + var nextVal string + var wasQuoted bool + if param.Schema.SchemaType == SchemaTypeArray { + hasOpener := strings.HasPrefix(input, botArrayOpener) + arrayClosed := false + if hasOpener { + input = input[len(botArrayOpener):] + if strings.HasPrefix(input, botArrayCloser) { + input = strings.TrimLeft(input[len(botArrayCloser):], " ") + arrayClosed = true + } + } + var collector []any + for len(input) > 0 && !arrayClosed { + //origInput = input + nextVal, input, wasQuoted = parseQuoted(input) + if !wasQuoted && hasOpener && strings.HasSuffix(nextVal, botArrayCloser) { + // The value wasn't quoted and has the array delimiter at the end, close the array + nextVal = strings.TrimRight(nextVal, botArrayCloser) + arrayClosed = true + } else if hasOpener && strings.HasPrefix(input, botArrayCloser) { + // The value was quoted or there was a space, and the next character is the + // array delimiter, close the array + input = strings.TrimLeft(input[len(botArrayCloser):], " ") + arrayClosed = true + } else if !hasOpener && !isLast { + // For array arguments in the middle without the <> delimiters, stop after the first item + arrayClosed = true + } + parsedVal, err := param.Schema.Items.ParseString(nextVal) + if err == nil { + collector = append(collector, parsedVal) + } else if hasOpener || isLast { + setError(fmt.Errorf("failed to parse item #%d of array %s: %w", len(collector)+1, param.Key, err)) + } else { + //input = origInput + } + } + args[param.Key] = collector + } else { + nextVal, input, wasQuoted = parseQuoted(input) + if isLast && !wasQuoted && len(input) > 0 { + // If the last argument is not quoted and not variadic, just treat the rest of the string + // as the argument without escapes (arguments with escapes should be quoted). + nextVal += " " + input + input = "" + } + // Special case for named boolean parameters: if no value is given, treat it as true + if nextVal == "" && !wasQuoted && isNamed && param.Schema.AllowsPrimitive(PrimitiveTypeBoolean) { + args[param.Key] = true + return + } + if nextVal == "" && !param.Optional { + setError(fmt.Errorf("missing value for required parameter %s", param.Key)) + } + parsedVal, err := param.Schema.ParseString(nextVal) + if err != nil { + args[param.Key] = param.GetDefaultValue() + // For optional parameters that fail to parse, restore the input and try passing it as the next parameter + if param.Optional && !isLast && !isNamed { + input = strings.TrimLeft(origInput, " ") + } else if !param.Optional || isNamed { + setError(fmt.Errorf("failed to parse %s: %w", param.Key, err)) + } + } else { + args[param.Key] = parsedVal + } + } + } + skipParams := make([]bool, len(ec.Parameters)) + for i, param := range ec.Parameters { + for strings.HasPrefix(input, "--") { + nameEndIdx := strings.IndexAny(input, " =") + if nameEndIdx == -1 { + nameEndIdx = len(input) + } + overrideParam, paramIdx := ec.parameterByName(input[2:nameEndIdx]) + if overrideParam != nil { + // Trim the equals sign, but leave spaces alone to let parseQuoted treat it as empty input + input = strings.TrimPrefix(input[nameEndIdx:], "=") + skipParams[paramIdx] = true + processParameter(overrideParam, false, true) + } else { + break + } + } + if skipParams[i] { + continue + } + processParameter(param, i == len(ec.Parameters)-1, false) + } + jsonArgs, marshalErr := json.Marshal(args) + if marshalErr != nil { + return nil, fmt.Errorf("failed to marshal arguments: %w", marshalErr) + } + return jsonArgs, retErr +} + +func (ec *EventContent) parameterByName(name string) (*Parameter, int) { + for i, param := range ec.Parameters { + if strings.EqualFold(param.Key, name) { + return param, i + } + } + return nil, -1 +} + +func (ec *EventContent) parsePrefix(origInput string, sigils []string, owner string) (prefix string) { + input := origInput + var chosenSigil string + for _, sigil := range sigils { + if strings.HasPrefix(input, sigil) { + chosenSigil = sigil + break + } + } + if chosenSigil == "" { + return "" + } + input = input[len(chosenSigil):] + var chosenAlias string + if !strings.HasPrefix(input, ec.Command) { + for _, alias := range ec.Aliases { + if strings.HasPrefix(input, alias) { + chosenAlias = alias + break + } + } + if chosenAlias == "" { + return "" + } + } else { + chosenAlias = ec.Command + } + input = strings.TrimPrefix(input[len(chosenAlias):], owner) + if input == "" || input[0] == ' ' { + input = strings.TrimLeft(input, " ") + return origInput[:len(origInput)-len(input)] + } + return "" +} + +func (pt PrimitiveType) ValidateValue(value any) bool { + _, err := pt.NormalizeValue(value) + return err == nil +} + +func normalizeNumber(value any) (int, error) { + switch typedValue := value.(type) { + case int: + return typedValue, nil + case int64: + return int(typedValue), nil + case float64: + return int(typedValue), nil + case json.Number: + if i, err := typedValue.Int64(); err != nil { + return 0, fmt.Errorf("failed to parse json.Number: %w", err) + } else { + return int(i), nil + } + default: + return 0, fmt.Errorf("unsupported type %T for integer", value) + } +} + +func (pt PrimitiveType) NormalizeValue(value any) (any, error) { + switch pt { + case PrimitiveTypeInteger: + return normalizeNumber(value) + case PrimitiveTypeBoolean: + bv, ok := value.(bool) + if !ok { + return nil, fmt.Errorf("unsupported type %T for boolean", value) + } + return bv, nil + case PrimitiveTypeString, PrimitiveTypeServerName: + str, ok := value.(string) + if !ok { + return nil, fmt.Errorf("unsupported type %T for string", value) + } + return str, pt.validateStringValue(str) + case PrimitiveTypeUserID, PrimitiveTypeRoomAlias: + str, ok := value.(string) + if !ok { + return nil, fmt.Errorf("unsupported type %T for user ID or room alias", value) + } else if plainErr := pt.validateStringValue(str); plainErr == nil { + return str, nil + } else if parsed, err := id.ParseMatrixURIOrMatrixToURL(str); err != nil { + return nil, fmt.Errorf("couldn't parse %q as plain ID nor matrix URI: %w / %w", value, plainErr, err) + } else if parsed.Sigil1 == '@' && pt == PrimitiveTypeUserID { + return parsed.UserID(), nil + } else if parsed.Sigil1 == '#' && pt == PrimitiveTypeRoomAlias { + return parsed.RoomAlias(), nil + } else { + return nil, fmt.Errorf("unexpected sigil %c for user ID or room alias", parsed.Sigil1) + } + case PrimitiveTypeRoomID, PrimitiveTypeEventID: + riv, err := NormalizeRoomIDValue(value) + if err != nil { + return nil, err + } + return riv, riv.Validate() + default: + return nil, fmt.Errorf("cannot normalize value for argument type %s", pt) + } +} + +func (pt PrimitiveType) validateStringValue(value string) error { + switch pt { + case PrimitiveTypeString: + return nil + case PrimitiveTypeServerName: + if !id.ValidateServerName(value) { + return fmt.Errorf("invalid server name: %q", value) + } + return nil + case PrimitiveTypeUserID: + _, _, err := id.UserID(value).ParseAndValidateRelaxed() + return err + case PrimitiveTypeRoomAlias: + sigil, localpart, serverName := id.ParseCommonIdentifier(value) + if sigil != '#' || localpart == "" || serverName == "" { + return fmt.Errorf("invalid room alias: %q", value) + } else if !id.ValidateServerName(serverName) { + return fmt.Errorf("invalid server name in room alias: %q", serverName) + } + return nil + default: + panic(fmt.Errorf("validateStringValue called with invalid type %s", pt)) + } +} + +func parseBoolean(val string) (bool, error) { + if len(val) == 0 { + return false, fmt.Errorf("cannot parse empty string as boolean") + } + switch val[0] { + case 't', 'T', 'y', 'Y', '1': + return true, nil + case 'f', 'F', 'n', 'N', '0': + return false, nil + default: + return false, fmt.Errorf("invalid boolean string: %q", val) + } +} + +var markdownLinkRegex = regexp.MustCompile(`^\[.+]\(([^)]+)\)$`) + +func parseRoomOrEventID(value string) (*RoomIDValue, error) { + if strings.HasPrefix(value, "[") && strings.Contains(value, "](") && strings.HasSuffix(value, ")") { + matches := markdownLinkRegex.FindStringSubmatch(value) + if len(matches) == 2 { + value = matches[1] + } + } + parsed, err := id.ParseMatrixURIOrMatrixToURL(value) + if err != nil && strings.HasPrefix(value, "!") { + return &RoomIDValue{ + Type: PrimitiveTypeRoomID, + RoomID: id.RoomID(value), + }, nil + } + if err != nil { + return nil, err + } else if parsed.Sigil1 != '!' { + return nil, fmt.Errorf("unexpected sigil %c for room ID", parsed.Sigil1) + } else if parsed.MXID2 != "" && parsed.Sigil2 != '$' { + return nil, fmt.Errorf("unexpected sigil %c for event ID", parsed.Sigil2) + } + valType := PrimitiveTypeRoomID + if parsed.MXID2 != "" { + valType = PrimitiveTypeEventID + } + return &RoomIDValue{ + Type: valType, + RoomID: parsed.RoomID(), + Via: parsed.Via, + EventID: parsed.EventID(), + }, nil +} + +func (pt PrimitiveType) ParseString(value string) (any, error) { + switch pt { + case PrimitiveTypeInteger: + return strconv.Atoi(value) + case PrimitiveTypeBoolean: + return parseBoolean(value) + case PrimitiveTypeString, PrimitiveTypeServerName, PrimitiveTypeUserID: + return value, pt.validateStringValue(value) + case PrimitiveTypeRoomAlias: + plainErr := pt.validateStringValue(value) + if plainErr == nil { + return value, nil + } + parsed, err := id.ParseMatrixURIOrMatrixToURL(value) + if err != nil { + return nil, fmt.Errorf("couldn't parse %q as plain room alias nor matrix URI: %w / %w", value, plainErr, err) + } else if parsed.Sigil1 != '#' { + return nil, fmt.Errorf("unexpected sigil %c for room alias", parsed.Sigil1) + } + return parsed.RoomAlias(), nil + case PrimitiveTypeRoomID, PrimitiveTypeEventID: + parsed, err := parseRoomOrEventID(value) + if err != nil { + return nil, err + } else if pt != parsed.Type { + return nil, fmt.Errorf("mismatching argument type: expected %s but got %s", pt, parsed.Type) + } + return parsed, nil + default: + return nil, fmt.Errorf("cannot parse string for argument type %s", pt) + } +} + +func (ps *ParameterSchema) ParseString(value string) (any, error) { + if ps == nil { + return nil, fmt.Errorf("parameter schema is nil") + } + switch ps.SchemaType { + case SchemaTypePrimitive: + return ps.Type.ParseString(value) + case SchemaTypeLiteral: + switch typedValue := ps.Value.(type) { + case string: + if value == typedValue { + return typedValue, nil + } else { + return nil, fmt.Errorf("literal value %q does not match %q", typedValue, value) + } + case int, int64, float64, json.Number: + expectedVal, _ := normalizeNumber(typedValue) + intVal, err := strconv.Atoi(value) + if err != nil { + return nil, fmt.Errorf("failed to parse integer literal: %w", err) + } else if intVal != expectedVal { + return nil, fmt.Errorf("literal value %d does not match %d", expectedVal, intVal) + } + return intVal, nil + case bool: + boolVal, err := parseBoolean(value) + if err != nil { + return nil, fmt.Errorf("failed to parse boolean literal: %w", err) + } else if boolVal != typedValue { + return nil, fmt.Errorf("literal value %t does not match %t", typedValue, boolVal) + } + return boolVal, nil + case RoomIDValue, *RoomIDValue, map[string]any, json.RawMessage: + expectedVal, _ := NormalizeRoomIDValue(typedValue) + parsed, err := parseRoomOrEventID(value) + if err != nil { + return nil, fmt.Errorf("failed to parse room or event ID literal: %w", err) + } else if !parsed.Equals(expectedVal) { + return nil, fmt.Errorf("literal value %s does not match %s", expectedVal, parsed) + } + return parsed, nil + default: + return nil, fmt.Errorf("unsupported literal type %T", ps.Value) + } + case SchemaTypeUnion: + var errs []error + for _, variant := range ps.Variants { + if parsed, err := variant.ParseString(value); err == nil { + return parsed, nil + } else { + errs = append(errs, err) + } + } + return nil, fmt.Errorf("no union variant matched: %w", errors.Join(errs...)) + case SchemaTypeArray: + return nil, fmt.Errorf("cannot parse string for array schema type") + default: + return nil, fmt.Errorf("unknown schema type %s", ps.SchemaType) + } +} diff --git a/event/cmdschema/parse_test.go b/event/cmdschema/parse_test.go new file mode 100644 index 00000000..725b0150 --- /dev/null +++ b/event/cmdschema/parse_test.go @@ -0,0 +1,118 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package cmdschema + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "go.mau.fi/util/exbytes" + "go.mau.fi/util/exerrors" + + "maunium.net/go/mautrix/event/cmdschema/testdata" +) + +type QuoteParseOutput struct { + Parsed string + Remaining string + Quoted bool +} + +func (qpo *QuoteParseOutput) UnmarshalJSON(data []byte) error { + var arr []any + if err := json.Unmarshal(data, &arr); err != nil { + return err + } + qpo.Parsed = arr[0].(string) + qpo.Remaining = arr[1].(string) + qpo.Quoted = arr[2].(bool) + return nil +} + +type QuoteParseTestData struct { + Name string `json:"name"` + Input string `json:"input"` + Output QuoteParseOutput `json:"output"` +} + +func loadFile[T any](name string) (into T) { + quoteData := exerrors.Must(testdata.FS.ReadFile(name)) + exerrors.PanicIfNotNil(json.Unmarshal(quoteData, &into)) + return +} + +func TestParseQuoted(t *testing.T) { + qptd := loadFile[[]QuoteParseTestData]("parse_quote.json") + for _, test := range qptd { + t.Run(test.Name, func(t *testing.T) { + parsed, remaining, quoted := parseQuoted(test.Input) + assert.Equalf(t, test.Output, QuoteParseOutput{ + Parsed: parsed, + Remaining: remaining, + Quoted: quoted, + }, "Failed with input `%s`", test.Input) + // Note: can't just test that requoted == input, because some inputs + // have unnecessary escapes which won't survive roundtripping + t.Run("roundtrip", func(t *testing.T) { + requoted := quoteString(parsed) + " " + remaining + reparsed, newRemaining, _ := parseQuoted(requoted) + assert.Equal(t, parsed, reparsed) + assert.Equal(t, remaining, newRemaining) + }) + }) + } +} + +type CommandTestData struct { + Spec *EventContent + Tests []*CommandTestUnit +} + +type CommandTestUnit struct { + Name string `json:"name"` + Input string `json:"input"` + Broken string `json:"broken,omitempty"` + Error bool `json:"error"` + Output json.RawMessage `json:"output"` +} + +func compactJSON(input json.RawMessage) json.RawMessage { + var buf bytes.Buffer + exerrors.PanicIfNotNil(json.Compact(&buf, input)) + return buf.Bytes() +} + +func TestMSC4391BotCommandEventContent_ParseInput(t *testing.T) { + for _, cmd := range exerrors.Must(testdata.FS.ReadDir("commands")) { + t.Run(strings.TrimSuffix(cmd.Name(), ".json"), func(t *testing.T) { + ctd := loadFile[CommandTestData]("commands/" + cmd.Name()) + for _, test := range ctd.Tests { + outputStr := exbytes.UnsafeString(compactJSON(test.Output)) + t.Run(test.Name, func(t *testing.T) { + if test.Broken != "" { + t.Skip(test.Broken) + } + output, err := ctd.Spec.ParseInput("@testbot", []string{"/"}, test.Input) + if test.Error { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + if outputStr == "null" { + assert.Nil(t, output) + } else { + assert.Equal(t, ctd.Spec.Command, output.MSC4391BotCommand.Command) + assert.Equal(t, outputStr, exbytes.UnsafeString(output.MSC4391BotCommand.Arguments)) + } + }) + } + }) + } +} diff --git a/event/cmdschema/roomid.go b/event/cmdschema/roomid.go new file mode 100644 index 00000000..98c421fc --- /dev/null +++ b/event/cmdschema/roomid.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package cmdschema + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "maunium.net/go/mautrix/id" +) + +var ParameterSchemaJoinableRoom = Union( + PrimitiveTypeRoomID.Schema(), + PrimitiveTypeRoomAlias.Schema(), +) + +type RoomIDValue struct { + Type PrimitiveType `json:"type"` + RoomID id.RoomID `json:"id"` + Via []string `json:"via,omitempty"` + EventID id.EventID `json:"event_id,omitempty"` +} + +func NormalizeRoomIDValue(input any) (riv *RoomIDValue, err error) { + switch typedValue := input.(type) { + case map[string]any, json.RawMessage: + var raw json.RawMessage + if raw, err = json.Marshal(input); err != nil { + err = fmt.Errorf("failed to roundtrip room ID value: %w", err) + } else if err = json.Unmarshal(raw, &riv); err != nil { + err = fmt.Errorf("failed to roundtrip room ID value: %w", err) + } + case *RoomIDValue: + riv = typedValue + case RoomIDValue: + riv = &typedValue + default: + err = fmt.Errorf("unsupported type %T for room or event ID", input) + } + return +} + +func (riv *RoomIDValue) String() string { + return riv.URI().String() +} + +func (riv *RoomIDValue) URI() *id.MatrixURI { + if riv == nil { + return nil + } + switch riv.Type { + case PrimitiveTypeRoomID: + return riv.RoomID.URI(riv.Via...) + case PrimitiveTypeEventID: + return riv.RoomID.EventURI(riv.EventID, riv.Via...) + default: + return nil + } +} + +func (riv *RoomIDValue) Equals(other *RoomIDValue) bool { + if riv == nil || other == nil { + return riv == other + } + return riv.Type == other.Type && + riv.RoomID == other.RoomID && + riv.EventID == other.EventID && + slices.Equal(riv.Via, other.Via) +} + +func (riv *RoomIDValue) Validate() error { + if riv == nil { + return fmt.Errorf("value is nil") + } + switch riv.Type { + case PrimitiveTypeRoomID: + if riv.EventID != "" { + return fmt.Errorf("event ID must be empty for room ID type") + } + case PrimitiveTypeEventID: + if !strings.HasPrefix(riv.EventID.String(), "$") { + return fmt.Errorf("event ID not valid: %q", riv.EventID) + } + default: + return fmt.Errorf("unexpected type %s for room/event ID value", riv.Type) + } + for _, via := range riv.Via { + if !id.ValidateServerName(via) { + return fmt.Errorf("invalid server name %q in vias", via) + } + } + sigil, localpart, serverName := id.ParseCommonIdentifier(riv.RoomID) + if sigil != '!' { + return fmt.Errorf("room ID does not start with !: %q", riv.RoomID) + } else if localpart == "" && serverName == "" { + return fmt.Errorf("room ID has empty localpart and server name: %q", riv.RoomID) + } else if serverName != "" && !id.ValidateServerName(serverName) { + return fmt.Errorf("invalid server name %q in room ID", serverName) + } + return nil +} + +func (riv *RoomIDValue) IsValid() bool { + return riv.Validate() == nil +} + +type RoomIDOrString string + +func (ros *RoomIDOrString) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return fmt.Errorf("empty data for room ID or string") + } + if data[0] == '"' { + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + *ros = RoomIDOrString(str) + return nil + } + var riv RoomIDValue + if err := json.Unmarshal(data, &riv); err != nil { + return err + } else if err = riv.Validate(); err != nil { + return err + } + *ros = RoomIDOrString(riv.String()) + return nil +} diff --git a/event/cmdschema/stringify.go b/event/cmdschema/stringify.go new file mode 100644 index 00000000..c5c57c53 --- /dev/null +++ b/event/cmdschema/stringify.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package cmdschema + +import ( + "encoding/json" + "strconv" + "strings" +) + +var quoteEscaper = strings.NewReplacer( + `"`, `\"`, + `\`, `\\`, +) + +const charsToQuote = ` \` + botArrayOpener + botArrayCloser + +func quoteString(val string) string { + if val == "" { + return `""` + } + val = quoteEscaper.Replace(val) + if strings.ContainsAny(val, charsToQuote) { + return `"` + val + `"` + } + return val +} + +func (ec *EventContent) StringifyArgs(args any) string { + var argMap map[string]any + switch typedArgs := args.(type) { + case json.RawMessage: + err := json.Unmarshal(typedArgs, &argMap) + if err != nil { + return "" + } + case map[string]any: + argMap = typedArgs + default: + if b, err := json.Marshal(args); err != nil { + return "" + } else if err = json.Unmarshal(b, &argMap); err != nil { + return "" + } + } + parts := make([]string, 0, len(ec.Parameters)) + for i, param := range ec.Parameters { + isLast := i == len(ec.Parameters)-1 + val := argMap[param.Key] + if val == nil { + val = param.DefaultValue + if val == nil && !param.Optional { + val = param.Schema.GetDefaultValue() + } + } + if val == nil { + continue + } + var stringified string + if param.Schema.SchemaType == SchemaTypeArray { + stringified = arrayArgumentToString(val, isLast) + } else { + stringified = singleArgumentToString(val) + } + if stringified != "" { + parts = append(parts, stringified) + } + } + return strings.Join(parts, " ") +} + +func arrayArgumentToString(val any, isLast bool) string { + valArr, ok := val.([]any) + if !ok { + return "" + } + parts := make([]string, 0, len(valArr)) + for _, elem := range valArr { + stringified := singleArgumentToString(elem) + if stringified != "" { + parts = append(parts, stringified) + } + } + joinedParts := strings.Join(parts, " ") + if isLast && len(parts) > 0 { + return joinedParts + } + return botArrayOpener + joinedParts + botArrayCloser +} + +func singleArgumentToString(val any) string { + switch typedVal := val.(type) { + case string: + return quoteString(typedVal) + case json.Number: + return typedVal.String() + case bool: + return strconv.FormatBool(typedVal) + case int: + return strconv.Itoa(typedVal) + case int64: + return strconv.FormatInt(typedVal, 10) + case float64: + return strconv.FormatInt(int64(typedVal), 10) + case map[string]any, json.RawMessage, RoomIDValue, *RoomIDValue: + normalized, err := NormalizeRoomIDValue(typedVal) + if err != nil { + return "" + } + uri := normalized.URI() + if uri == nil { + return "" + } + return quoteString(uri.String()) + default: + return "" + } +} diff --git a/event/cmdschema/testdata/commands/flags.json b/event/cmdschema/testdata/commands/flags.json new file mode 100644 index 00000000..dedde348 --- /dev/null +++ b/event/cmdschema/testdata/commands/flags.json @@ -0,0 +1,153 @@ +{ + "spec": { + "command": "flag", + "source": "@testbot", + "parameters": [ + { + "key": "meow", + "schema": { + "schema_type": "primitive", + "type": "string" + } + }, + { + "key": "user", + "schema": { + "schema_type": "primitive", + "type": "user_id" + }, + "optional": true + }, + { + "key": "woof", + "schema": { + "schema_type": "primitive", + "type": "boolean" + }, + "optional": true, + "fi.mau.default_value": false + } + ] + }, + "tests": [ + { + "name": "no flags", + "input": "/flag mrrp", + "output": { + "meow": "mrrp", + "user": null, + "woof": false + } + }, + { + "name": "positional flag", + "input": "/flag mrrp @user:example.com yes", + "output": { + "meow": "mrrp", + "user": "@user:example.com", + "woof": true + } + }, + { + "name": "named flag at start", + "input": "/flag --woof=yes mrrp @user:example.com", + "output": { + "meow": "mrrp", + "user": "@user:example.com", + "woof": true + } + }, + { + "name": "boolean flag without value", + "input": "/flag --woof mrrp @user:example.com", + "output": { + "meow": "mrrp", + "user": "@user:example.com", + "woof": true + } + }, + { + "name": "user id flag without value", + "input": "/flag --user --woof mrrp", + "error": true, + "output": { + "meow": "mrrp", + "user": null, + "woof": true + } + }, + { + "name": "named flag in the middle", + "input": "/flag mrrp --woof=yes @user:example.com", + "output": { + "meow": "mrrp", + "user": "@user:example.com", + "woof": true + } + }, + { + "name": "named flag in the middle with different value", + "input": "/flag mrrp --woof=no @user:example.com", + "output": { + "meow": "mrrp", + "user": "@user:example.com", + "woof": false + } + }, + { + "name": "named flag at end", + "input": "/flag mrrp @user:example.com --woof=yes", + "output": { + "meow": "mrrp", + "user": "@user:example.com", + "woof": true + } + }, + { + "name": "named flag at end without value", + "input": "/flag mrrp @user:example.com --woof", + "output": { + "meow": "mrrp", + "user": "@user:example.com", + "woof": true + } + }, + { + "name": "all variables named", + "input": "/flag --woof=no --meow=mrrp --user=@user:example.com", + "output": { + "meow": "mrrp", + "user": "@user:example.com", + "woof": false + } + }, + { + "name": "all variables named with quotes", + "input": "/flag --woof --meow=\"meow meow mrrp\" --user=\"@user:example.com\"", + "output": { + "meow": "meow meow mrrp", + "user": "@user:example.com", + "woof": true + } + }, + { + "name": "only string variables named", + "input": "/flag --user=@user:example.com --meow=mrrp yes", + "output": { + "meow": "mrrp", + "user": "@user:example.com", + "woof": true + } + }, + { + "name": "invalid value for named parameter", + "input": "/flag --user=meowings mrrp yes", + "error": true, + "output": { + "meow": "mrrp", + "user": null, + "woof": true + } + } + ] +} diff --git a/event/cmdschema/testdata/commands/room_id_or_alias.json b/event/cmdschema/testdata/commands/room_id_or_alias.json new file mode 100644 index 00000000..0dc233b8 --- /dev/null +++ b/event/cmdschema/testdata/commands/room_id_or_alias.json @@ -0,0 +1,84 @@ +{ + "spec": { + "command": "test room reference", + "source": "@testbot", + "parameters": [ + { + "key": "room", + "schema": { + "schema_type": "union", + "variants": [ + { + "schema_type": "primitive", + "type": "room_id" + }, + { + "schema_type": "primitive", + "type": "room_alias" + } + ] + } + } + ] + }, + "tests": [ + { + "name": "room alias", + "input": "/test room reference #test:matrix.org", + "output": { + "room": "#test:matrix.org" + } + }, + { + "name": "room id", + "input": "/test room reference !aiwVrNhPwbGBNjqlNu:matrix.org", + "output": { + "room": { + "type": "room_id", + "id": "!aiwVrNhPwbGBNjqlNu:matrix.org" + } + } + }, + { + "name": "room id matrix.to link", + "input": "/test room reference https://matrix.to/#/!aiwVrNhPwbGBNjqlNu:matrix.org?via=example.com", + "output": { + "room": { + "type": "room_id", + "id": "!aiwVrNhPwbGBNjqlNu:matrix.org", + "via": [ + "example.com" + ] + } + } + }, + { + "name": "room id matrix.to link with url encoding", + "input": "/test room reference https://matrix.to/#/!%23test%2Froom%0Aversion%20%3Cu%3E11%3C%2Fu%3E%2C%20with%20%40%F0%9F%90%88%EF%B8%8F%3Amaunium.net?via=maunium.net", + "broken": "Go's url.URL does url decoding on the fragment, which breaks splitting the path segments properly", + "output": { + "room": { + "type": "room_id", + "id": "!#test/room\nversion 11, with @🐈️:maunium.net", + "via": [ + "maunium.net" + ] + } + } + }, + { + "name": "room id matrix: URI", + "input": "/test room reference matrix:roomid/mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ?via=maunium.net&via=matrix.org", + "output": { + "room": { + "type": "room_id", + "id": "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", + "via": [ + "maunium.net", + "matrix.org" + ] + } + } + } + ] +} diff --git a/event/cmdschema/testdata/commands/room_reference_list.json b/event/cmdschema/testdata/commands/room_reference_list.json new file mode 100644 index 00000000..99388f90 --- /dev/null +++ b/event/cmdschema/testdata/commands/room_reference_list.json @@ -0,0 +1,105 @@ +{ + "spec": { + "command": "test room reference", + "source": "@testbot", + "parameters": [ + { + "key": "rooms", + "schema": { + "schema_type": "array", + "items": { + "schema_type": "union", + "variants": [ + { + "schema_type": "primitive", + "type": "room_id" + }, + { + "schema_type": "primitive", + "type": "room_alias" + } + ] + } + } + } + ] + }, + "tests": [ + { + "name": "room alias", + "input": "/test room reference #test:matrix.org", + "output": { + "rooms": [ + "#test:matrix.org" + ] + } + }, + { + "name": "room id", + "input": "/test room reference !aiwVrNhPwbGBNjqlNu:matrix.org", + "output": { + "rooms": [ + { + "type": "room_id", + "id": "!aiwVrNhPwbGBNjqlNu:matrix.org" + } + ] + } + }, + { + "name": "two room ids", + "input": "/test room reference !mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ !aiwVrNhPwbGBNjqlNu:matrix.org", + "output": { + "rooms": [ + { + "type": "room_id", + "id": "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ" + }, + { + "type": "room_id", + "id": "!aiwVrNhPwbGBNjqlNu:matrix.org" + } + ] + } + }, + { + "name": "room id matrix: URI", + "input": "/test room reference matrix:roomid/mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ?via=maunium.net&via=matrix.org", + "output": { + "rooms": [ + { + "type": "room_id", + "id": "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", + "via": [ + "maunium.net", + "matrix.org" + ] + } + ] + } + }, + { + "name": "room id matrix: URI and matrix.to URL", + "input": "/test room reference https://matrix.to/#/!aiwVrNhPwbGBNjqlNu:matrix.org?via=example.com matrix:roomid/mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ?via=maunium.net&via=matrix.org", + "output": { + "rooms": [ + { + "type": "room_id", + "id": "!aiwVrNhPwbGBNjqlNu:matrix.org", + "via": [ + "example.com" + ] + }, + { + "type": "room_id", + "id": "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", + "via": [ + "maunium.net", + "matrix.org" + ] + } + ] + } + } + ] +} diff --git a/event/cmdschema/testdata/commands/simple.json b/event/cmdschema/testdata/commands/simple.json new file mode 100644 index 00000000..8127aff1 --- /dev/null +++ b/event/cmdschema/testdata/commands/simple.json @@ -0,0 +1,45 @@ +{ + "spec": { + "command": "test simple", + "source": "@testbot", + "parameters": [ + { + "key": "meow", + "schema": { + "schema_type": "primitive", + "type": "string" + } + } + ] + }, + "tests": [ + { + "name": "success", + "input": "/test simple mrrp", + "output": { + "meow": "mrrp" + } + }, + { + "name": "directed success", + "input": "/test simple@testbot mrrp", + "output": { + "meow": "mrrp" + } + }, + { + "name": "missing parameter", + "input": "/test simple", + "error": true, + "output": { + "meow": "" + } + }, + { + "name": "directed at another bot", + "input": "/test simple@anotherbot mrrp", + "error": false, + "output": null + } + ] +} diff --git a/event/cmdschema/testdata/data.go b/event/cmdschema/testdata/data.go new file mode 100644 index 00000000..eceea3d2 --- /dev/null +++ b/event/cmdschema/testdata/data.go @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package testdata + +import ( + "embed" +) + +//go:embed * +var FS embed.FS diff --git a/event/cmdschema/testdata/parse_quote.json b/event/cmdschema/testdata/parse_quote.json new file mode 100644 index 00000000..d22f1299 --- /dev/null +++ b/event/cmdschema/testdata/parse_quote.json @@ -0,0 +1,20 @@ +[ + {"name": "single word", "input": "meow", "output": ["meow", "", false]}, + {"name": "two words", "input": "meow woof", "output": ["meow", "woof", false]}, + {"name": "many words", "input": "meow meow mrrp", "output": ["meow", "meow mrrp", false]}, + {"name": "extra spaces", "input": "meow meow mrrp", "output": ["meow", "meow mrrp", false]}, + {"name": "trailing space", "input": "meow ", "output": ["meow", "", false]}, + {"name": "quoted word", "input": "\"meow\" meow mrrp", "output": ["meow", "meow mrrp", true]}, + {"name": "quoted words", "input": "\"meow meow\" mrrp", "output": ["meow meow", "mrrp", true]}, + {"name": "spaces in quotes", "input": "\" meow meow \" mrrp", "output": [" meow meow ", "mrrp", true]}, + {"name": "quotes after word", "input": "meow \" meow mrrp \"", "output": ["meow", "\" meow mrrp \"", false]}, + {"name": "escaped quote", "input": "\"meow\\\" meow\" mrrp", "output": ["meow\" meow", "mrrp", true]}, + {"name": "missing end quote", "input": "\"meow meow mrrp", "output": ["meow meow mrrp", "", true]}, + {"name": "missing end quote with escaped quote", "input": "\"meow\\\" meow mrrp", "output": ["meow\" meow mrrp", "", true]}, + {"name": "quote in the middle", "input": "me\"ow meow mrrp", "output": ["me\"ow", "meow mrrp", false]}, + {"name": "backslash in the middle", "input": "me\\ow meow mrrp", "output": ["me\\ow", "meow mrrp", false]}, + {"name": "other escaped character", "input": "\"m\\eow\" meow mrrp", "output": ["meow", "meow mrrp", true]}, + {"name": "escaped backslashes", "input": "\"m\\\\e\\\"ow\\\\\" meow mrrp", "output": ["m\\e\"ow\\", "meow mrrp", true]}, + {"name": "just quotes", "input": "\"\\\"\\\"\\\\\\\"\" meow", "output": ["\"\"\\\"", "meow", true]}, + {"name": "eof escape", "input": "\"meow\\", "output": ["meow\\", "", true]} +] diff --git a/event/message.go b/event/message.go index 0af3a2c9..5e80d2ef 100644 --- a/event/message.go +++ b/event/message.go @@ -142,6 +142,8 @@ type MessageEventContent struct { MSC1767Audio *MSC1767Audio `json:"org.matrix.msc1767.audio,omitempty"` MSC3245Voice *MSC3245Voice `json:"org.matrix.msc3245.voice,omitempty"` + + MSC4391BotCommand *MSC4391BotCommandInput `json:"org.matrix.msc4391.command,omitempty"` } func (content *MessageEventContent) GetCapMsgType() CapabilityMsgType { @@ -285,6 +287,13 @@ func (m *Mentions) Merge(other *Mentions) *Mentions { } } +type MSC4391BotCommandInputCustom[T any] struct { + Command string `json:"command"` + Arguments T `json:"arguments,omitempty"` +} + +type MSC4391BotCommandInput = MSC4391BotCommandInputCustom[json.RawMessage] + type EncryptedFileInfo struct { attachment.EncryptedFile URL id.ContentURIString `json:"url"` diff --git a/event/state.go b/event/state.go index 29e0e524..6d027e04 100644 --- a/event/state.go +++ b/event/state.go @@ -62,6 +62,13 @@ type ExtensibleTextContainer struct { Text []ExtensibleText `json:"m.text"` } +func (c *ExtensibleTextContainer) Equals(description *ExtensibleTextContainer) bool { + if c == nil || description == nil { + return c == description + } + return slices.Equal(c.Text, description.Text) +} + func MakeExtensibleText(text string) *ExtensibleTextContainer { return &ExtensibleTextContainer{ Text: []ExtensibleText{{ diff --git a/event/type.go b/event/type.go index 2a9b382c..b193dc59 100644 --- a/event/type.go +++ b/event/type.go @@ -112,7 +112,8 @@ func (et *Type) GuessClass() TypeClass { StatePowerLevels.Type, StateRoomName.Type, StateRoomAvatar.Type, StateServerACL.Type, StateTopic.Type, StatePinnedEvents.Type, StateTombstone.Type, StateEncryption.Type, StateBridge.Type, StateHalfShotBridge.Type, StateSpaceParent.Type, StateSpaceChild.Type, StatePolicyRoom.Type, StatePolicyServer.Type, StatePolicyUser.Type, - StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type, StateBeeperDisappearingTimer.Type: + StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type, StateBeeperDisappearingTimer.Type, + StateMSC4391BotCommand.Type: return StateEventType case EphemeralEventReceipt.Type, EphemeralEventTyping.Type, EphemeralEventPresence.Type: return EphemeralEventType @@ -204,6 +205,7 @@ var ( StateElementFunctionalMembers = Type{"io.element.functional_members", StateEventType} StateBeeperRoomFeatures = Type{"com.beeper.room_features", StateEventType} StateBeeperDisappearingTimer = Type{"com.beeper.disappearing_timer", StateEventType} + StateMSC4391BotCommand = Type{"org.matrix.msc4391.command_description", StateEventType} ) // Message events From d63a008ec6e63b5c1d2a3afdefea9806bbc3c13b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 10 Jan 2026 18:30:27 +0200 Subject: [PATCH 507/581] commands: add MSC4391 support --- commands/container.go | 40 +++++++++++++++++++++++++++++++-- commands/event.go | 51 +++++++++++++++++++++++++++++++++++++++++-- commands/handler.go | 46 +++++++++++++++++++++++++++++++++++++- commands/processor.go | 31 +++++++++++++++++++++++--- commands/reactions.go | 28 +++++++++++++++++++----- 5 files changed, 183 insertions(+), 13 deletions(-) diff --git a/commands/container.go b/commands/container.go index bc685b7b..9b909b75 100644 --- a/commands/container.go +++ b/commands/container.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Tulir Asokan +// Copyright (c) 2026 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,14 +8,20 @@ package commands import ( "fmt" + "slices" "strings" "sync" + + "go.mau.fi/util/exmaps" + + "maunium.net/go/mautrix/event/cmdschema" ) type CommandContainer[MetaType any] struct { commands map[string]*Handler[MetaType] aliases map[string]string lock sync.RWMutex + parent *Handler[MetaType] } func NewCommandContainer[MetaType any]() *CommandContainer[MetaType] { @@ -25,6 +31,29 @@ func NewCommandContainer[MetaType any]() *CommandContainer[MetaType] { } } +func (cont *CommandContainer[MetaType]) AllSpecs() []*cmdschema.EventContent { + data := make(exmaps.Set[*Handler[MetaType]]) + cont.collectHandlers(data) + specs := make([]*cmdschema.EventContent, 0, data.Size()) + for handler := range data.Iter() { + if handler.Parameters != nil { + specs = append(specs, handler.Spec()) + } + } + return specs +} + +func (cont *CommandContainer[MetaType]) collectHandlers(into exmaps.Set[*Handler[MetaType]]) { + cont.lock.RLock() + defer cont.lock.RUnlock() + for _, handler := range cont.commands { + into.Add(handler) + if handler.subcommandContainer != nil { + handler.subcommandContainer.collectHandlers(into) + } + } +} + // Register registers the given command handlers. func (cont *CommandContainer[MetaType]) Register(handlers ...*Handler[MetaType]) { if cont == nil { @@ -32,7 +61,10 @@ func (cont *CommandContainer[MetaType]) Register(handlers ...*Handler[MetaType]) } cont.lock.Lock() defer cont.lock.Unlock() - for _, handler := range handlers { + for i, handler := range handlers { + if handler == nil { + panic(fmt.Errorf("handler #%d is nil", i+1)) + } cont.registerOne(handler) } } @@ -45,6 +77,10 @@ func (cont *CommandContainer[MetaType]) registerOne(handler *Handler[MetaType]) } else if aliasTarget, alreadyExists := cont.aliases[handler.Name]; alreadyExists { panic(fmt.Errorf("tried to register command %q, but it's already registered as an alias for %q", handler.Name, aliasTarget)) } + if !slices.Contains(handler.parents, cont.parent) { + handler.parents = append(handler.parents, cont.parent) + handler.nestedNameCache = nil + } cont.commands[handler.Name] = handler for _, alias := range handler.Aliases { if strings.ToLower(alias) != alias { diff --git a/commands/event.go b/commands/event.go index 77a3c0d2..76d6c9f0 100644 --- a/commands/event.go +++ b/commands/event.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Tulir Asokan +// Copyright (c) 2026 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,6 +8,7 @@ package commands import ( "context" + "encoding/json" "fmt" "strings" @@ -35,6 +36,8 @@ type Event[MetaType any] struct { // RawArgs is the same as args, but without the splitting by whitespace. RawArgs string + StructuredArgs json.RawMessage + Ctx context.Context Log *zerolog.Logger Proc *Processor[MetaType] @@ -61,7 +64,7 @@ var IDHTMLParser = &format.HTMLParser{ } // ParseEvent parses a message into a command event struct. -func ParseEvent[MetaType any](ctx context.Context, evt *event.Event) *Event[MetaType] { +func (proc *Processor[MetaType]) ParseEvent(ctx context.Context, evt *event.Event) *Event[MetaType] { content, ok := evt.Content.Parsed.(*event.MessageEventContent) if !ok || content.MsgType == event.MsgNotice || content.RelatesTo.GetReplaceID() != "" { return nil @@ -70,12 +73,34 @@ func ParseEvent[MetaType any](ctx context.Context, evt *event.Event) *Event[Meta if content.Format == event.FormatHTML { text = IDHTMLParser.Parse(content.FormattedBody, format.NewContext(ctx)) } + if content.MSC4391BotCommand != nil { + if !content.Mentions.Has(proc.Client.UserID) || len(content.Mentions.UserIDs) != 1 { + return nil + } + wrapped := StructuredCommandToEvent[MetaType](ctx, evt, content.MSC4391BotCommand) + wrapped.RawInput = text + return wrapped + } if len(text) == 0 { return nil } return RawTextToEvent[MetaType](ctx, evt, text) } +func StructuredCommandToEvent[MetaType any](ctx context.Context, evt *event.Event, content *event.MSC4391BotCommandInput) *Event[MetaType] { + commandParts := strings.Split(content.Command, " ") + return &Event[MetaType]{ + Event: evt, + // Fake a command and args to let the subcommand finder in Process work. + Command: commandParts[0], + Args: commandParts[1:], + Ctx: ctx, + Log: zerolog.Ctx(ctx), + + StructuredArgs: content.Arguments, + } +} + func RawTextToEvent[MetaType any](ctx context.Context, evt *event.Event, text string) *Event[MetaType] { parts := strings.Fields(text) if len(parts) == 0 { @@ -188,3 +213,25 @@ func (evt *Event[MetaType]) UnshiftArg(arg string) { evt.RawArgs = arg + " " + evt.RawArgs evt.Args = append([]string{arg}, evt.Args...) } + +func (evt *Event[MetaType]) ParseArgs(into any) error { + return json.Unmarshal(evt.StructuredArgs, into) +} + +func ParseArgs[T, MetaType any](evt *Event[MetaType]) (into T, err error) { + err = evt.ParseArgs(&into) + return +} + +func WithParsedArgs[T, MetaType any](fn func(*Event[MetaType], T)) func(*Event[MetaType]) { + return func(evt *Event[MetaType]) { + parsed, err := ParseArgs[T, MetaType](evt) + if err != nil { + evt.Log.Debug().Err(err).Msg("Failed to parse structured args into struct") + // TODO better error, usage info? deduplicate with Process + evt.Reply("Failed to parse arguments: %v", err) + return + } + fn(evt, parsed) + } +} diff --git a/commands/handler.go b/commands/handler.go index b01d594f..3b92a908 100644 --- a/commands/handler.go +++ b/commands/handler.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Tulir Asokan +// Copyright (c) 2026 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,6 +8,9 @@ package commands import ( "strings" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/event/cmdschema" ) type Handler[MetaType any] struct { @@ -25,12 +28,53 @@ type Handler[MetaType any] struct { // Event.ShiftArg will likely be useful for implementing such parameters. PreFunc func(ce *Event[MetaType]) + // Description is a short description of the command. + Description *event.ExtensibleTextContainer + // Parameters is a description of structured command parameters. + // If set, the StructuredArgs field of Event will be populated. + Parameters []*cmdschema.Parameter + + parents []*Handler[MetaType] + nestedNameCache []string subcommandContainer *CommandContainer[MetaType] } +func (h *Handler[MetaType]) NestedNames() []string { + if h.nestedNameCache != nil { + return h.nestedNameCache + } + nestedNames := make([]string, 0, (1+len(h.Aliases))*len(h.parents)) + for _, parent := range h.parents { + if parent == nil { + nestedNames = append(nestedNames, h.Name) + nestedNames = append(nestedNames, h.Aliases...) + } else { + for _, parentName := range parent.NestedNames() { + nestedNames = append(nestedNames, parentName+" "+h.Name) + for _, alias := range h.Aliases { + nestedNames = append(nestedNames, parentName+" "+alias) + } + } + } + } + h.nestedNameCache = nestedNames + return nestedNames +} + +func (h *Handler[MetaType]) Spec() *cmdschema.EventContent { + names := h.NestedNames() + return &cmdschema.EventContent{ + Command: names[0], + Aliases: names[1:], + Parameters: h.Parameters, + Description: h.Description, + } +} + func (h *Handler[MetaType]) initSubcommandContainer() { if len(h.Subcommands) > 0 { h.subcommandContainer = NewCommandContainer[MetaType]() + h.subcommandContainer.parent = h h.subcommandContainer.Register(h.Subcommands...) } else { h.subcommandContainer = nil diff --git a/commands/processor.go b/commands/processor.go index 9341329b..0089226f 100644 --- a/commands/processor.go +++ b/commands/processor.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Tulir Asokan +// Copyright (c) 2026 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -72,9 +72,9 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) case event.EventReaction: parsed = proc.ParseReaction(ctx, evt) case event.EventMessage: - parsed = ParseEvent[MetaType](ctx, evt) + parsed = proc.ParseEvent(ctx, evt) } - if parsed == nil || !proc.PreValidator.Validate(parsed) { + if parsed == nil || (!proc.PreValidator.Validate(parsed) && parsed.StructuredArgs == nil) { return } parsed.Proc = proc @@ -107,6 +107,11 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) break } } + if parsed.StructuredArgs != nil && len(parsed.Args) > 0 { + // The client sent MSC4391 data, but the target command wasn't found + log.Debug().Msg("Didn't find handler for MSC4391 command") + return + } logWith := log.With(). Str("command", parsed.Command). @@ -116,11 +121,31 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) } if proc.LogArgs { logWith = logWith.Strs("args", parsed.Args) + if parsed.StructuredArgs != nil { + logWith = logWith.RawJSON("structured_args", parsed.StructuredArgs) + } } log = logWith.Logger() parsed.Ctx = log.WithContext(ctx) parsed.Log = &log + if handler.Parameters != nil && parsed.StructuredArgs == nil { + // The handler wants structured parameters, but the client didn't send MSC4391 data + var err error + parsed.StructuredArgs, err = handler.Spec().ParseArguments(parsed.RawArgs) + if err != nil { + log.Debug().Err(err).Msg("Failed to parse structured arguments") + // TODO better error, usage info? deduplicate with WithParsedArgs + parsed.Reply("Failed to parse arguments: %v", err) + return + } + if proc.LogArgs { + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.RawJSON("structured_args", parsed.StructuredArgs) + }) + } + } + log.Debug().Msg("Processing command") handler.Func(parsed) } diff --git a/commands/reactions.go b/commands/reactions.go index 0df372e5..0d316219 100644 --- a/commands/reactions.go +++ b/commands/reactions.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Tulir Asokan +// Copyright (c) 2026 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,6 +8,7 @@ package commands import ( "context" + "encoding/json" "strings" "github.com/rs/zerolog" @@ -19,6 +20,11 @@ import ( const ReactionCommandsKey = "fi.mau.reaction_commands" const ReactionMultiUseKey = "fi.mau.reaction_multi_use" +type ReactionCommandData struct { + Command string `json:"command"` + Args any `json:"args,omitempty"` +} + func (proc *Processor[MetaType]) ParseReaction(ctx context.Context, evt *event.Event) *Event[MetaType] { content, ok := evt.Content.Parsed.(*event.ReactionEventContent) if !ok { @@ -67,21 +73,33 @@ func (proc *Processor[MetaType]) ParseReaction(ctx context.Context, evt *event.E Msg("Reaction command not found in target event") return nil } - cmdString, ok := rawCmd.(string) - if !ok { + var wrappedEvt *Event[MetaType] + switch typedCmd := rawCmd.(type) { + case string: + wrappedEvt = RawTextToEvent[MetaType](ctx, evt, typedCmd) + case map[string]any: + var input event.MSC4391BotCommandInput + if marshaled, err := json.Marshal(typedCmd); err != nil { + + } else if err = json.Unmarshal(marshaled, &input); err != nil { + + } else { + wrappedEvt = StructuredCommandToEvent[MetaType](ctx, evt, &input) + } + } + if wrappedEvt == nil { zerolog.Ctx(ctx).Debug(). Stringer("target_event_id", evtID). Str("reaction_key", content.RelatesTo.Key). Msg("Reaction command data is invalid") return nil } - wrappedEvt := RawTextToEvent[MetaType](ctx, evt, cmdString) wrappedEvt.Proc = proc wrappedEvt.Redact() if !isMultiUse { DeleteAllReactions(ctx, proc.Client, evt) } - if cmdString == "" { + if wrappedEvt.Command == "" { return nil } return wrappedEvt From 60be95440731a8809609171916d56bc0622521fe Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 11 Jan 2026 23:42:16 +0200 Subject: [PATCH 508/581] event/cmdschema: make boolean parsing stricter --- event/cmdschema/parse.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/event/cmdschema/parse.go b/event/cmdschema/parse.go index 6536b410..5269ab28 100644 --- a/event/cmdschema/parse.go +++ b/event/cmdschema/parse.go @@ -331,10 +331,10 @@ func parseBoolean(val string) (bool, error) { if len(val) == 0 { return false, fmt.Errorf("cannot parse empty string as boolean") } - switch val[0] { - case 't', 'T', 'y', 'Y', '1': + switch strings.ToLower(val) { + case "t", "true", "y", "yes", "1": return true, nil - case 'f', 'F', 'n', 'N', '0': + case "f", "false", "n", "no", "0": return false, nil default: return false, fmt.Errorf("invalid boolean string: %q", val) From 4cd376cd90553f21cf65aac3c9e3e8116c2cfeec Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 11 Jan 2026 23:42:24 +0200 Subject: [PATCH 509/581] event/cmdschema: disallow positional optional parameters and add tail parameters --- commands/handler.go | 10 ++++++++ commands/processor.go | 1 + event/cmdschema/content.go | 13 +++++++++++ event/cmdschema/parse.go | 11 +++++---- event/cmdschema/parse_test.go | 2 +- event/cmdschema/testdata/commands/flags.json | 24 ++++++-------------- 6 files changed, 38 insertions(+), 23 deletions(-) diff --git a/commands/handler.go b/commands/handler.go index 3b92a908..56f27f06 100644 --- a/commands/handler.go +++ b/commands/handler.go @@ -33,6 +33,7 @@ type Handler[MetaType any] struct { // Parameters is a description of structured command parameters. // If set, the StructuredArgs field of Event will be populated. Parameters []*cmdschema.Parameter + TailParam string parents []*Handler[MetaType] nestedNameCache []string @@ -68,9 +69,18 @@ func (h *Handler[MetaType]) Spec() *cmdschema.EventContent { Aliases: names[1:], Parameters: h.Parameters, Description: h.Description, + TailParam: h.TailParam, } } +func (h *Handler[MetaType]) CopyFrom(other *Handler[MetaType]) { + if h.Parameters == nil { + h.Parameters = other.Parameters + h.TailParam = other.TailParam + } + h.Func = other.Func +} + func (h *Handler[MetaType]) initSubcommandContainer() { if len(h.Subcommands) > 0 { h.subcommandContainer = NewCommandContainer[MetaType]() diff --git a/commands/processor.go b/commands/processor.go index 0089226f..80f6745d 100644 --- a/commands/processor.go +++ b/commands/processor.go @@ -108,6 +108,7 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) } } if parsed.StructuredArgs != nil && len(parsed.Args) > 0 { + // TODO allow unknown command handlers to be called? // The client sent MSC4391 data, but the target command wasn't found log.Debug().Msg("Didn't find handler for MSC4391 command") return diff --git a/event/cmdschema/content.go b/event/cmdschema/content.go index b69f0c1f..e7f362ed 100644 --- a/event/cmdschema/content.go +++ b/event/cmdschema/content.go @@ -13,6 +13,7 @@ import ( "reflect" "slices" + "go.mau.fi/util/exsync" "go.mau.fi/util/ptr" "maunium.net/go/mautrix/event" @@ -24,6 +25,7 @@ type EventContent struct { Aliases []string `json:"aliases,omitempty"` Parameters []*Parameter `json:"parameters,omitempty"` Description *event.ExtensibleTextContainer `json:"description,omitempty"` + TailParam string `json:"fi.mau.tail_parameter,omitempty"` } func (ec *EventContent) Validate() error { @@ -32,11 +34,22 @@ func (ec *EventContent) Validate() error { } else if ec.Command == "" { return fmt.Errorf("command is empty") } + var tailFound bool + dupMap := exsync.NewSet[string]() for i, p := range ec.Parameters { if err := p.Validate(); err != nil { return fmt.Errorf("parameter %q (#%d) is invalid: %w", ptr.Val(p).Key, i+1, err) + } else if !dupMap.Add(p.Key) { + return fmt.Errorf("duplicate parameter key %q at #%d", p.Key, i+1) + } else if p.Key == ec.TailParam { + tailFound = true + } else if tailFound && !p.Optional { + return fmt.Errorf("required parameter %q (#%d) is after tail parameter %q", p.Key, i+1, ec.TailParam) } } + if ec.TailParam != "" && !tailFound { + return fmt.Errorf("tail parameter %q not found in parameters", ec.TailParam) + } return nil } diff --git a/event/cmdschema/parse.go b/event/cmdschema/parse.go index 5269ab28..91a02827 100644 --- a/event/cmdschema/parse.go +++ b/event/cmdschema/parse.go @@ -135,8 +135,8 @@ func (ec *EventContent) ParseArguments(input string) (json.RawMessage, error) { args[param.Key] = collector } else { nextVal, input, wasQuoted = parseQuoted(input) - if isLast && !wasQuoted && len(input) > 0 { - // If the last argument is not quoted and not variadic, just treat the rest of the string + if isLast && !wasQuoted && len(input) > 0 && !strings.Contains(input, "--") { + // If the last argument is not quoted and doesn't have flags, just treat the rest of the string // as the argument without escapes (arguments with escapes should be quoted). nextVal += " " + input input = "" @@ -146,7 +146,7 @@ func (ec *EventContent) ParseArguments(input string) (json.RawMessage, error) { args[param.Key] = true return } - if nextVal == "" && !param.Optional { + if nextVal == "" && !wasQuoted && !isNamed && !param.Optional { setError(fmt.Errorf("missing value for required parameter %s", param.Key)) } parsedVal, err := param.Schema.ParseString(nextVal) @@ -180,10 +180,11 @@ func (ec *EventContent) ParseArguments(input string) (json.RawMessage, error) { break } } - if skipParams[i] { + isTail := param.Key == ec.TailParam + if skipParams[i] || (param.Optional && !isTail) { continue } - processParameter(param, i == len(ec.Parameters)-1, false) + processParameter(param, i == len(ec.Parameters)-1 || isTail, false) } jsonArgs, marshalErr := json.Marshal(args) if marshalErr != nil { diff --git a/event/cmdschema/parse_test.go b/event/cmdschema/parse_test.go index 725b0150..1e0d1817 100644 --- a/event/cmdschema/parse_test.go +++ b/event/cmdschema/parse_test.go @@ -109,7 +109,7 @@ func TestMSC4391BotCommandEventContent_ParseInput(t *testing.T) { assert.Nil(t, output) } else { assert.Equal(t, ctd.Spec.Command, output.MSC4391BotCommand.Command) - assert.Equal(t, outputStr, exbytes.UnsafeString(output.MSC4391BotCommand.Arguments)) + assert.Equalf(t, outputStr, exbytes.UnsafeString(output.MSC4391BotCommand.Arguments), "Input: %s", test.Input) } }) } diff --git a/event/cmdschema/testdata/commands/flags.json b/event/cmdschema/testdata/commands/flags.json index dedde348..469986f0 100644 --- a/event/cmdschema/testdata/commands/flags.json +++ b/event/cmdschema/testdata/commands/flags.json @@ -27,7 +27,8 @@ "optional": true, "fi.mau.default_value": false } - ] + ], + "fi.mau.tail_parameter": "user" }, "tests": [ { @@ -35,17 +36,15 @@ "input": "/flag mrrp", "output": { "meow": "mrrp", - "user": null, - "woof": false + "user": null } }, { - "name": "positional flag", - "input": "/flag mrrp @user:example.com yes", + "name": "no flags, has tail", + "input": "/flag mrrp @user:example.com", "output": { "meow": "mrrp", - "user": "@user:example.com", - "woof": true + "user": "@user:example.com" } }, { @@ -130,18 +129,9 @@ "woof": true } }, - { - "name": "only string variables named", - "input": "/flag --user=@user:example.com --meow=mrrp yes", - "output": { - "meow": "mrrp", - "user": "@user:example.com", - "woof": true - } - }, { "name": "invalid value for named parameter", - "input": "/flag --user=meowings mrrp yes", + "input": "/flag --user=meowings mrrp --woof", "error": true, "output": { "meow": "mrrp", From e034c16753eb0b4a898e3a3aca1bfa2425856933 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 11 Jan 2026 23:54:10 +0200 Subject: [PATCH 510/581] event/cmdschema: don't allow flags after tail parameter --- event/cmdschema/content.go | 3 +- event/cmdschema/parse.go | 10 ++-- event/cmdschema/testdata/commands/flags.json | 18 ------ event/cmdschema/testdata/commands/tail.json | 59 ++++++++++++++++++++ 4 files changed, 66 insertions(+), 24 deletions(-) create mode 100644 event/cmdschema/testdata/commands/tail.json diff --git a/event/cmdschema/content.go b/event/cmdschema/content.go index e7f362ed..ce07c4c0 100644 --- a/event/cmdschema/content.go +++ b/event/cmdschema/content.go @@ -69,7 +69,8 @@ func (ec *EventContent) Equals(other *EventContent) bool { return ec.Command == other.Command && slices.Equal(ec.Aliases, other.Aliases) && slices.EqualFunc(ec.Parameters, other.Parameters, (*Parameter).Equals) && - ec.Description.Equals(other.Description) + ec.Description.Equals(other.Description) && + ec.TailParam == other.TailParam } func init() { diff --git a/event/cmdschema/parse.go b/event/cmdschema/parse.go index 91a02827..fbb8b671 100644 --- a/event/cmdschema/parse.go +++ b/event/cmdschema/parse.go @@ -92,7 +92,7 @@ func (ec *EventContent) ParseArguments(input string) (json.RawMessage, error) { retErr = err } } - processParameter := func(param *Parameter, isLast, isNamed bool) { + processParameter := func(param *Parameter, isLast, isTail, isNamed bool) { origInput := input var nextVal string var wasQuoted bool @@ -135,8 +135,8 @@ func (ec *EventContent) ParseArguments(input string) (json.RawMessage, error) { args[param.Key] = collector } else { nextVal, input, wasQuoted = parseQuoted(input) - if isLast && !wasQuoted && len(input) > 0 && !strings.Contains(input, "--") { - // If the last argument is not quoted and doesn't have flags, just treat the rest of the string + if (isLast || isTail) && !wasQuoted && len(input) > 0 { + // If the last argument is not quoted, just treat the rest of the string // as the argument without escapes (arguments with escapes should be quoted). nextVal += " " + input input = "" @@ -175,7 +175,7 @@ func (ec *EventContent) ParseArguments(input string) (json.RawMessage, error) { // Trim the equals sign, but leave spaces alone to let parseQuoted treat it as empty input input = strings.TrimPrefix(input[nameEndIdx:], "=") skipParams[paramIdx] = true - processParameter(overrideParam, false, true) + processParameter(overrideParam, false, false, true) } else { break } @@ -184,7 +184,7 @@ func (ec *EventContent) ParseArguments(input string) (json.RawMessage, error) { if skipParams[i] || (param.Optional && !isTail) { continue } - processParameter(param, i == len(ec.Parameters)-1 || isTail, false) + processParameter(param, i == len(ec.Parameters)-1, isTail, false) } jsonArgs, marshalErr := json.Marshal(args) if marshalErr != nil { diff --git a/event/cmdschema/testdata/commands/flags.json b/event/cmdschema/testdata/commands/flags.json index 469986f0..89f0f334 100644 --- a/event/cmdschema/testdata/commands/flags.json +++ b/event/cmdschema/testdata/commands/flags.json @@ -93,24 +93,6 @@ "woof": false } }, - { - "name": "named flag at end", - "input": "/flag mrrp @user:example.com --woof=yes", - "output": { - "meow": "mrrp", - "user": "@user:example.com", - "woof": true - } - }, - { - "name": "named flag at end without value", - "input": "/flag mrrp @user:example.com --woof", - "output": { - "meow": "mrrp", - "user": "@user:example.com", - "woof": true - } - }, { "name": "all variables named", "input": "/flag --woof=no --meow=mrrp --user=@user:example.com", diff --git a/event/cmdschema/testdata/commands/tail.json b/event/cmdschema/testdata/commands/tail.json new file mode 100644 index 00000000..db6f79d4 --- /dev/null +++ b/event/cmdschema/testdata/commands/tail.json @@ -0,0 +1,59 @@ +{ + "spec": { + "command": "tail", + "source": "@testbot", + "parameters": [ + { + "key": "meow", + "schema": { + "schema_type": "primitive", + "type": "string" + } + }, + { + "key": "reason", + "schema": { + "schema_type": "primitive", + "type": "string" + }, + "optional": true + }, + { + "key": "woof", + "schema": { + "schema_type": "primitive", + "type": "boolean" + }, + "optional": true + } + ], + "fi.mau.tail_parameter": "reason" + }, + "tests": [ + { + "name": "no tail or flag", + "input": "/tail mrrp", + "output": { + "meow": "mrrp", + "reason": "" + } + }, + { + "name": "tail, no flag", + "input": "/tail mrrp meow meow", + "output": { + "meow": "mrrp", + "reason": "meow meow" + } + }, + { + "name": "flag before tail", + "input": "/tail mrrp --woof meow meow", + "output": { + "meow": "mrrp", + "reason": "meow meow", + "woof": true + } + } + ] +} From 4c0b511c01e06d3dceaa5f2698fd6e9710b7181c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 12 Jan 2026 00:52:24 +0200 Subject: [PATCH 511/581] event/cmdschema: add JSON schemas for test data --- event/cmdschema/testdata/commands.schema.json | 281 ++++++++++++++++++ event/cmdschema/testdata/commands/flags.json | 1 + .../testdata/commands/room_id_or_alias.json | 1 + .../commands/room_reference_list.json | 1 + event/cmdschema/testdata/commands/simple.json | 1 + event/cmdschema/testdata/commands/tail.json | 1 + .../testdata/parse_quote.schema.json | 46 +++ 7 files changed, 332 insertions(+) create mode 100644 event/cmdschema/testdata/commands.schema.json create mode 100644 event/cmdschema/testdata/parse_quote.schema.json diff --git a/event/cmdschema/testdata/commands.schema.json b/event/cmdschema/testdata/commands.schema.json new file mode 100644 index 00000000..e53382db --- /dev/null +++ b/event/cmdschema/testdata/commands.schema.json @@ -0,0 +1,281 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema#", + "$id": "commands.schema.json", + "title": "ParseInput test cases", + "description": "JSON schema for test case files containing command specifications and test cases", + "type": "object", + "required": [ + "spec", + "tests" + ], + "additionalProperties": false, + "properties": { + "spec": { + "title": "MSC4391 Command Description", + "description": "JSON schema defining the structure of a bot command event content", + "type": "object", + "required": [ + "command" + ], + "additionalProperties": false, + "properties": { + "command": { + "type": "string", + "description": "The command name that triggers this bot command" + }, + "aliases": { + "type": "array", + "description": "Alternative names/aliases for this command", + "items": { + "type": "string" + } + }, + "parameters": { + "type": "array", + "description": "List of parameters accepted by this command", + "items": { + "$ref": "#/$defs/Parameter" + } + }, + "description": { + "$ref": "#/$defs/ExtensibleTextContainer", + "description": "Human-readable description of the command" + }, + "fi.mau.tail_parameter": { + "type": "string", + "description": "The key of the parameter that accepts remaining arguments as tail text" + }, + "source": { + "type": "string", + "description": "The user ID of the bot that responds to this command" + } + } + }, + "tests": { + "type": "array", + "description": "Array of test cases for the command", + "items": { + "type": "object", + "description": "A single test case for command parsing", + "required": [ + "name", + "input" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the test case" + }, + "input": { + "type": "string", + "description": "The command input string to parse" + }, + "output": { + "description": "The expected parsed parameter values, or null if the parsing is expected to fail", + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "error": { + "type": "boolean", + "description": "Whether parsing should result in an error. May still produce output.", + "default": false + } + } + } + } + }, + "$defs": { + "ExtensibleTextContainer": { + "type": "object", + "description": "Container for text that can have multiple representations", + "required": [ + "m.text" + ], + "properties": { + "m.text": { + "type": "array", + "description": "Array of text representations in different formats", + "items": { + "$ref": "#/$defs/ExtensibleText" + } + } + } + }, + "ExtensibleText": { + "type": "object", + "description": "A text representation with a specific MIME type", + "required": [ + "body" + ], + "properties": { + "body": { + "type": "string", + "description": "The text content" + }, + "mimetype": { + "type": "string", + "description": "The MIME type of the text (e.g., text/plain, text/html)", + "default": "text/plain", + "examples": [ + "text/plain", + "text/html" + ] + } + } + }, + "Parameter": { + "type": "object", + "description": "A parameter definition for a command", + "required": [ + "key", + "schema" + ], + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "The identifier for this parameter" + }, + "schema": { + "$ref": "#/$defs/ParameterSchema", + "description": "The schema defining the type and structure of this parameter" + }, + "optional": { + "type": "boolean", + "description": "Whether this parameter is optional", + "default": false + }, + "description": { + "$ref": "#/$defs/ExtensibleTextContainer", + "description": "Human-readable description of this parameter" + }, + "fi.mau.default_value": { + "description": "Default value for this parameter if not provided" + } + } + }, + "ParameterSchema": { + "type": "object", + "description": "Schema definition for a parameter value", + "required": [ + "schema_type" + ], + "additionalProperties": false, + "properties": { + "schema_type": { + "type": "string", + "enum": [ + "primitive", + "array", + "union", + "literal" + ], + "description": "The type of schema" + } + }, + "allOf": [ + { + "if": { + "properties": { + "schema_type": { + "const": "primitive" + } + } + }, + "then": { + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "string", + "integer", + "boolean", + "server_name", + "user_id", + "room_id", + "room_alias", + "event_id" + ], + "description": "The primitive type (only for schema_type: primitive)" + } + } + } + }, + { + "if": { + "properties": { + "schema_type": { + "const": "array" + } + } + }, + "then": { + "required": [ + "items" + ], + "properties": { + "items": { + "$ref": "#/$defs/ParameterSchema", + "description": "The schema for array items (only for schema_type: array)" + } + } + } + }, + { + "if": { + "properties": { + "schema_type": { + "const": "union" + } + } + }, + "then": { + "required": [ + "variants" + ], + "properties": { + "variants": { + "type": "array", + "description": "The possible variants (only for schema_type: union)", + "items": { + "$ref": "#/$defs/ParameterSchema" + }, + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": { + "schema_type": { + "const": "literal" + } + } + }, + "then": { + "required": [ + "value" + ], + "properties": { + "value": { + "description": "The literal value (only for schema_type: literal)" + } + } + } + } + ] + } + } +} diff --git a/event/cmdschema/testdata/commands/flags.json b/event/cmdschema/testdata/commands/flags.json index 89f0f334..6ce1f4da 100644 --- a/event/cmdschema/testdata/commands/flags.json +++ b/event/cmdschema/testdata/commands/flags.json @@ -1,4 +1,5 @@ { + "$schema": "../commands.schema.json#", "spec": { "command": "flag", "source": "@testbot", diff --git a/event/cmdschema/testdata/commands/room_id_or_alias.json b/event/cmdschema/testdata/commands/room_id_or_alias.json index 0dc233b8..1351c292 100644 --- a/event/cmdschema/testdata/commands/room_id_or_alias.json +++ b/event/cmdschema/testdata/commands/room_id_or_alias.json @@ -1,4 +1,5 @@ { + "$schema": "../commands.schema.json#", "spec": { "command": "test room reference", "source": "@testbot", diff --git a/event/cmdschema/testdata/commands/room_reference_list.json b/event/cmdschema/testdata/commands/room_reference_list.json index 99388f90..aa266054 100644 --- a/event/cmdschema/testdata/commands/room_reference_list.json +++ b/event/cmdschema/testdata/commands/room_reference_list.json @@ -1,4 +1,5 @@ { + "$schema": "../commands.schema.json#", "spec": { "command": "test room reference", "source": "@testbot", diff --git a/event/cmdschema/testdata/commands/simple.json b/event/cmdschema/testdata/commands/simple.json index 8127aff1..94667323 100644 --- a/event/cmdschema/testdata/commands/simple.json +++ b/event/cmdschema/testdata/commands/simple.json @@ -1,4 +1,5 @@ { + "$schema": "../commands.schema.json#", "spec": { "command": "test simple", "source": "@testbot", diff --git a/event/cmdschema/testdata/commands/tail.json b/event/cmdschema/testdata/commands/tail.json index db6f79d4..9782f8ec 100644 --- a/event/cmdschema/testdata/commands/tail.json +++ b/event/cmdschema/testdata/commands/tail.json @@ -1,4 +1,5 @@ { + "$schema": "../commands.schema.json#", "spec": { "command": "tail", "source": "@testbot", diff --git a/event/cmdschema/testdata/parse_quote.schema.json b/event/cmdschema/testdata/parse_quote.schema.json new file mode 100644 index 00000000..9f249116 --- /dev/null +++ b/event/cmdschema/testdata/parse_quote.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema#", + "$id": "parse_quote.schema.json", + "title": "parseQuote test cases", + "description": "Test cases for the parseQuoted function", + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "input", + "output" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the test case" + }, + "input": { + "type": "string", + "description": "Input string to be parsed" + }, + "output": { + "type": "array", + "description": "Expected output of parsing: [first word, remaining text, was quoted]", + "minItems": 3, + "maxItems": 3, + "prefixItems": [ + { + "type": "string", + "description": "First parsed word" + }, + { + "type": "string", + "description": "Remaining text after the first word" + }, + { + "type": "boolean", + "description": "Whether the first word was quoted" + } + ] + } + }, + "additionalProperties": false + } +} From 650f9c3139b5e45ef4df6bce914e1bb235607f8b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 12 Jan 2026 00:57:12 +0200 Subject: [PATCH 512/581] event/cmdschema: adjust handling of unterminated quotes --- event/cmdschema/parse.go | 8 +++++++- event/cmdschema/testdata/parse_quote.json | 12 +++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/event/cmdschema/parse.go b/event/cmdschema/parse.go index fbb8b671..92e69b60 100644 --- a/event/cmdschema/parse.go +++ b/event/cmdschema/parse.go @@ -39,7 +39,13 @@ func parseQuoted(val string) (parsed, remaining string, quoted bool) { var buf strings.Builder for { quoteIdx := strings.IndexByte(val, '"') - escapeIdx := strings.IndexByte(val[:max(0, quoteIdx)], '\\') + var valUntilQuote string + if quoteIdx == -1 { + valUntilQuote = val + } else { + valUntilQuote = val[:quoteIdx] + } + escapeIdx := strings.IndexByte(valUntilQuote, '\\') if escapeIdx >= 0 { buf.WriteString(val[:escapeIdx]) if len(val) > escapeIdx+1 { diff --git a/event/cmdschema/testdata/parse_quote.json b/event/cmdschema/testdata/parse_quote.json index d22f1299..8f52b7f5 100644 --- a/event/cmdschema/testdata/parse_quote.json +++ b/event/cmdschema/testdata/parse_quote.json @@ -1,12 +1,21 @@ [ + {"name": "empty string", "input": "", "output": ["", "", false]}, {"name": "single word", "input": "meow", "output": ["meow", "", false]}, {"name": "two words", "input": "meow woof", "output": ["meow", "woof", false]}, {"name": "many words", "input": "meow meow mrrp", "output": ["meow", "meow mrrp", false]}, {"name": "extra spaces", "input": "meow meow mrrp", "output": ["meow", "meow mrrp", false]}, {"name": "trailing space", "input": "meow ", "output": ["meow", "", false]}, + {"name": "only spaces", "input": " ", "output": ["", "", false]}, + {"name": "leading spaces", "input": " meow woof", "output": ["", "meow woof", false]}, + {"name": "backslash at end unquoted", "input": "meow\\ woof", "output": ["meow\\", "woof", false]}, {"name": "quoted word", "input": "\"meow\" meow mrrp", "output": ["meow", "meow mrrp", true]}, {"name": "quoted words", "input": "\"meow meow\" mrrp", "output": ["meow meow", "mrrp", true]}, {"name": "spaces in quotes", "input": "\" meow meow \" mrrp", "output": [" meow meow ", "mrrp", true]}, + {"name": "empty quoted string", "input": "\"\"", "output": ["", "", true]}, + {"name": "empty quoted with trailing", "input": "\"\" meow", "output": ["", "meow", true]}, + {"name": "quote no space before next", "input": "\"meow\"woof", "output": ["meow", "woof", true]}, + {"name": "just opening quote", "input": "\"", "output": ["", "", true]}, + {"name": "quote then space then text", "input": "\" meow", "output": [" meow", "", true]}, {"name": "quotes after word", "input": "meow \" meow mrrp \"", "output": ["meow", "\" meow mrrp \"", false]}, {"name": "escaped quote", "input": "\"meow\\\" meow\" mrrp", "output": ["meow\" meow", "mrrp", true]}, {"name": "missing end quote", "input": "\"meow meow mrrp", "output": ["meow meow mrrp", "", true]}, @@ -16,5 +25,6 @@ {"name": "other escaped character", "input": "\"m\\eow\" meow mrrp", "output": ["meow", "meow mrrp", true]}, {"name": "escaped backslashes", "input": "\"m\\\\e\\\"ow\\\\\" meow mrrp", "output": ["m\\e\"ow\\", "meow mrrp", true]}, {"name": "just quotes", "input": "\"\\\"\\\"\\\\\\\"\" meow", "output": ["\"\"\\\"", "meow", true]}, - {"name": "eof escape", "input": "\"meow\\", "output": ["meow\\", "", true]} + {"name": "escape at eof", "input": "\"meow\\", "output": ["meow", "", true]}, + {"name": "escaped backslash at eof", "input": "\"meow\\\\", "output": ["meow\\", "", true]} ] From 9d70b2b845caf77f2e3793f548465f650c4b9755 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 12 Jan 2026 12:33:55 +0200 Subject: [PATCH 513/581] bridgev2/matrixinterface: properly expose GetProvisioning --- bridgev2/matrix/provisioning.go | 7 +------ bridgev2/matrixinterface.go | 9 +++++++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 44e00e64..e3d3a0b4 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -96,12 +96,7 @@ func (prov *ProvisioningAPI) GetRouter() *http.ServeMux { return prov.Router } -type IProvisioningAPI interface { - GetRouter() *http.ServeMux - GetUser(r *http.Request) *bridgev2.User -} - -func (br *Connector) GetProvisioning() IProvisioningAPI { +func (br *Connector) GetProvisioning() bridgev2.IProvisioningAPI { return br.Provisioning } diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index 07615daf..f24390bf 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -67,6 +67,15 @@ type MatrixConnectorWithServer interface { GetRouter() *http.ServeMux } +type IProvisioningAPI interface { + GetRouter() *http.ServeMux + GetUser(r *http.Request) *User +} + +type MatrixConnectorWithProvisioning interface { + GetProvisioning() IProvisioningAPI +} + type MatrixConnectorWithPublicMedia interface { GetPublicMediaAddress(contentURI id.ContentURIString) string GetPublicMediaAddressForEvent(ctx context.Context, evt *event.MessageEventContent) (string, error) From 3d5de4ed2fb012767c9c7ba1227bac1c0f420880 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 13 Jan 2026 17:05:06 +0200 Subject: [PATCH 514/581] bridgev2/matrixinterface: add parent interface to MatrixConnector subinterfaces --- bridgev2/matrixinterface.go | 12 ++++++++++++ bridgev2/networkinterface.go | 21 +++++++++++---------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index f24390bf..f9695c19 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -59,10 +59,12 @@ type MatrixConnector interface { } type MatrixConnectorWithArbitraryRoomState interface { + MatrixConnector GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error) } type MatrixConnectorWithServer interface { + MatrixConnector GetPublicAddress() string GetRouter() *http.ServeMux } @@ -73,31 +75,38 @@ type IProvisioningAPI interface { } type MatrixConnectorWithProvisioning interface { + MatrixConnector GetProvisioning() IProvisioningAPI } type MatrixConnectorWithPublicMedia interface { + MatrixConnector GetPublicMediaAddress(contentURI id.ContentURIString) string GetPublicMediaAddressForEvent(ctx context.Context, evt *event.MessageEventContent) (string, error) } type MatrixConnectorWithNameDisambiguation interface { + MatrixConnector IsConfusableName(ctx context.Context, roomID id.RoomID, userID id.UserID, name string) ([]id.UserID, error) } type MatrixConnectorWithBridgeIdentifier interface { + MatrixConnector GetUniqueBridgeID() string } type MatrixConnectorWithURLPreviews interface { + MatrixConnector GetURLPreview(ctx context.Context, url string) (*event.LinkPreview, error) } type MatrixConnectorWithPostRoomBridgeHandling interface { + MatrixConnector HandleNewlyBridgedRoom(ctx context.Context, roomID id.RoomID) error } type MatrixConnectorWithAnalytics interface { + MatrixConnector TrackAnalytics(userID id.UserID, event string, properties map[string]any) } @@ -112,6 +121,7 @@ type DirectNotificationData struct { } type MatrixConnectorWithNotifications interface { + MatrixConnector DisplayNotification(ctx context.Context, data *DirectNotificationData) } @@ -192,9 +202,11 @@ type MatrixAPI interface { } type StreamOrderReadingMatrixAPI interface { + MatrixAPI MarkStreamOrderRead(ctx context.Context, roomID id.RoomID, streamOrder int64, ts time.Time) error } type MarkAsDMMatrixAPI interface { + MatrixAPI MarkAsDM(ctx context.Context, roomID id.RoomID, otherUser id.UserID) error } diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index adbd3155..3e25031f 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -261,6 +261,7 @@ type NetworkConnector interface { } type StoppableNetwork interface { + NetworkConnector // Stop is called when the bridge is stopping, after all network clients have been disconnected. Stop() } @@ -295,11 +296,6 @@ type PortalBridgeInfoFillingNetwork interface { FillPortalBridgeInfo(portal *Portal, content *event.BridgeEventContent) } -type PersonalFilteringCustomizingNetworkAPI interface { - NetworkAPI - CustomizePersonalFilteringSpace(req *mautrix.ReqCreateRoom) -} - // ConfigValidatingNetwork is an optional interface that network connectors can implement to validate config fields // before the bridge is started. // @@ -792,6 +788,16 @@ type UserSearchingNetworkAPI interface { SearchUsers(ctx context.Context, query string) ([]*ResolveIdentifierResponse, error) } +type GroupCreatingNetworkAPI interface { + IdentifierResolvingNetworkAPI + CreateGroup(ctx context.Context, params *GroupCreateParams) (*CreateChatResponse, error) +} + +type PersonalFilteringCustomizingNetworkAPI interface { + NetworkAPI + CustomizePersonalFilteringSpace(req *mautrix.ReqCreateRoom) +} + type ProvisioningCapabilities struct { ResolveIdentifier ResolveIdentifierCapabilities `json:"resolve_identifier"` GroupCreation map[string]GroupTypeCapabilities `json:"group_creation"` @@ -863,11 +869,6 @@ type GroupCreateParams struct { RoomID id.RoomID `json:"room_id,omitempty"` } -type GroupCreatingNetworkAPI interface { - IdentifierResolvingNetworkAPI - CreateGroup(ctx context.Context, params *GroupCreateParams) (*CreateChatResponse, error) -} - type MembershipChangeType struct { From event.Membership To event.Membership From d77cb628ffd2e1921897e7379526f2c011a68817 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 13 Jan 2026 23:11:50 +0200 Subject: [PATCH 515/581] bridgev2/matrixinterface: let matrix connector suggest HTTP client settings --- bridgev2/matrixinterface.go | 7 +++++++ go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index f9695c19..57f786bb 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -14,6 +14,8 @@ import ( "os" "time" + "go.mau.fi/util/exhttp" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" @@ -125,6 +127,11 @@ type MatrixConnectorWithNotifications interface { DisplayNotification(ctx context.Context, data *DirectNotificationData) } +type MatrixConnectorWithHTTPSettings interface { + MatrixConnector + GetHTTPClientSettings() exhttp.ClientSettings +} + type MatrixSendExtra struct { Timestamp time.Time MessageMeta *database.Message diff --git a/go.mod b/go.mod index cdb62f20..544a9ff4 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.13 - go.mau.fi/util v0.9.4 + go.mau.fi/util v0.9.5-0.20260113180831-8cda92561373 go.mau.fi/zeroconfig v0.2.0 golang.org/x/crypto v0.46.0 golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 diff --git a/go.sum b/go.sum index a55f0661..70a1b5a9 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.4 h1:gWdUff+K2rCynRPysXalqqQyr2ahkSWaestH6YhSpso= -go.mau.fi/util v0.9.4/go.mod h1:647nVfwUvuhlZFOnro3aRNPmRd2y3iDha9USb8aKSmM= +go.mau.fi/util v0.9.5-0.20260113180831-8cda92561373 h1:LjFGO80c9mGeYCvrBsASvK9jx3oPkXo++l9quy4YMls= +go.mau.fi/util v0.9.5-0.20260113180831-8cda92561373/go.mod h1:647nVfwUvuhlZFOnro3aRNPmRd2y3iDha9USb8aKSmM= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= From 38799be3ca6f9ae112ac33677435ada2df0bb50a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 13 Jan 2026 23:17:41 +0200 Subject: [PATCH 516/581] bridgev2/networkinterface: let matrix connector reset remote network connections --- bridgev2/bridge.go | 28 ++++++++++++++++++++++++++++ bridgev2/networkinterface.go | 10 ++++++++++ bridgev2/userlogin.go | 7 +++++++ 3 files changed, 45 insertions(+) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index c84c2fd5..3825333c 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -373,6 +373,34 @@ func (br *Bridge) StartLogins(ctx context.Context) error { return nil } +func (br *Bridge) ResetNetworkConnections() { + nrn, ok := br.Network.(NetworkResettingNetwork) + if ok { + br.Log.Info().Msg("Resetting network connections with NetworkConnector.ResetNetworkConnections") + nrn.ResetNetworkConnections() + return + } + + br.Log.Info().Msg("Network connector doesn't support ResetNetworkConnections, recreating clients manually") + for _, login := range br.GetAllCachedUserLogins() { + login.Log.Debug().Msg("Disconnecting and recreating client for network reset") + ctx := login.Log.WithContext(br.BackgroundCtx) + login.Client.Disconnect() + err := login.recreateClient(ctx) + if err != nil { + login.Log.Err(err).Msg("Failed to recreate client during network reset") + login.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateUnknownError, + Error: "bridgev2-network-reset-fail", + Info: map[string]any{"go_error": err.Error()}, + }) + } else { + login.Client.Connect(ctx) + } + } + br.Log.Info().Msg("Finished resetting all user logins") +} + func (br *Bridge) IsStopping() bool { return br.stopping.Load() } diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 3e25031f..0e9a8543 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -318,6 +318,16 @@ type MaxFileSizeingNetwork interface { SetMaxFileSize(maxSize int64) } +type NetworkResettingNetwork interface { + NetworkConnector + // ResetHTTPTransport should recreate the HTTP client used by the bridge. + // It should refetch settings from the Matrix connector using GetHTTPClientSettings if applicable. + ResetHTTPTransport() + // ResetNetworkConnections should forcefully disconnect and restart any persistent network connections. + // ResetHTTPTransport will usually be called before this, so resetting the transport is not necessary here. + ResetNetworkConnections() +} + type RemoteEchoHandler func(RemoteMessage, *database.Message) (bool, error) type MatrixMessageResponse struct { diff --git a/bridgev2/userlogin.go b/bridgev2/userlogin.go index b5fcfcd0..c9102248 100644 --- a/bridgev2/userlogin.go +++ b/bridgev2/userlogin.go @@ -10,6 +10,7 @@ import ( "cmp" "context" "fmt" + "maps" "slices" "sync" "time" @@ -140,6 +141,12 @@ func (br *Bridge) GetCachedUserLoginByID(id networkid.UserLoginID) *UserLogin { return br.userLoginsByID[id] } +func (br *Bridge) GetAllCachedUserLogins() (logins []*UserLogin) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + return slices.Collect(maps.Values(br.userLoginsByID)) +} + func (br *Bridge) GetCurrentBridgeStates() (states []status.BridgeState) { br.cacheLock.Lock() defer br.cacheLock.Unlock() From 75f9cb369bea0a3756e91f2ef9cdce86d6f4ffe9 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 14 Jan 2026 17:06:32 +0200 Subject: [PATCH 517/581] bridgev2: add helper method for getting HTTP settings from matrix connector --- bridgev2/bridge.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bridgev2/bridge.go b/bridgev2/bridge.go index 3825333c..226adc90 100644 --- a/bridgev2/bridge.go +++ b/bridgev2/bridge.go @@ -16,6 +16,7 @@ import ( "github.com/rs/zerolog" "go.mau.fi/util/dbutil" + "go.mau.fi/util/exhttp" "go.mau.fi/util/exsync" "maunium.net/go/mautrix/bridgev2/bridgeconfig" @@ -401,6 +402,14 @@ func (br *Bridge) ResetNetworkConnections() { br.Log.Info().Msg("Finished resetting all user logins") } +func (br *Bridge) GetHTTPClientSettings() exhttp.ClientSettings { + mchs, ok := br.Matrix.(MatrixConnectorWithHTTPSettings) + if ok { + return mchs.GetHTTPClientSettings() + } + return exhttp.SensibleClientSettings +} + func (br *Bridge) IsStopping() bool { return br.stopping.Load() } From 34bcd027e54ce56d61c2f265bfa025d194f410df Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 15 Jan 2026 14:02:00 +0200 Subject: [PATCH 518/581] bridgev2/commands: add debug command for resetting connections --- bridgev2/commands/debug.go | 22 ++++++++++++++++++++++ bridgev2/commands/processor.go | 3 ++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/bridgev2/commands/debug.go b/bridgev2/commands/debug.go index ad773ac8..1cae98fe 100644 --- a/bridgev2/commands/debug.go +++ b/bridgev2/commands/debug.go @@ -101,3 +101,25 @@ var CommandSendAccountData = &FullHandler{ RequiresPortal: true, RequiresLogin: true, } + +var CommandResetNetwork = &FullHandler{ + Func: func(ce *Event) { + if strings.Contains(strings.ToLower(ce.RawArgs), "--reset-transport") { + nrn, ok := ce.Bridge.Network.(bridgev2.NetworkResettingNetwork) + if ok { + nrn.ResetHTTPTransport() + } else { + ce.Reply("Network connector does not support resetting HTTP transport") + } + } + ce.Bridge.ResetNetworkConnections() + ce.React("✅️") + }, + Name: "debug-reset-network", + Help: HelpMeta{ + Section: HelpSectionAdmin, + Description: "Reset network connections to the remote network", + Args: "[--reset-transport]", + }, + RequiresAdmin: true, +} diff --git a/bridgev2/commands/processor.go b/bridgev2/commands/processor.go index 692db80d..391c3685 100644 --- a/bridgev2/commands/processor.go +++ b/bridgev2/commands/processor.go @@ -41,7 +41,8 @@ func NewProcessor(bridge *bridgev2.Bridge) bridgev2.CommandProcessor { } proc.AddHandlers( CommandHelp, CommandCancel, - CommandRegisterPush, CommandSendAccountData, CommandDeletePortal, CommandDeleteAllPortals, CommandSetManagementRoom, + CommandRegisterPush, CommandSendAccountData, CommandResetNetwork, + CommandDeletePortal, CommandDeleteAllPortals, CommandSetManagementRoom, CommandLogin, CommandRelogin, CommandListLogins, CommandLogout, CommandSetPreferredLogin, CommandSetRelay, CommandUnsetRelay, CommandResolveIdentifier, CommandStartChat, CommandCreateGroup, CommandSearch, CommandSyncChat, CommandMute, From 65d708f1b7d5ce7bbd4df93c611c457e381ce7ce Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 16 Jan 2026 14:50:43 +0200 Subject: [PATCH 519/581] Bump version to v0.26.2 --- CHANGELOG.md | 24 ++++++++++++++++++++++++ go.mod | 20 ++++++++++---------- go.sum | 36 ++++++++++++++++++------------------ version.go | 2 +- 4 files changed, 53 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8017ef97..dbc7c494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## v0.26.2 (2026-01-16) + +* *(bridgev2)* Added chunked portal deletion to avoid database locks when + deleting large portals. +* *(crypto,bridgev2)* Added option to encrypt reaction and reply metadata + as per [MSC4392]. +* *(bridgev2/login)* Added `default_value` for user input fields. +* *(bridgev2)* Added interfaces to let the Matrix connector provide suggested + HTTP client settings and to reset active connections of the network connector. +* *(bridgev2)* Added interface to let network connectors get the provisioning + API HTTP router and add new endpoints. +* *(event)* Added blurhash field to Beeper link preview objects. +* *(event)* Added [MSC4391] support for bot commands. +* *(event)* Dropped [MSC4332] support for bot commands. +* *(client)* Changed media download methods to return an error if the provided + MXC URI is empty. +* *(client)* Stabilized support for [MSC4323]. +* *(bridgev2/matrix)* Fixed `GetEvent` panicking when trying to decrypt events. +* *(bridgev2)* Fixed some deadlocks when room creation happens in parallel with + a portal re-ID call. + +[MSC4391]: https://github.com/matrix-org/matrix-spec-proposals/pull/4391 +[MSC4392]: https://github.com/matrix-org/matrix-spec-proposals/pull/4392 + ## v0.26.1 (2025-12-16) * **Breaking change *(mediaproxy)*** Changed `GetMediaResponseFile` to return diff --git a/go.mod b/go.mod index 544a9ff4..27acd21b 100644 --- a/go.mod +++ b/go.mod @@ -2,26 +2,26 @@ module maunium.net/go/mautrix go 1.24.0 -toolchain go1.25.5 +toolchain go1.25.6 require ( filippo.io/edwards25519 v1.1.0 github.com/chzyer/readline v1.5.1 github.com/coder/websocket v1.8.14 github.com/lib/pq v1.10.9 - github.com/mattn/go-sqlite3 v1.14.32 + github.com/mattn/go-sqlite3 v1.14.33 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.34.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 - github.com/yuin/goldmark v1.7.13 - go.mau.fi/util v0.9.5-0.20260113180831-8cda92561373 + github.com/yuin/goldmark v1.7.16 + go.mau.fi/util v0.9.5 go.mau.fi/zeroconfig v0.2.0 - golang.org/x/crypto v0.46.0 - golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 - golang.org/x/net v0.48.0 + golang.org/x/crypto v0.47.0 + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 + golang.org/x/net v0.49.0 golang.org/x/sync v0.19.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 @@ -32,11 +32,11 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect + github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index 70a1b5a9..9702337a 100644 --- a/go.sum +++ b/go.sum @@ -25,10 +25,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a h1:VweslR2akb/ARhXfqSfRbj1vpWwYXf3eeAUyw/ndms0= -github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -50,28 +50,28 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= -github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.5-0.20260113180831-8cda92561373 h1:LjFGO80c9mGeYCvrBsASvK9jx3oPkXo++l9quy4YMls= -go.mau.fi/util v0.9.5-0.20260113180831-8cda92561373/go.mod h1:647nVfwUvuhlZFOnro3aRNPmRd2y3iDha9USb8aKSmM= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mau.fi/util v0.9.5 h1:7AoWPCIZJGv4jvtFEuCe3GhAbI7uF9ckIooaXvwlIR4= +go.mau.fi/util v0.9.5/go.mod h1:g1uvZ03VQhtTt2BgaRGVytS/Zj67NV0YNIECch0sQCQ= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= -golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= diff --git a/version.go b/version.go index 46d3342c..b0e31c7e 100644 --- a/version.go +++ b/version.go @@ -8,7 +8,7 @@ import ( "strings" ) -const Version = "v0.26.1" +const Version = "v0.26.2" var GoModVersion = "" var Commit = "" From 0e4b074b571c1fa1f6cbd1f256b3b1572fe82fe1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 17 Jan 2026 00:43:41 +0200 Subject: [PATCH 520/581] event: add detail to not json string parse error --- event/encryption.go | 2 +- id/contenturi.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/event/encryption.go b/event/encryption.go index e07944af..8e386b60 100644 --- a/event/encryption.go +++ b/event/encryption.go @@ -63,7 +63,7 @@ func (content *EncryptedEventContent) UnmarshalJSON(data []byte) error { return json.Unmarshal(content.Ciphertext, &content.OlmCiphertext) case id.AlgorithmMegolmV1: if len(content.Ciphertext) == 0 || content.Ciphertext[0] != '"' || content.Ciphertext[len(content.Ciphertext)-1] != '"' { - return id.ErrInputNotJSONString + return fmt.Errorf("ciphertext %w", id.ErrInputNotJSONString) } content.MegolmCiphertext = content.Ciphertext[1 : len(content.Ciphertext)-1] } diff --git a/id/contenturi.go b/id/contenturi.go index be45eb2b..67127b6c 100644 --- a/id/contenturi.go +++ b/id/contenturi.go @@ -92,7 +92,7 @@ func (uri *ContentURI) UnmarshalJSON(raw []byte) (err error) { *uri = ContentURI{} return nil } else if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' { - return ErrInputNotJSONString + return fmt.Errorf("ContentURI: %w", ErrInputNotJSONString) } parsed, err := ParseContentURIBytes(raw[1 : len(raw)-1]) if err != nil { From b226c03277ae43ffd88a1c4e6fbdb5fa0692170d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 17 Jan 2026 00:55:16 +0200 Subject: [PATCH 521/581] crypto: add length check to hacky megolm message index parser --- crypto/encryptmegolm.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crypto/encryptmegolm.go b/crypto/encryptmegolm.go index 8ce70ca0..806a227d 100644 --- a/crypto/encryptmegolm.go +++ b/crypto/encryptmegolm.go @@ -91,11 +91,16 @@ func IsShareError(err error) bool { } func ParseMegolmMessageIndex(ciphertext []byte) (uint, error) { + if len(ciphertext) == 0 { + return 0, fmt.Errorf("empty ciphertext") + } decoded := make([]byte, base64.RawStdEncoding.DecodedLen(len(ciphertext))) var err error _, err = base64.RawStdEncoding.Decode(decoded, ciphertext) if err != nil { return 0, err + } else if len(decoded) < 2+binary.MaxVarintLen64 { + return 0, fmt.Errorf("decoded ciphertext too short: %d bytes", len(decoded)) } else if decoded[0] != 3 || decoded[1] != 8 { return 0, fmt.Errorf("unexpected initial bytes %d and %d", decoded[0], decoded[1]) } From ec3cf5fbdd0e9e1c80da4c6f5f6d11ef3fdd33ce Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 17 Jan 2026 01:02:39 +0200 Subject: [PATCH 522/581] crypto/decryptmegolm: add additional checks for megolm decryption --- crypto/decryptmegolm.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crypto/decryptmegolm.go b/crypto/decryptmegolm.go index d8b419ab..59ff67a8 100644 --- a/crypto/decryptmegolm.go +++ b/crypto/decryptmegolm.go @@ -31,6 +31,7 @@ var ( ErrDeviceKeyMismatch = errors.New("device keys in event and verified device info do not match") ErrSenderKeyMismatch = errors.New("sender keys in content and megolm session do not match") ErrRatchetError = errors.New("failed to ratchet session after use") + ErrCorruptedMegolmPayload = errors.New("corrupted megolm payload") ) // Deprecated: use variables prefixed with Err @@ -56,6 +57,17 @@ var ( relatesToTopLevelPath = exgjson.Path("content", "m.relates_to") ) +const sessionIDLength = 43 + +func validateCiphertextCharacters(ciphertext []byte) bool { + for _, b := range ciphertext { + if (b < 'a' || b > 'z') && (b < 'A' || b > 'Z') && (b < '0' || b > '9') && b != '+' && b != '/' { + return false + } + } + return true +} + // DecryptMegolmEvent decrypts an m.room.encrypted event where the algorithm is m.megolm.v1.aes-sha2 func (mach *OlmMachine) DecryptMegolmEvent(ctx context.Context, evt *event.Event) (*event.Event, error) { content, ok := evt.Content.Parsed.(*event.EncryptedEventContent) @@ -63,6 +75,12 @@ func (mach *OlmMachine) DecryptMegolmEvent(ctx context.Context, evt *event.Event return nil, ErrIncorrectEncryptedContentType } else if content.Algorithm != id.AlgorithmMegolmV1 { return nil, ErrUnsupportedAlgorithm + } else if len(content.MegolmCiphertext) < 74 { + return nil, fmt.Errorf("%w: ciphertext too short (%d bytes)", ErrCorruptedMegolmPayload, len(content.MegolmCiphertext)) + } else if len(content.SessionID) != sessionIDLength { + return nil, fmt.Errorf("%w: invalid session ID length %d", ErrCorruptedMegolmPayload, len(content.SessionID)) + } else if !validateCiphertextCharacters(content.MegolmCiphertext) { + return nil, fmt.Errorf("%w: invalid characters in ciphertext", ErrCorruptedMegolmPayload) } log := mach.machOrContextLog(ctx).With(). Str("action", "decrypt megolm event"). From b2b58f3a2972cf75ec44bc510c1cc68ad5b45dd6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 17 Jan 2026 01:36:36 +0200 Subject: [PATCH 523/581] bridgev2/provisioning: cancel logins on error and delete completed logins from map --- bridgev2/matrix/provisioning.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index e3d3a0b4..17e827e3 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -407,6 +407,7 @@ func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Reque } func (prov *ProvisioningAPI) handleCompleteStep(ctx context.Context, login *ProvLogin, step *bridgev2.LoginStep) { + prov.deleteLogin(login, false) if login.Override == nil || login.Override.ID == step.CompleteParams.UserLoginID { return } @@ -420,6 +421,15 @@ func (prov *ProvisioningAPI) handleCompleteStep(ctx context.Context, login *Prov }, bridgev2.DeleteOpts{LogoutRemote: true}) } +func (prov *ProvisioningAPI) deleteLogin(login *ProvLogin, cancel bool) { + if cancel { + login.Process.Cancel() + } + prov.loginsLock.Lock() + delete(prov.logins, login.ID) + prov.loginsLock.Unlock() +} + func (prov *ProvisioningAPI) PostLoginStep(w http.ResponseWriter, r *http.Request) { loginID := r.PathValue("loginProcessID") prov.loginsLock.RLock() @@ -490,6 +500,7 @@ func (prov *ProvisioningAPI) PostLoginSubmitInput(w http.ResponseWriter, r *http if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to submit input") RespondWithError(w, err, "Internal error submitting input") + prov.deleteLogin(login, true) return } login.NextStep = nextStep @@ -508,6 +519,7 @@ func (prov *ProvisioningAPI) PostLoginWait(w http.ResponseWriter, r *http.Reques if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to wait") RespondWithError(w, err, "Internal error waiting for login") + prov.deleteLogin(login, true) return } login.NextStep = nextStep From 0b6fa137cead39d87aae5ffcee72715de9b6f698 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 18 Jan 2026 14:49:06 +0200 Subject: [PATCH 524/581] client: add support for sending MSC4354 sticky events --- client.go | 6 ++++++ requests.go | 13 ++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/client.go b/client.go index 87b6d87e..3aa1627d 100644 --- a/client.go +++ b/client.go @@ -1324,6 +1324,9 @@ func (cli *Client) SendMessageEvent(ctx context.Context, roomID id.RoomID, event if req.UnstableDelay > 0 { queryParams["org.matrix.msc4140.delay"] = strconv.FormatInt(req.UnstableDelay.Milliseconds(), 10) } + if req.UnstableStickyDuration > 0 { + queryParams["org.matrix.msc4354.sticky_duration_ms"] = strconv.FormatInt(req.UnstableStickyDuration.Milliseconds(), 10) + } if !req.DontEncrypt && cli != nil && cli.Crypto != nil && eventType != event.EventReaction && eventType != event.EventEncrypted { var isEncrypted bool @@ -1365,6 +1368,9 @@ func (cli *Client) SendStateEvent(ctx context.Context, roomID id.RoomID, eventTy if req.UnstableDelay > 0 { queryParams["org.matrix.msc4140.delay"] = strconv.FormatInt(req.UnstableDelay.Milliseconds(), 10) } + if req.UnstableStickyDuration > 0 { + queryParams["org.matrix.msc4354.sticky_duration_ms"] = strconv.FormatInt(req.UnstableStickyDuration.Milliseconds(), 10) + } if req.Timestamp > 0 { queryParams["ts"] = strconv.FormatInt(req.Timestamp, 10) } diff --git a/requests.go b/requests.go index f0287b3c..397d30de 100644 --- a/requests.go +++ b/requests.go @@ -367,13 +367,12 @@ type ReqSendToDevice struct { } type ReqSendEvent struct { - Timestamp int64 - TransactionID string - UnstableDelay time.Duration - - DontEncrypt bool - - MeowEventID id.EventID + Timestamp int64 + TransactionID string + UnstableDelay time.Duration + UnstableStickyDuration time.Duration + DontEncrypt bool + MeowEventID id.EventID } type ReqDelayedEvents struct { From 28bcc356db0962eb53f296f17c03f08a0fa0ac0b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 18 Jan 2026 22:41:34 +0200 Subject: [PATCH 525/581] client: add MemberCount helper method for lazy load summary --- responses.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/responses.go b/responses.go index d822c84b..20286431 100644 --- a/responses.go +++ b/responses.go @@ -341,6 +341,13 @@ type LazyLoadSummary struct { InvitedMemberCount *int `json:"m.invited_member_count,omitempty"` } +func (lls *LazyLoadSummary) MemberCount() int { + if lls == nil { + return 0 + } + return ptr.Val(lls.JoinedMemberCount) + ptr.Val(lls.InvitedMemberCount) +} + func (lls *LazyLoadSummary) Equal(other *LazyLoadSummary) bool { if lls == other { return true From e28f7170bc4bc9aab3cb8e04d1a94f677dc5f27b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 19 Jan 2026 14:58:18 +0200 Subject: [PATCH 526/581] bridgev2/portal: auto-accept message requests on message (#451) --- bridgev2/portal.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ event/beeper.go | 2 ++ 2 files changed, 46 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index e9feb448..6d90a9ed 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1223,6 +1223,12 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin } } + err = portal.autoAcceptMessageRequest(ctx, evt, sender, origSender, caps) + if err != nil { + log.Warn().Err(err).Msg("Failed to auto-accept message request on message") + // TODO stop processing? + } + var resp *MatrixMessageResponse if msgContent != nil { resp, err = sender.Client.HandleMatrixMessage(ctx, wrappedMsgEvt) @@ -1502,6 +1508,12 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi log.Warn().Msg("Reaction target message not found in database") return EventHandlingResultFailed.WithMSSError(fmt.Errorf("reaction %w", ErrTargetMessageNotFound)) } + caps := sender.Client.GetCapabilities(ctx, portal) + err = portal.autoAcceptMessageRequest(ctx, evt, sender, nil, caps) + if err != nil { + log.Warn().Err(err).Msg("Failed to auto-accept message request on reaction") + // TODO stop processing? + } log.UpdateContext(func(c zerolog.Context) zerolog.Context { return c.Str("reaction_target_remote_id", string(reactionTarget.ID)) }) @@ -1801,6 +1813,38 @@ func (portal *Portal) handleMatrixAcceptMessageRequest( return EventHandlingResultSuccess.WithMSS() } +func (portal *Portal) autoAcceptMessageRequest( + ctx context.Context, evt *event.Event, sender *UserLogin, origSender *OrigSender, caps *event.RoomFeatures, +) error { + if !portal.MessageRequest || caps.MessageRequest == nil || caps.MessageRequest.AcceptWithMessage == event.CapLevelFullySupported { + return nil + } + mran, ok := sender.Client.(MessageRequestAcceptingNetworkAPI) + if !ok { + return nil + } + err := mran.HandleMatrixAcceptMessageRequest(ctx, &MatrixAcceptMessageRequest{ + Event: evt, + Content: &event.BeeperAcceptMessageRequestEventContent{ + IsImplicit: true, + }, + Portal: portal, + OrigSender: origSender, + }) + if err != nil { + return err + } + if portal.MessageRequest { + portal.MessageRequest = false + portal.UpdateBridgeInfo(ctx) + err = portal.Save(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after accepting message request") + } + } + return nil +} + func (portal *Portal) handleMatrixDeleteChat( ctx context.Context, sender *UserLogin, diff --git a/event/beeper.go b/event/beeper.go index b46106ab..49aa964f 100644 --- a/event/beeper.go +++ b/event/beeper.go @@ -94,6 +94,8 @@ type BeeperChatDeleteEventContent struct { } type BeeperAcceptMessageRequestEventContent struct { + // Whether this was triggered by a message rather than an explicit event + IsImplicit bool `json:"-"` } type BeeperSendStateEventContent struct { From f32af79d208dded3330e60492f2bedeeafe21f61 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 19 Jan 2026 14:26:22 +0000 Subject: [PATCH 527/581] bridgev2/ghost: consider avatar being set in `Ghost.UpdateInfoIfNecessary` (#453) Co-authored-by: Tulir Asokan --- bridgev2/ghost.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bridgev2/ghost.go b/bridgev2/ghost.go index 6cef6f06..f7072a9c 100644 --- a/bridgev2/ghost.go +++ b/bridgev2/ghost.go @@ -234,7 +234,7 @@ func (br *Bridge) allowAggressiveUpdateForType(evtType RemoteEventType) bool { } func (ghost *Ghost) UpdateInfoIfNecessary(ctx context.Context, source *UserLogin, evtType RemoteEventType) { - if ghost.Name != "" && ghost.NameSet && !ghost.Bridge.allowAggressiveUpdateForType(evtType) { + if ghost.Name != "" && ghost.NameSet && ghost.AvatarSet && !ghost.Bridge.allowAggressiveUpdateForType(evtType) { return } info, err := source.Client.GetUserInfo(ctx, ghost) @@ -244,12 +244,16 @@ func (ghost *Ghost) UpdateInfoIfNecessary(ctx context.Context, source *UserLogin zerolog.Ctx(ctx).Debug(). Bool("has_name", ghost.Name != ""). Bool("name_set", ghost.NameSet). + Bool("has_avatar", ghost.AvatarMXC != ""). + Bool("avatar_set", ghost.AvatarSet). Msg("Updating ghost info in IfNecessary call") ghost.UpdateInfo(ctx, info) } else { zerolog.Ctx(ctx).Trace(). Bool("has_name", ghost.Name != ""). Bool("name_set", ghost.NameSet). + Bool("has_avatar", ghost.AvatarMXC != ""). + Bool("avatar_set", ghost.AvatarSet). Msg("No ghost info received in IfNecessary call") } } @@ -277,6 +281,11 @@ func (ghost *Ghost) UpdateInfo(ctx context.Context, info *UserInfo) { } if info.Avatar != nil { update = ghost.UpdateAvatar(ctx, info.Avatar) || update + } else if oldAvatar == "" && !ghost.AvatarSet { + // Special case: nil avatar means we're not expecting one ever, if we don't currently have + // one we flag it as set to avoid constantly refetching in UpdateInfoIfNecessary. + ghost.AvatarSet = true + update = true } if info.Identifiers != nil || info.IsBot != nil { update = ghost.UpdateContactInfo(ctx, info.Identifiers, info.IsBot) || update From a55693bbd7c616a8e9fa04fdd20cb36154997094 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 20 Jan 2026 12:06:55 +0200 Subject: [PATCH 528/581] client,bridgev2/matrix: fix context used for async uploads --- bridgev2/matrix/intent.go | 2 ++ client.go | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index 3d2692f9..173f7c15 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -403,6 +403,7 @@ func (as *ASIntent) UploadMediaStream( removeAndClose(replFile) removeAndClose(tempFile) } + req.AsyncContext = zerolog.Ctx(ctx).WithContext(as.Connector.Bridge.BackgroundCtx) startedAsyncUpload = true var resp *mautrix.RespCreateMXC resp, err = as.Matrix.UploadAsync(ctx, req) @@ -435,6 +436,7 @@ func (as *ASIntent) doUploadReq(ctx context.Context, file *event.EncryptedFileIn as.Connector.uploadSema.Release(int64(len(req.ContentBytes))) } } + req.AsyncContext = zerolog.Ctx(ctx).WithContext(as.Connector.Bridge.BackgroundCtx) var resp *mautrix.RespCreateMXC resp, err = as.Matrix.UploadAsync(ctx, req) if resp != nil { diff --git a/client.go b/client.go index 3aa1627d..2503556a 100644 --- a/client.go +++ b/client.go @@ -1933,10 +1933,15 @@ func (cli *Client) UploadAsync(ctx context.Context, req ReqUploadMedia) (*RespCr } req.MXC = resp.ContentURI req.UnstableUploadURL = resp.UnstableUploadURL + if req.AsyncContext == nil { + req.AsyncContext = cli.cliOrContextLog(ctx).WithContext(context.Background()) + } go func() { - _, err = cli.UploadMedia(ctx, req) + _, err = cli.UploadMedia(req.AsyncContext, req) if err != nil { - cli.Log.Error().Stringer("mxc", req.MXC).Err(err).Msg("Async upload of media failed") + zerolog.Ctx(req.AsyncContext).Err(err). + Stringer("mxc", req.MXC). + Msg("Async upload of media failed") } }() return resp, nil @@ -1972,6 +1977,7 @@ type ReqUploadMedia struct { ContentType string FileName string + AsyncContext context.Context DoneCallback func() // MXC specifies an existing MXC URI which doesn't have content yet to upload into. From a1236b65bea37ab97c550aff2c39411125833932 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 20 Jan 2026 14:28:21 +0200 Subject: [PATCH 529/581] crypto/keyimport: call session received callback for all sessions in import --- crypto/keyimport.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crypto/keyimport.go b/crypto/keyimport.go index 36ad6b9c..aef3eca2 100644 --- a/crypto/keyimport.go +++ b/crypto/keyimport.go @@ -120,7 +120,9 @@ func (mach *OlmMachine) importExportedRoomKey(ctx context.Context, session Expor existingIGS, _ := mach.CryptoStore.GetGroupSession(ctx, igs.RoomID, igs.ID()) firstKnownIndex := igs.Internal.FirstKnownIndex() if existingIGS != nil && existingIGS.Internal.FirstKnownIndex() <= firstKnownIndex { - // We already have an equivalent or better session in the store, so don't override it. + // We already have an equivalent or better session in the store, so don't override it, + // but do notify the session received callback just in case. + mach.MarkSessionReceived(ctx, session.RoomID, igs.ID(), existingIGS.Internal.FirstKnownIndex()) return false, nil } err = mach.CryptoStore.PutGroupSession(ctx, igs) From d057f1c6732e1a0da45f7888984a02087709841c Mon Sep 17 00:00:00 2001 From: SpiritCroc Date: Fri, 23 Jan 2026 15:38:17 +0100 Subject: [PATCH 530/581] event: add action message content for rich call notifications (#454) --- event/beeper.go | 18 ++++++++++++++++++ event/message.go | 1 + 2 files changed, 19 insertions(+) diff --git a/event/beeper.go b/event/beeper.go index 49aa964f..2c7d9bf2 100644 --- a/event/beeper.go +++ b/event/beeper.go @@ -166,6 +166,24 @@ type BeeperPerMessageProfile struct { HasFallback bool `json:"has_fallback,omitempty"` } +type BeeperActionMessageType string + +const ( + BeeperActionMessageCall BeeperActionMessageType = "call" +) + +type BeeperActionMessageCallType string + +const ( + BeeperActionMessageCallTypeVoice BeeperActionMessageCallType = "voice" + BeeperActionMessageCallTypeVideo BeeperActionMessageCallType = "video" +) + +type BeeperActionMessage struct { + Type BeeperActionMessageType `json:"type"` + CallType BeeperActionMessageCallType `json:"call_type,omitempty"` +} + func (content *MessageEventContent) AddPerMessageProfileFallback() { if content.BeeperPerMessageProfile == nil || content.BeeperPerMessageProfile.HasFallback || content.BeeperPerMessageProfile.Displayname == "" { return diff --git a/event/message.go b/event/message.go index 5e80d2ef..3fb3dc82 100644 --- a/event/message.go +++ b/event/message.go @@ -135,6 +135,7 @@ type MessageEventContent struct { BeeperGalleryCaption string `json:"com.beeper.gallery.caption,omitempty"` BeeperGalleryCaptionHTML string `json:"com.beeper.gallery.caption_html,omitempty"` BeeperPerMessageProfile *BeeperPerMessageProfile `json:"com.beeper.per_message_profile,omitempty"` + BeeperActionMessage *BeeperActionMessage `json:"com.beeper.action_message,omitempty"` BeeperLinkPreviews []*BeeperLinkPreview `json:"com.beeper.linkpreviews,omitempty"` From 8b04430d84edbc3efb16b89a6c9e2c74ac5f0d7b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 23 Jan 2026 19:37:35 +0200 Subject: [PATCH 531/581] event: switch url preview image blurhash to use MSC2448 field --- event/beeper.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/event/beeper.go b/event/beeper.go index 2c7d9bf2..6de41df6 100644 --- a/event/beeper.go +++ b/event/beeper.go @@ -146,7 +146,7 @@ type BeeperLinkPreview struct { MatchedURL string `json:"matched_url,omitempty"` ImageEncryption *EncryptedFileInfo `json:"beeper:image:encryption,omitempty"` - ImageBlurhash string `json:"beeper:image:blurhash,omitempty"` + ImageBlurhash string `json:"matrix:image:blurhash,omitempty"` } type BeeperProfileExtra struct { From b041eb924ea508fece4f09546d83e336a8d3edf4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 26 Jan 2026 01:20:30 +0200 Subject: [PATCH 532/581] error: allow storing extra headers in RespError --- error.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/error.go b/error.go index 5ff671e0..a284f7d1 100644 --- a/error.go +++ b/error.go @@ -140,7 +140,8 @@ type RespError struct { Err string ExtraData map[string]any - StatusCode int + StatusCode int + ExtraHeader map[string]string } func (e *RespError) UnmarshalJSON(data []byte) error { @@ -168,6 +169,9 @@ func (e RespError) Write(w http.ResponseWriter) { if statusCode == 0 { statusCode = http.StatusInternalServerError } + for key, value := range e.ExtraHeader { + w.Header().Set(key, value) + } exhttp.WriteJSONResponse(w, statusCode, &e) } @@ -190,6 +194,18 @@ func (e RespError) WithExtraData(extraData map[string]any) RespError { return e } +func (e RespError) WithExtraHeader(key, value string) RespError { + e.ExtraHeader = exmaps.NonNilClone(e.ExtraHeader) + e.ExtraHeader[key] = value + return e +} + +func (e RespError) WithExtraHeaders(headers map[string]string) RespError { + e.ExtraHeader = exmaps.NonNilClone(e.ExtraHeader) + maps.Copy(e.ExtraHeader, headers) + return e +} + // Error returns the errcode and error message. func (e RespError) Error() string { return e.ErrCode + ": " + e.Err From 074a2d8d4d5d9dede2ff847aec57939f913d0041 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 26 Jan 2026 01:38:03 +0200 Subject: [PATCH 533/581] crypto/keysharing: fix including sender key in forwards --- crypto/decryptmegolm.go | 4 ---- crypto/keysharing.go | 3 ++- event/encryption.go | 3 ++- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/crypto/decryptmegolm.go b/crypto/decryptmegolm.go index 59ff67a8..77a64b1e 100644 --- a/crypto/decryptmegolm.go +++ b/crypto/decryptmegolm.go @@ -29,7 +29,6 @@ var ( ErrDuplicateMessageIndex = errors.New("duplicate megolm message index") ErrWrongRoom = errors.New("encrypted megolm event is not intended for this room") ErrDeviceKeyMismatch = errors.New("device keys in event and verified device info do not match") - ErrSenderKeyMismatch = errors.New("sender keys in content and megolm session do not match") ErrRatchetError = errors.New("failed to ratchet session after use") ErrCorruptedMegolmPayload = errors.New("corrupted megolm payload") ) @@ -41,7 +40,6 @@ var ( DuplicateMessageIndex = ErrDuplicateMessageIndex WrongRoom = ErrWrongRoom DeviceKeyMismatch = ErrDeviceKeyMismatch - SenderKeyMismatch = ErrSenderKeyMismatch RatchetError = ErrRatchetError ) @@ -254,8 +252,6 @@ func (mach *OlmMachine) actuallyDecryptMegolmEvent(ctx context.Context, evt *eve return nil, nil, 0, fmt.Errorf("failed to get group session: %w", err) } else if sess == nil { return nil, nil, 0, fmt.Errorf("%w (ID %s)", ErrNoSessionFound, content.SessionID) - } else if content.SenderKey != "" && content.SenderKey != sess.SenderKey { - return sess, nil, 0, ErrSenderKeyMismatch } plaintext, messageIndex, err := sess.Internal.Decrypt(content.MegolmCiphertext) if err != nil { diff --git a/crypto/keysharing.go b/crypto/keysharing.go index f1d427af..cde594c2 100644 --- a/crypto/keysharing.go +++ b/crypto/keysharing.go @@ -214,6 +214,7 @@ func (mach *OlmMachine) rejectKeyRequest(ctx context.Context, rejection KeyShare RoomID: request.RoomID, Algorithm: request.Algorithm, SessionID: request.SessionID, + //lint:ignore SA1019 This is just echoing back the deprecated field SenderKey: request.SenderKey, Code: rejection.Code, Reason: rejection.Reason, @@ -356,7 +357,7 @@ func (mach *OlmMachine) HandleRoomKeyRequest(ctx context.Context, sender id.User SessionID: igs.ID(), SessionKey: string(exportedKey), }, - SenderKey: content.Body.SenderKey, + SenderKey: igs.SenderKey, ForwardingKeyChain: igs.ForwardingChains, SenderClaimedKey: igs.SigningKey, }, diff --git a/event/encryption.go b/event/encryption.go index 8e386b60..c60cb91a 100644 --- a/event/encryption.go +++ b/event/encryption.go @@ -132,8 +132,9 @@ type RoomKeyRequestEventContent struct { type RequestedKeyInfo struct { Algorithm id.Algorithm `json:"algorithm"` RoomID id.RoomID `json:"room_id"` - SenderKey id.SenderKey `json:"sender_key"` SessionID id.SessionID `json:"session_id"` + // Deprecated: Matrix v1.3 + SenderKey id.SenderKey `json:"sender_key"` } type RoomKeyWithheldCode string From 9d30203f6b9c6a14d751dbba73482465b5b49020 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 26 Jan 2026 13:42:33 +0200 Subject: [PATCH 534/581] bridgev2/userlogin: add todo --- bridgev2/userlogin.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bridgev2/userlogin.go b/bridgev2/userlogin.go index c9102248..35443025 100644 --- a/bridgev2/userlogin.go +++ b/bridgev2/userlogin.go @@ -51,6 +51,8 @@ func (br *Bridge) loadUserLogin(ctx context.Context, user *User, dbUserLogin *da if err != nil { return nil, fmt.Errorf("failed to get user: %w", err) } + // TODO if loading the user caused the provided userlogin to be loaded, cancel here? + // Currently this will double-load it } userLogin := &UserLogin{ UserLogin: dbUserLogin, From c4ce008c8eee58e7a9dc1978403e75292f1f0927 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 Jan 2026 12:51:46 +0200 Subject: [PATCH 535/581] crypto/ssss: skip verifying recovery key if MAC or IV are missing --- crypto/cross_sign_ssss.go | 7 ++++++- crypto/ssss/meta.go | 14 +++++++++++--- crypto/ssss/types.go | 3 ++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crypto/cross_sign_ssss.go b/crypto/cross_sign_ssss.go index 50b58ea0..fd42880d 100644 --- a/crypto/cross_sign_ssss.go +++ b/crypto/cross_sign_ssss.go @@ -8,6 +8,7 @@ package crypto import ( "context" + "errors" "fmt" "maunium.net/go/mautrix" @@ -77,7 +78,11 @@ func (mach *OlmMachine) VerifyWithRecoveryKey(ctx context.Context, recoveryKey s return fmt.Errorf("failed to get default SSSS key data: %w", err) } key, err := keyData.VerifyRecoveryKey(keyID, recoveryKey) - if err != nil { + if errors.Is(err, ssss.ErrUnverifiableKey) { + mach.machOrContextLog(ctx).Warn(). + Str("key_id", keyID). + Msg("SSSS key is unverifiable, trying to use without verifying") + } else if err != nil { return err } err = mach.FetchCrossSigningKeysFromSSSS(ctx, key) diff --git a/crypto/ssss/meta.go b/crypto/ssss/meta.go index 474c85d8..f2ae68eb 100644 --- a/crypto/ssss/meta.go +++ b/crypto/ssss/meta.go @@ -8,6 +8,7 @@ package ssss import ( "encoding/base64" + "errors" "fmt" "strings" @@ -33,7 +34,9 @@ func (kd *KeyMetadata) VerifyPassphrase(keyID, passphrase string) (*Key, error) ssssKey, err := kd.Passphrase.GetKey(passphrase) if err != nil { return nil, err - } else if err = kd.verifyKey(ssssKey); err != nil { + } + err = kd.verifyKey(ssssKey) + if err != nil && !errors.Is(err, ErrUnverifiableKey) { return nil, err } @@ -49,7 +52,9 @@ func (kd *KeyMetadata) VerifyRecoveryKey(keyID, recoveryKey string) (*Key, error ssssKey := utils.DecodeBase58RecoveryKey(recoveryKey) if ssssKey == nil { return nil, ErrInvalidRecoveryKey - } else if err := kd.verifyKey(ssssKey); err != nil { + } + err := kd.verifyKey(ssssKey) + if err != nil && !errors.Is(err, ErrUnverifiableKey) { return nil, err } @@ -57,10 +62,13 @@ func (kd *KeyMetadata) VerifyRecoveryKey(keyID, recoveryKey string) (*Key, error ID: keyID, Key: ssssKey, Metadata: kd, - }, nil + }, err } func (kd *KeyMetadata) verifyKey(key []byte) error { + if kd.MAC == "" || kd.IV == "" { + return ErrUnverifiableKey + } unpaddedMAC := strings.TrimRight(kd.MAC, "=") expectedMACLength := base64.RawStdEncoding.EncodedLen(utils.SHAHashLength) if len(unpaddedMAC) != expectedMACLength { diff --git a/crypto/ssss/types.go b/crypto/ssss/types.go index c08f107c..b7465d3e 100644 --- a/crypto/ssss/types.go +++ b/crypto/ssss/types.go @@ -26,7 +26,8 @@ var ( ErrUnsupportedPassphraseAlgorithm = errors.New("unsupported passphrase KDF algorithm") ErrIncorrectSSSSKey = errors.New("incorrect SSSS key") ErrInvalidRecoveryKey = errors.New("invalid recovery key") - ErrCorruptedKeyMetadata = errors.New("corrupted key metadata") + ErrCorruptedKeyMetadata = errors.New("corrupted recovery key metadata") + ErrUnverifiableKey = errors.New("cannot verify recovery key: missing MAC or IV in metadata") ) // Algorithm is the identifier for an SSSS encryption algorithm. From 2c0d51ee7d92a62334268c57035bc4153f3b4597 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 Jan 2026 14:39:52 +0200 Subject: [PATCH 536/581] crypto/ssss: handle slightly broken key metadata better --- crypto/ssss/key.go | 4 ++-- crypto/ssss/meta.go | 34 +++++++++++++++++++----------- crypto/ssss/meta_test.go | 45 +++++++++++++++++++++++++++++++++------- 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/crypto/ssss/key.go b/crypto/ssss/key.go index cd8e3fce..78ebd8f3 100644 --- a/crypto/ssss/key.go +++ b/crypto/ssss/key.go @@ -59,12 +59,12 @@ func NewKey(passphrase string) (*Key, error) { // We store a certain hash in the key metadata so that clients can check if the user entered the correct key. ivBytes := random.Bytes(utils.AESCTRIVLength) keyData.IV = base64.RawStdEncoding.EncodeToString(ivBytes) - var err error - keyData.MAC, err = keyData.calculateHash(ssssKey) + macBytes, err := keyData.calculateHash(ssssKey) if err != nil { // This should never happen because we just generated the IV and key. return nil, fmt.Errorf("failed to calculate hash: %w", err) } + keyData.MAC = base64.RawStdEncoding.EncodeToString(macBytes) return &Key{ Key: ssssKey, diff --git a/crypto/ssss/meta.go b/crypto/ssss/meta.go index f2ae68eb..34775fa7 100644 --- a/crypto/ssss/meta.go +++ b/crypto/ssss/meta.go @@ -7,6 +7,8 @@ package ssss import ( + "crypto/hmac" + "crypto/sha256" "encoding/base64" "errors" "fmt" @@ -74,11 +76,16 @@ func (kd *KeyMetadata) verifyKey(key []byte) error { if len(unpaddedMAC) != expectedMACLength { return fmt.Errorf("%w: invalid mac length %d (expected %d)", ErrCorruptedKeyMetadata, len(unpaddedMAC), expectedMACLength) } - hash, err := kd.calculateHash(key) + expectedMAC, err := base64.RawStdEncoding.DecodeString(unpaddedMAC) + if err != nil { + return fmt.Errorf("%w: failed to decode mac: %w", ErrCorruptedKeyMetadata, err) + } + calculatedMAC, err := kd.calculateHash(key) if err != nil { return err } - if unpaddedMAC != hash { + // This doesn't really need to be constant time since it's fully local, but might as well be. + if !hmac.Equal(expectedMAC, calculatedMAC) { return ErrIncorrectSSSSKey } return nil @@ -91,23 +98,26 @@ func (kd *KeyMetadata) VerifyKey(key []byte) bool { // calculateHash calculates the hash used for checking if the key is entered correctly as described // in the spec: https://matrix.org/docs/spec/client_server/unstable#m-secret-storage-v1-aes-hmac-sha2 -func (kd *KeyMetadata) calculateHash(key []byte) (string, error) { +func (kd *KeyMetadata) calculateHash(key []byte) ([]byte, error) { aesKey, hmacKey := utils.DeriveKeysSHA256(key, "") unpaddedIV := strings.TrimRight(kd.IV, "=") expectedIVLength := base64.RawStdEncoding.EncodedLen(utils.AESCTRIVLength) - if len(unpaddedIV) != expectedIVLength { - return "", fmt.Errorf("%w: invalid iv length %d (expected %d)", ErrCorruptedKeyMetadata, len(unpaddedIV), expectedIVLength) + if len(unpaddedIV) < expectedIVLength || len(unpaddedIV) > expectedIVLength*3 { + return nil, fmt.Errorf("%w: invalid iv length %d (expected %d)", ErrCorruptedKeyMetadata, len(unpaddedIV), expectedIVLength) } - - var ivBytes [utils.AESCTRIVLength]byte - _, err := base64.RawStdEncoding.Decode(ivBytes[:], []byte(unpaddedIV)) + rawIVBytes, err := base64.RawStdEncoding.DecodeString(unpaddedIV) if err != nil { - return "", fmt.Errorf("%w: failed to decode iv: %w", ErrCorruptedKeyMetadata, err) + return nil, fmt.Errorf("%w: failed to decode iv: %w", ErrCorruptedKeyMetadata, err) } + // TODO log a warning for non-16 byte IVs? + // Certain broken clients like nheko generated 32-byte IVs where only the first 16 bytes were used. + ivBytes := *(*[utils.AESCTRIVLength]byte)(rawIVBytes[:utils.AESCTRIVLength]) - cipher := utils.XorA256CTR(make([]byte, utils.AESCTRKeyLength), aesKey, ivBytes) - - return utils.HMACSHA256B64(cipher, hmacKey), nil + zeroes := make([]byte, utils.AESCTRKeyLength) + encryptedZeroes := utils.XorA256CTR(zeroes, aesKey, ivBytes) + h := hmac.New(sha256.New, hmacKey[:]) + h.Write(encryptedZeroes) + return h.Sum(nil), nil } // PassphraseMetadata represents server-side metadata about a SSSS key passphrase. diff --git a/crypto/ssss/meta_test.go b/crypto/ssss/meta_test.go index 7a5ef8b9..d59809c7 100644 --- a/crypto/ssss/meta_test.go +++ b/crypto/ssss/meta_test.go @@ -8,7 +8,6 @@ package ssss_test import ( "encoding/json" - "errors" "testing" "github.com/stretchr/testify/assert" @@ -42,10 +41,24 @@ const key2Meta = ` } ` +const key2MetaUnverified = ` +{ + "algorithm": "m.secret_storage.v1.aes-hmac-sha2" +} +` + +const key2MetaLongIV = ` +{ + "algorithm": "m.secret_storage.v1.aes-hmac-sha2", + "iv": "O0BOvTqiIAYjC+RMcyHfW2f/gdxjceTxoYtNlpPduJ8=", + "mac": "7k6OruQlWg0UmQjxGZ0ad4Q6DdwkgnoI7G6X3IjBYtI=" +} +` + const key2MetaBrokenIV = ` { "algorithm": "m.secret_storage.v1.aes-hmac-sha2", - "iv": "O0BOvTqiIAYjC+RMcyHfWwMeowMeowMeow", + "iv": "MeowMeowMeow", "mac": "7k6OruQlWg0UmQjxGZ0ad4Q6DdwkgnoI7G6X3IjBYtI=" } ` @@ -94,17 +107,33 @@ func TestKeyMetadata_VerifyRecoveryKey_Correct2(t *testing.T) { assert.Equal(t, key2RecoveryKey, key.RecoveryKey()) } +func TestKeyMetadata_VerifyRecoveryKey_NonCompliant_LongIV(t *testing.T) { + km := getKeyMeta(key2MetaLongIV) + key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) + assert.NoError(t, err) + assert.NotNil(t, key) + assert.Equal(t, key2RecoveryKey, key.RecoveryKey()) +} + +func TestKeyMetadata_VerifyRecoveryKey_Unverified(t *testing.T) { + km := getKeyMeta(key2MetaUnverified) + key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) + assert.ErrorIs(t, err, ssss.ErrUnverifiableKey) + assert.NotNil(t, key) + assert.Equal(t, key2RecoveryKey, key.RecoveryKey()) +} + func TestKeyMetadata_VerifyRecoveryKey_Invalid(t *testing.T) { km := getKeyMeta(key1Meta) key, err := km.VerifyRecoveryKey(key1ID, "foo") - assert.True(t, errors.Is(err, ssss.ErrInvalidRecoveryKey), "unexpected error: %v", err) + assert.ErrorIs(t, err, ssss.ErrInvalidRecoveryKey) assert.Nil(t, key) } func TestKeyMetadata_VerifyRecoveryKey_Incorrect(t *testing.T) { km := getKeyMeta(key1Meta) key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) - assert.True(t, errors.Is(err, ssss.ErrIncorrectSSSSKey), "unexpected error: %v", err) + assert.ErrorIs(t, err, ssss.ErrIncorrectSSSSKey) assert.Nil(t, key) } @@ -119,27 +148,27 @@ func TestKeyMetadata_VerifyPassphrase_Correct(t *testing.T) { func TestKeyMetadata_VerifyPassphrase_Incorrect(t *testing.T) { km := getKeyMeta(key1Meta) key, err := km.VerifyPassphrase(key1ID, "incorrect horse battery staple") - assert.True(t, errors.Is(err, ssss.ErrIncorrectSSSSKey), "unexpected error %v", err) + assert.ErrorIs(t, err, ssss.ErrIncorrectSSSSKey) assert.Nil(t, key) } func TestKeyMetadata_VerifyPassphrase_NotSet(t *testing.T) { km := getKeyMeta(key2Meta) key, err := km.VerifyPassphrase(key2ID, "hmm") - assert.True(t, errors.Is(err, ssss.ErrNoPassphrase), "unexpected error %v", err) + assert.ErrorIs(t, err, ssss.ErrNoPassphrase) assert.Nil(t, key) } func TestKeyMetadata_VerifyRecoveryKey_CorruptedIV(t *testing.T) { km := getKeyMeta(key2MetaBrokenIV) key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) - assert.True(t, errors.Is(err, ssss.ErrCorruptedKeyMetadata), "unexpected error %v", err) + assert.ErrorIs(t, err, ssss.ErrCorruptedKeyMetadata) assert.Nil(t, key) } func TestKeyMetadata_VerifyRecoveryKey_CorruptedMAC(t *testing.T) { km := getKeyMeta(key2MetaBrokenMAC) key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) - assert.True(t, errors.Is(err, ssss.ErrCorruptedKeyMetadata), "unexpected error %v", err) + assert.ErrorIs(t, err, ssss.ErrCorruptedKeyMetadata) assert.Nil(t, key) } From b613f4d67647c6d03c09de03727f49b3a8d9a7f8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 Jan 2026 21:32:48 +0200 Subject: [PATCH 537/581] crypto/sessions: add missing field in export --- crypto/sessions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto/sessions.go b/crypto/sessions.go index 6b90c998..d7e68eb1 100644 --- a/crypto/sessions.go +++ b/crypto/sessions.go @@ -169,7 +169,7 @@ func (igs *InboundGroupSession) export() (*ExportedSession, error) { ForwardingChains: igs.ForwardingChains, RoomID: igs.RoomID, SenderKey: igs.SenderKey, - SenderClaimedKeys: SenderClaimedKeys{}, + SenderClaimedKeys: SenderClaimedKeys{Ed25519: igs.SigningKey}, SessionID: igs.ID(), SessionKey: string(key), }, nil From 2423716f83946e840ec3d28271d884470296cb27 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 Jan 2026 21:34:07 +0200 Subject: [PATCH 538/581] crypto/keysharing: don't send withheld response to some key requests --- crypto/keysharing.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crypto/keysharing.go b/crypto/keysharing.go index cde594c2..c1f7171c 100644 --- a/crypto/keysharing.go +++ b/crypto/keysharing.go @@ -264,9 +264,14 @@ func (mach *OlmMachine) defaultAllowKeyShare(ctx context.Context, device *id.Dev log.Err(err).Msg("Rejecting key request due to internal error when checking session sharing") return &KeyShareRejectNoResponse } else if !isShared { - // TODO differentiate session not shared with requester vs session not created by this device? - log.Debug().Msg("Rejecting key request for unshared session") - return &KeyShareRejectNotRecipient + igs, _ := mach.CryptoStore.GetGroupSession(ctx, evt.RoomID, evt.SessionID) + if igs != nil && igs.SenderKey == mach.OwnIdentity().IdentityKey { + log.Debug().Msg("Rejecting key request for unshared session") + return &KeyShareRejectNotRecipient + } + // Note: this case will also happen for redacted sessions and database errors + log.Debug().Msg("Rejecting key request for session created by another device") + return &KeyShareRejectNoResponse } log.Debug().Msg("Accepting key request for shared session") return nil @@ -324,7 +329,9 @@ func (mach *OlmMachine) HandleRoomKeyRequest(ctx context.Context, sender id.User if err != nil { if errors.Is(err, ErrGroupSessionWithheld) { log.Debug().Err(err).Msg("Requested group session not available") - mach.rejectKeyRequest(ctx, KeyShareRejectUnavailable, device, content.Body) + if sender != mach.Client.UserID { + mach.rejectKeyRequest(ctx, KeyShareRejectUnavailable, device, content.Body) + } } else { log.Error().Err(err).Msg("Failed to get group session to forward") mach.rejectKeyRequest(ctx, KeyShareRejectInternalError, device, content.Body) @@ -332,7 +339,9 @@ func (mach *OlmMachine) HandleRoomKeyRequest(ctx context.Context, sender id.User return } else if igs == nil { log.Error().Msg("Didn't find group session to forward") - mach.rejectKeyRequest(ctx, KeyShareRejectUnavailable, device, content.Body) + if sender != mach.Client.UserID { + mach.rejectKeyRequest(ctx, KeyShareRejectUnavailable, device, content.Body) + } return } if internalID := igs.ID(); internalID != content.Body.SessionID { From 60742c4b61a4839f2ae78d443edb2f22de78ca4e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 Jan 2026 21:37:23 +0200 Subject: [PATCH 539/581] crypto: update test --- crypto/keyexport_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto/keyexport_test.go b/crypto/keyexport_test.go index 47616a20..fd6f105d 100644 --- a/crypto/keyexport_test.go +++ b/crypto/keyexport_test.go @@ -31,5 +31,5 @@ func TestExportKeys(t *testing.T) { )) data, err := crypto.ExportKeys("meow", []*crypto.InboundGroupSession{sess}) assert.NoError(t, err) - assert.Len(t, data, 836) + assert.Len(t, data, 893) } From 4b387c305b43a59703b0483454f52b3271069539 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Thu, 29 Jan 2026 15:01:48 +0000 Subject: [PATCH 540/581] error: add `RespError.CanRetry` field (#456) --- error.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/error.go b/error.go index a284f7d1..4711b3dc 100644 --- a/error.go +++ b/error.go @@ -142,6 +142,8 @@ type RespError struct { StatusCode int ExtraHeader map[string]string + + CanRetry bool } func (e *RespError) UnmarshalJSON(data []byte) error { @@ -151,6 +153,7 @@ func (e *RespError) UnmarshalJSON(data []byte) error { } e.ErrCode, _ = e.ExtraData["errcode"].(string) e.Err, _ = e.ExtraData["error"].(string) + e.CanRetry, _ = e.ExtraData["com.beeper.can_retry"].(bool) return nil } @@ -158,6 +161,9 @@ func (e *RespError) MarshalJSON() ([]byte, error) { data := exmaps.NonNilClone(e.ExtraData) data["errcode"] = e.ErrCode data["error"] = e.Err + if e.CanRetry { + data["com.beeper.can_retry"] = e.CanRetry + } return json.Marshal(data) } @@ -188,6 +194,11 @@ func (e RespError) WithStatus(status int) RespError { return e } +func (e RespError) WithCanRetry(canRetry bool) RespError { + e.CanRetry = canRetry + return e +} + func (e RespError) WithExtraData(extraData map[string]any) RespError { e.ExtraData = exmaps.NonNilClone(e.ExtraData) maps.Copy(e.ExtraData, extraData) From d2364b3822751ea1863a886ad9f47ca60fea055c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 29 Jan 2026 19:47:10 +0200 Subject: [PATCH 541/581] bridgev2/portal: allow delivery receipts even if portal has no other user ID --- bridgev2/portal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 6d90a9ed..b72f00a6 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -3639,7 +3639,7 @@ func (portal *Portal) handleRemoteMarkUnread(ctx context.Context, source *UserLo } func (portal *Portal) handleRemoteDeliveryReceipt(ctx context.Context, source *UserLogin, evt RemoteDeliveryReceipt) EventHandlingResult { - if portal.RoomType != database.RoomTypeDM || evt.GetSender().Sender != portal.OtherUserID { + if portal.RoomType != database.RoomTypeDM || (evt.GetSender().Sender != portal.OtherUserID && portal.OtherUserID != "") { return EventHandlingResultIgnored } intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventDeliveryReceipt) From fe541df21769ea189e4a4c42a1046bf147c663b5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 11 Feb 2026 21:34:47 +0200 Subject: [PATCH 542/581] main: bump minimum Go version to 1.25 --- .github/workflows/go.yml | 11 +++++------ go.mod | 6 +++--- go.sum | 4 ++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index deaa1f1d..c0add220 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.25" + go-version: "1.26" cache: true - name: Install libolm @@ -35,8 +35,8 @@ jobs: strategy: fail-fast: false matrix: - go-version: ["1.24", "1.25"] - name: Build (${{ matrix.go-version == '1.25' && 'latest' || 'old' }}, libolm) + go-version: ["1.25", "1.26"] + name: Build (${{ matrix.go-version == '1.26' && 'latest' || 'old' }}, libolm) steps: - uses: actions/checkout@v6 @@ -62,7 +62,6 @@ jobs: run: go test -json -v ./... 2>&1 | gotestfmt - name: Test (jsonv2) - if: matrix.go-version == '1.25' env: GOEXPERIMENT: jsonv2 run: go test -json -v ./... 2>&1 | gotestfmt @@ -72,8 +71,8 @@ jobs: strategy: fail-fast: false matrix: - go-version: ["1.24", "1.25"] - name: Build (${{ matrix.go-version == '1.25' && 'latest' || 'old' }}, goolm) + go-version: ["1.25", "1.26"] + name: Build (${{ matrix.go-version == '1.26' && 'latest' || 'old' }}, goolm) steps: - uses: actions/checkout@v6 diff --git a/go.mod b/go.mod index 27acd21b..a76d1ec7 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module maunium.net/go/mautrix -go 1.24.0 +go 1.25.0 -toolchain go1.25.6 +toolchain go1.26.0 require ( filippo.io/edwards25519 v1.1.0 @@ -17,7 +17,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.16 - go.mau.fi/util v0.9.5 + go.mau.fi/util v0.9.6-0.20260211193350-78c2ff4a9df8 go.mau.fi/zeroconfig v0.2.0 golang.org/x/crypto v0.47.0 golang.org/x/exp v0.0.0-20260112195511-716be5621a96 diff --git a/go.sum b/go.sum index 9702337a..a142a727 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.5 h1:7AoWPCIZJGv4jvtFEuCe3GhAbI7uF9ckIooaXvwlIR4= -go.mau.fi/util v0.9.5/go.mod h1:g1uvZ03VQhtTt2BgaRGVytS/Zj67NV0YNIECch0sQCQ= +go.mau.fi/util v0.9.6-0.20260211193350-78c2ff4a9df8 h1:7McVSdP7wEpb1omjyKG5OjxCY2NPP5Ba1pJujkOZx7g= +go.mau.fi/util v0.9.6-0.20260211193350-78c2ff4a9df8/go.mod h1:DzglKWpYOxKq4h9noyJBMoUu72/XgbP8j/OPehS/l/U= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= From 7dbc4dd16aaf2063a81499fbf23b996a9fd85545 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 12 Feb 2026 17:34:40 +0200 Subject: [PATCH 543/581] appservice: fix building websocket url --- appservice/websocket.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appservice/websocket.go b/appservice/websocket.go index 4f2538bf..ef65e65a 100644 --- a/appservice/websocket.go +++ b/appservice/websocket.go @@ -14,7 +14,7 @@ import ( "io" "net/http" "net/url" - "path/filepath" + "path" "strings" "sync" "sync/atomic" @@ -374,7 +374,7 @@ func (as *AppService) StartWebsocket(ctx context.Context, baseURL string, onConn copiedURL := *as.hsURLForClient parsed = &copiedURL } - parsed.Path = filepath.Join(parsed.Path, "_matrix/client/unstable/fi.mau.as_sync") + parsed.Path = path.Join(parsed.Path, "_matrix/client/unstable/fi.mau.as_sync") if parsed.Scheme == "http" { parsed.Scheme = "ws" } else if parsed.Scheme == "https" { From b97f989032a25fca236508655193ab822019e252 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 14 Feb 2026 23:37:20 +0200 Subject: [PATCH 544/581] federation/eventauth: add support for underscores in string power levels --- federation/eventauth/eventauth.go | 11 +++- .../eventauth/eventauth_internal_test.go | 61 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 federation/eventauth/eventauth_internal_test.go diff --git a/federation/eventauth/eventauth.go b/federation/eventauth/eventauth.go index eac110a3..d2073607 100644 --- a/federation/eventauth/eventauth.go +++ b/federation/eventauth/eventauth.go @@ -799,7 +799,7 @@ func parsePythonInt(val gjson.Result) *int { return ptr.Ptr(int(val.Int())) case gjson.String: // strconv.Atoi accepts signs as well as leading zeroes, so we just need to trim spaces beforehand - num, err := strconv.Atoi(strings.TrimSpace(val.Str)) + num, err := strconv.Atoi(removeUnderscores(strings.TrimSpace(val.Str))) if err != nil { return nil } @@ -810,6 +810,15 @@ func parsePythonInt(val gjson.Result) *int { } } +func removeUnderscores(num string) string { + numWithoutSign := strings.TrimPrefix(strings.TrimPrefix(num, "+"), "-") + if strings.HasPrefix(numWithoutSign, "_") || strings.HasSuffix(numWithoutSign, "_") { + // Leading or trailing underscores are not valid, let strconv.Atoi fail + return num + } + return strings.ReplaceAll(num, "_", "") +} + func safeParsePowerLevels(content jsontext.Value, into *event.PowerLevelsEventContent) { *into = event.PowerLevelsEventContent{ Users: make(map[id.UserID]int), diff --git a/federation/eventauth/eventauth_internal_test.go b/federation/eventauth/eventauth_internal_test.go new file mode 100644 index 00000000..e8f61b76 --- /dev/null +++ b/federation/eventauth/eventauth_internal_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package eventauth + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type pythonIntTest struct { + Name string + Input string + Expected int + Invalid bool +} + +var pythonIntTests = []pythonIntTest{ + {"True", `true`, 1, false}, + {"False", `false`, 0, false}, + {"SmallFloat", `3.1415`, 3, false}, + {"SmallFloatRoundDown", `10.999999999999999`, 10, false}, + {"SmallFloatRoundUp", `10.9999999999999999`, 11, false}, + {"BigFloatRoundDown", `1000000.9999999999`, 1000000, false}, + {"BigFloatRoundUp", `1000000.99999999999`, 1000001, false}, + {"String", `"123"`, 123, false}, + {"FloatInString", `"123.456"`, 0, true}, + {"StringWithPlusSign", `"+123"`, 123, false}, + {"StringWithMinusSign", `"-123"`, -123, false}, + {"StringWithSpaces", `" 123 "`, 123, false}, + {"StringWithSpacesAndSign", `" -123 "`, -123, false}, + {"StringWithUnderscores", `"123_456"`, 123456, false}, + {"StringWithUnderscores", `"123_456"`, 123456, false}, + {"StringWithTrailingUnderscore", `"123_456_"`, 0, true}, + {"StringWithLeadingUnderscore", `"_123_456"`, 0, true}, + {"StringWithUnderscoreAfterSign", `"+_123_456"`, 0, true}, + {"StringWithUnderscoreAfterSpace", `" _123_456"`, 0, true}, + {"StringWithUnderscoresAndSpaces", `" +1_2_3_4_5_6 "`, 123456, false}, +} + +func TestParsePythonInt(t *testing.T) { + for _, test := range pythonIntTests { + t.Run(test.Name, func(t *testing.T) { + output := parsePythonInt(gjson.Parse(test.Input)) + if test.Invalid { + assert.Nil(t, output) + } else { + require.NotNil(t, output) + assert.Equal(t, test.Expected, *output) + } + }) + } +} From bafba9b22773131e21a5be19f540e00fe8afb4ac Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 14 Feb 2026 23:39:57 +0200 Subject: [PATCH 545/581] federation/eventauth: make expected success a part of test name --- .../eventauth/eventauth_internal_test.go | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/federation/eventauth/eventauth_internal_test.go b/federation/eventauth/eventauth_internal_test.go index e8f61b76..9dd36a7b 100644 --- a/federation/eventauth/eventauth_internal_test.go +++ b/federation/eventauth/eventauth_internal_test.go @@ -9,6 +9,7 @@ package eventauth import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -19,42 +20,45 @@ import ( type pythonIntTest struct { Name string Input string - Expected int - Invalid bool + Expected int64 } var pythonIntTests = []pythonIntTest{ - {"True", `true`, 1, false}, - {"False", `false`, 0, false}, - {"SmallFloat", `3.1415`, 3, false}, - {"SmallFloatRoundDown", `10.999999999999999`, 10, false}, - {"SmallFloatRoundUp", `10.9999999999999999`, 11, false}, - {"BigFloatRoundDown", `1000000.9999999999`, 1000000, false}, - {"BigFloatRoundUp", `1000000.99999999999`, 1000001, false}, - {"String", `"123"`, 123, false}, - {"FloatInString", `"123.456"`, 0, true}, - {"StringWithPlusSign", `"+123"`, 123, false}, - {"StringWithMinusSign", `"-123"`, -123, false}, - {"StringWithSpaces", `" 123 "`, 123, false}, - {"StringWithSpacesAndSign", `" -123 "`, -123, false}, - {"StringWithUnderscores", `"123_456"`, 123456, false}, - {"StringWithUnderscores", `"123_456"`, 123456, false}, - {"StringWithTrailingUnderscore", `"123_456_"`, 0, true}, - {"StringWithLeadingUnderscore", `"_123_456"`, 0, true}, - {"StringWithUnderscoreAfterSign", `"+_123_456"`, 0, true}, - {"StringWithUnderscoreAfterSpace", `" _123_456"`, 0, true}, - {"StringWithUnderscoresAndSpaces", `" +1_2_3_4_5_6 "`, 123456, false}, + {"True", `true`, 1}, + {"False", `false`, 0}, + {"SmallFloat", `3.1415`, 3}, + {"SmallFloatRoundDown", `10.999999999999999`, 10}, + {"SmallFloatRoundUp", `10.9999999999999999`, 11}, + {"BigFloatRoundDown", `1000000.9999999999`, 1000000}, + {"BigFloatRoundUp", `1000000.99999999999`, 1000001}, + {"BigFloatPrecisionError", `9007199254740993.0`, 9007199254740992}, + {"BigFloatPrecisionError2", `9007199254740993.123`, 9007199254740994}, + {"Int64", `9223372036854775807`, 9223372036854775807}, + {"Int64String", `"9223372036854775807"`, 9223372036854775807}, + {"String", `"123"`, 123}, + {"InvalidFloatInString", `"123.456"`, 0}, + {"StringWithPlusSign", `"+123"`, 123}, + {"StringWithMinusSign", `"-123"`, -123}, + {"StringWithSpaces", `" 123 "`, 123}, + {"StringWithSpacesAndSign", `" -123 "`, -123}, + {"StringWithUnderscores", `"123_456"`, 123456}, + {"StringWithUnderscores", `"123_456"`, 123456}, + {"InvalidStringWithTrailingUnderscore", `"123_456_"`, 0}, + {"InvalidStringWithLeadingUnderscore", `"_123_456"`, 0}, + {"InvalidStringWithUnderscoreAfterSign", `"+_123_456"`, 0}, + {"InvalidStringWithUnderscoreAfterSpace", `" _123_456"`, 0}, + {"StringWithUnderscoresAndSpaces", `" +1_2_3_4_5_6 "`, 123456}, } func TestParsePythonInt(t *testing.T) { for _, test := range pythonIntTests { t.Run(test.Name, func(t *testing.T) { output := parsePythonInt(gjson.Parse(test.Input)) - if test.Invalid { + if strings.HasPrefix(test.Name, "Invalid") { assert.Nil(t, output) } else { require.NotNil(t, output) - assert.Equal(t, test.Expected, *output) + assert.Equal(t, int(test.Expected), *output) } }) } From c52d87b6ea999e55b5103e3d8e6629691dab16c8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 15 Feb 2026 21:47:10 +0200 Subject: [PATCH 546/581] mediaproxy: handle federation thumbnail requests --- mediaproxy/mediaproxy.go | 1 + 1 file changed, 1 insertion(+) diff --git a/mediaproxy/mediaproxy.go b/mediaproxy/mediaproxy.go index 2063675a..4d2bc7cf 100644 --- a/mediaproxy/mediaproxy.go +++ b/mediaproxy/mediaproxy.go @@ -143,6 +143,7 @@ func New(serverName string, serverKey string, getMedia GetMediaFunc) (*MediaProx } mp.FederationRouter = http.NewServeMux() mp.FederationRouter.HandleFunc("GET /v1/media/download/{mediaID}", mp.DownloadMediaFederation) + mp.FederationRouter.HandleFunc("GET /v1/media/thumbnail/{mediaID}", mp.DownloadMediaFederation) mp.FederationRouter.HandleFunc("GET /v1/version", mp.KeyServer.GetServerVersion) mp.ClientMediaRouter = http.NewServeMux() mp.ClientMediaRouter.HandleFunc("GET /download/{serverName}/{mediaID}", mp.DownloadMedia) From 53ed8526c6a9f0af9c1d72a239827e0876fa6f34 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 16 Feb 2026 14:29:09 +0200 Subject: [PATCH 547/581] federation/eventauth: disable underscore support in string power levels --- federation/eventauth/eventauth.go | 11 +---------- federation/eventauth/eventauth_internal_test.go | 7 ++++--- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/federation/eventauth/eventauth.go b/federation/eventauth/eventauth.go index d2073607..eac110a3 100644 --- a/federation/eventauth/eventauth.go +++ b/federation/eventauth/eventauth.go @@ -799,7 +799,7 @@ func parsePythonInt(val gjson.Result) *int { return ptr.Ptr(int(val.Int())) case gjson.String: // strconv.Atoi accepts signs as well as leading zeroes, so we just need to trim spaces beforehand - num, err := strconv.Atoi(removeUnderscores(strings.TrimSpace(val.Str))) + num, err := strconv.Atoi(strings.TrimSpace(val.Str)) if err != nil { return nil } @@ -810,15 +810,6 @@ func parsePythonInt(val gjson.Result) *int { } } -func removeUnderscores(num string) string { - numWithoutSign := strings.TrimPrefix(strings.TrimPrefix(num, "+"), "-") - if strings.HasPrefix(numWithoutSign, "_") || strings.HasSuffix(numWithoutSign, "_") { - // Leading or trailing underscores are not valid, let strconv.Atoi fail - return num - } - return strings.ReplaceAll(num, "_", "") -} - func safeParsePowerLevels(content jsontext.Value, into *event.PowerLevelsEventContent) { *into = event.PowerLevelsEventContent{ Users: make(map[id.UserID]int), diff --git a/federation/eventauth/eventauth_internal_test.go b/federation/eventauth/eventauth_internal_test.go index 9dd36a7b..d316f3c8 100644 --- a/federation/eventauth/eventauth_internal_test.go +++ b/federation/eventauth/eventauth_internal_test.go @@ -41,13 +41,14 @@ var pythonIntTests = []pythonIntTest{ {"StringWithMinusSign", `"-123"`, -123}, {"StringWithSpaces", `" 123 "`, 123}, {"StringWithSpacesAndSign", `" -123 "`, -123}, - {"StringWithUnderscores", `"123_456"`, 123456}, - {"StringWithUnderscores", `"123_456"`, 123456}, + //{"StringWithUnderscores", `"123_456"`, 123456}, + //{"StringWithUnderscores", `"123_456"`, 123456}, {"InvalidStringWithTrailingUnderscore", `"123_456_"`, 0}, + {"InvalidStringWithMultipleUnderscores", `"123__456"`, 0}, {"InvalidStringWithLeadingUnderscore", `"_123_456"`, 0}, {"InvalidStringWithUnderscoreAfterSign", `"+_123_456"`, 0}, {"InvalidStringWithUnderscoreAfterSpace", `" _123_456"`, 0}, - {"StringWithUnderscoresAndSpaces", `" +1_2_3_4_5_6 "`, 123456}, + //{"StringWithUnderscoresAndSpaces", `" +1_2_3_4_5_6 "`, 123456}, } func TestParsePythonInt(t *testing.T) { From 0b9471e1904d92ff3055384debd5b1287f3662cb Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 16 Feb 2026 14:31:01 +0200 Subject: [PATCH 548/581] dependencies: update --- go.mod | 16 ++++++++-------- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index a76d1ec7..647473cc 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,8 @@ require ( filippo.io/edwards25519 v1.1.0 github.com/chzyer/readline v1.5.1 github.com/coder/websocket v1.8.14 - github.com/lib/pq v1.10.9 - github.com/mattn/go-sqlite3 v1.14.33 + github.com/lib/pq v1.11.2 + github.com/mattn/go-sqlite3 v1.14.34 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.34.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e @@ -17,11 +17,11 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.16 - go.mau.fi/util v0.9.6-0.20260211193350-78c2ff4a9df8 + go.mau.fi/util v0.9.6 go.mau.fi/zeroconfig v0.2.0 - golang.org/x/crypto v0.47.0 - golang.org/x/exp v0.0.0-20260112195511-716be5621a96 - golang.org/x/net v0.49.0 + golang.org/x/crypto v0.48.0 + golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a + golang.org/x/net v0.50.0 golang.org/x/sync v0.19.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 @@ -36,7 +36,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index a142a727..dafa8c67 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= +github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -25,8 +25,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= -github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -52,26 +52,26 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.6-0.20260211193350-78c2ff4a9df8 h1:7McVSdP7wEpb1omjyKG5OjxCY2NPP5Ba1pJujkOZx7g= -go.mau.fi/util v0.9.6-0.20260211193350-78c2ff4a9df8/go.mod h1:DzglKWpYOxKq4h9noyJBMoUu72/XgbP8j/OPehS/l/U= +go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts= +go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= From 9cd7258764e3b17649887cc05c73b2ef90447650 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 16 Feb 2026 14:33:21 +0200 Subject: [PATCH 549/581] Bump version to v0.26.3 --- CHANGELOG.md | 21 +++++++++++++++++++++ version.go | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbc7c494..f2829199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## v0.26.3 (2026-02-16) + +* Bumped minimum Go version to 1.25. +* *(client)* Added fields for sending [MSC4354] sticky events. +* *(bridgev2)* Added automatic message request accepting when sending message. +* *(mediaproxy)* Added support for federation thumbnail endpoint. +* *(crypto/ssss)* Improved support for recovery keys with slightly broken + metadata. +* *(crypto)* Changed key import to call session received callback even for + sessions that already exist in the database. +* *(appservice)* Fixed building websocket URL accidentally using file path + separators instead of always `/`. +* *(crypto)* Fixed key exports not including the `sender_claimed_keys` field. +* *(client)* Fixed incorrect context usage in async uploads. +* *(crypto)* Fixed panic when passing invalid input to megolm message index + parser used for debugging. +* *(bridgev2/provisioning)* Fixed completed or failed logins not being cleaned + up properly. + +[MSC4354]: https://github.com/matrix-org/matrix-spec-proposals/pull/4354 + ## v0.26.2 (2026-01-16) * *(bridgev2)* Added chunked portal deletion to avoid database locks when diff --git a/version.go b/version.go index b0e31c7e..f00bbf39 100644 --- a/version.go +++ b/version.go @@ -8,7 +8,7 @@ import ( "strings" ) -const Version = "v0.26.2" +const Version = "v0.26.3" var GoModVersion = "" var Commit = "" From de0d12e26a7e548a1013c7e3ddd5e6c42b7feba8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 18 Feb 2026 12:41:16 +0200 Subject: [PATCH 550/581] goolm/crypto: add test to ensure shared secrets can't be zero --- crypto/goolm/crypto/curve25519.go | 1 + crypto/goolm/crypto/curve25519_test.go | 2 ++ 2 files changed, 3 insertions(+) diff --git a/crypto/goolm/crypto/curve25519.go b/crypto/goolm/crypto/curve25519.go index e9759501..6e42d886 100644 --- a/crypto/goolm/crypto/curve25519.go +++ b/crypto/goolm/crypto/curve25519.go @@ -53,6 +53,7 @@ func (c Curve25519KeyPair) B64Encoded() id.Curve25519 { // SharedSecret returns the shared secret between the key pair and the given public key. func (c Curve25519KeyPair) SharedSecret(pubKey Curve25519PublicKey) ([]byte, error) { + // Note: the standard library checks that the output is non-zero return c.PrivateKey.SharedSecret(pubKey) } diff --git a/crypto/goolm/crypto/curve25519_test.go b/crypto/goolm/crypto/curve25519_test.go index 9039c126..2550f15e 100644 --- a/crypto/goolm/crypto/curve25519_test.go +++ b/crypto/goolm/crypto/curve25519_test.go @@ -25,6 +25,8 @@ func TestCurve25519(t *testing.T) { fromPrivate, err := crypto.Curve25519GenerateFromPrivate(firstKeypair.PrivateKey) assert.NoError(t, err) assert.Equal(t, fromPrivate, firstKeypair) + _, err = secondKeypair.SharedSecret(make([]byte, crypto.Curve25519PublicKeyLength)) + assert.Error(t, err) } func TestCurve25519Case1(t *testing.T) { From ae58161412b86a684d8c581d7323910211a72aea Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 19 Feb 2026 14:09:59 +0200 Subject: [PATCH 551/581] bridgev2/provisioning: log group create params --- bridgev2/provisionutil/creategroup.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bridgev2/provisionutil/creategroup.go b/bridgev2/provisionutil/creategroup.go index fbe0a513..72bacaff 100644 --- a/bridgev2/provisionutil/creategroup.go +++ b/bridgev2/provisionutil/creategroup.go @@ -32,6 +32,9 @@ func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev if !ok { return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support creating groups")) } + zerolog.Ctx(ctx).Debug(). + Any("create_params", params). + Msg("Creating group chat on remote network") caps := login.Bridge.Network.GetCapabilities() typeSpec, validType := caps.Provisioning.GroupCreation[params.Type] if !validType { @@ -98,6 +101,9 @@ func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev if resp.PortalKey.IsEmpty() { return nil, ErrNoPortalKey } + zerolog.Ctx(ctx).Debug(). + Object("portal_key", resp.PortalKey). + Msg("Successfully created group on remote network") if resp.Portal == nil { resp.Portal, err = login.Bridge.GetPortalByKey(ctx, resp.PortalKey) if err != nil { From 974f7dc5446f25090b5cf35f53579a5bdd437d58 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 19 Feb 2026 14:10:20 +0200 Subject: [PATCH 552/581] crypto/decryptmegolm: allow device key mismatches, but mark as untrusted --- crypto/decryptmegolm.go | 8 +++++++- id/trust.go | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crypto/decryptmegolm.go b/crypto/decryptmegolm.go index 77a64b1e..9753eabd 100644 --- a/crypto/decryptmegolm.go +++ b/crypto/decryptmegolm.go @@ -124,7 +124,13 @@ func (mach *OlmMachine) DecryptMegolmEvent(ctx context.Context, evt *event.Event Msg("Couldn't resolve trust level of session: sent by unknown device") trustLevel = id.TrustStateUnknownDevice } else if device.SigningKey != sess.SigningKey || device.IdentityKey != sess.SenderKey { - return nil, ErrDeviceKeyMismatch + log.Debug(). + Stringer("session_sender_key", sess.SenderKey). + Stringer("device_sender_key", device.IdentityKey). + Stringer("session_signing_key", sess.SigningKey). + Stringer("device_signing_key", device.SigningKey). + Msg("Device keys don't match keys in session, marking as untrusted") + trustLevel = id.TrustStateDeviceKeyMismatch } else { trustLevel, err = mach.ResolveTrustContext(ctx, device) if err != nil { diff --git a/id/trust.go b/id/trust.go index 04f6e36b..6255093e 100644 --- a/id/trust.go +++ b/id/trust.go @@ -16,6 +16,7 @@ type TrustState int const ( TrustStateBlacklisted TrustState = -100 + TrustStateDeviceKeyMismatch TrustState = -5 TrustStateUnset TrustState = 0 TrustStateUnknownDevice TrustState = 10 TrustStateForwarded TrustState = 20 @@ -23,7 +24,7 @@ const ( TrustStateCrossSignedTOFU TrustState = 100 TrustStateCrossSignedVerified TrustState = 200 TrustStateVerified TrustState = 300 - TrustStateInvalid TrustState = (1 << 31) - 1 + TrustStateInvalid TrustState = -2147483647 ) func (ts *TrustState) UnmarshalText(data []byte) error { @@ -44,6 +45,8 @@ func ParseTrustState(val string) TrustState { switch strings.ToLower(val) { case "blacklisted": return TrustStateBlacklisted + case "device-key-mismatch": + return TrustStateDeviceKeyMismatch case "unverified": return TrustStateUnset case "cross-signed-untrusted": @@ -67,6 +70,8 @@ func (ts TrustState) String() string { switch ts { case TrustStateBlacklisted: return "blacklisted" + case TrustStateDeviceKeyMismatch: + return "device-key-mismatch" case TrustStateUnset: return "unverified" case TrustStateCrossSignedUntrusted: From 67d30e054ccd982cfae117653fb90cb2d60c612f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 19 Feb 2026 22:51:31 +0200 Subject: [PATCH 553/581] dependencies: update --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 647473cc..49a1d4e4 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.0 toolchain go1.26.0 require ( - filippo.io/edwards25519 v1.1.0 + filippo.io/edwards25519 v1.2.0 github.com/chzyer/readline v1.5.1 github.com/coder/websocket v1.8.14 github.com/lib/pq v1.11.2 @@ -20,7 +20,7 @@ require ( go.mau.fi/util v0.9.6 go.mau.fi/zeroconfig v0.2.0 golang.org/x/crypto v0.48.0 - golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa golang.org/x/net v0.50.0 golang.org/x/sync v0.19.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index dafa8c67..871a5156 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= @@ -58,8 +58,8 @@ go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= -golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= From bc79822eab1546980a56681e4ad07f0ed69941ce Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 21 Feb 2026 00:51:44 +0200 Subject: [PATCH 554/581] crypto: save source of megolm sessions --- crypto/keybackup.go | 3 +- crypto/keyimport.go | 13 ++++----- crypto/keysharing.go | 1 + crypto/sessions.go | 2 ++ crypto/sql_store.go | 25 ++++++++++------- .../sql_store_upgrade/00-latest-revision.sql | 3 +- .../19-megolm-session-source.sql | 2 ++ id/crypto.go | 28 +++++++++++++++++++ 8 files changed, 58 insertions(+), 19 deletions(-) create mode 100644 crypto/sql_store_upgrade/19-megolm-session-source.sql diff --git a/crypto/keybackup.go b/crypto/keybackup.go index ceec1d58..7b3c30db 100644 --- a/crypto/keybackup.go +++ b/crypto/keybackup.go @@ -200,13 +200,14 @@ func (mach *OlmMachine) ImportRoomKeyFromBackupWithoutSaving( SigningKey: keyBackupData.SenderClaimedKeys.Ed25519, SenderKey: keyBackupData.SenderKey, RoomID: roomID, - ForwardingChains: append(keyBackupData.ForwardingKeyChain, keyBackupData.SenderKey.String()), + ForwardingChains: keyBackupData.ForwardingKeyChain, id: sessionID, ReceivedAt: time.Now().UTC(), MaxAge: maxAge.Milliseconds(), MaxMessages: maxMessages, KeyBackupVersion: version, + KeySource: id.KeySourceBackup, }, nil } diff --git a/crypto/keyimport.go b/crypto/keyimport.go index aef3eca2..3ffc74a5 100644 --- a/crypto/keyimport.go +++ b/crypto/keyimport.go @@ -108,14 +108,13 @@ func (mach *OlmMachine) importExportedRoomKey(ctx context.Context, session Expor return false, ErrMismatchingExportedSessionID } igs := &InboundGroupSession{ - Internal: igsInternal, - SigningKey: session.SenderClaimedKeys.Ed25519, - SenderKey: session.SenderKey, - RoomID: session.RoomID, - // TODO should we add something here to mark the signing key as unverified like key requests do? + Internal: igsInternal, + SigningKey: session.SenderClaimedKeys.Ed25519, + SenderKey: session.SenderKey, + RoomID: session.RoomID, ForwardingChains: session.ForwardingChains, - - ReceivedAt: time.Now().UTC(), + KeySource: id.KeySourceImport, + ReceivedAt: time.Now().UTC(), } existingIGS, _ := mach.CryptoStore.GetGroupSession(ctx, igs.RoomID, igs.ID()) firstKnownIndex := igs.Internal.FirstKnownIndex() diff --git a/crypto/keysharing.go b/crypto/keysharing.go index c1f7171c..19a68c87 100644 --- a/crypto/keysharing.go +++ b/crypto/keysharing.go @@ -189,6 +189,7 @@ func (mach *OlmMachine) importForwardedRoomKey(ctx context.Context, evt *Decrypt MaxAge: maxAge.Milliseconds(), MaxMessages: maxMessages, IsScheduled: content.IsScheduled, + KeySource: id.KeySourceForward, } existingIGS, _ := mach.CryptoStore.GetGroupSession(ctx, igs.RoomID, igs.ID()) if existingIGS != nil && existingIGS.Internal.FirstKnownIndex() <= igs.Internal.FirstKnownIndex() { diff --git a/crypto/sessions.go b/crypto/sessions.go index d7e68eb1..ccc7b784 100644 --- a/crypto/sessions.go +++ b/crypto/sessions.go @@ -117,6 +117,7 @@ type InboundGroupSession struct { MaxMessages int IsScheduled bool KeyBackupVersion id.KeyBackupVersion + KeySource id.KeySource id id.SessionID } @@ -136,6 +137,7 @@ func NewInboundGroupSession(senderKey id.SenderKey, signingKey id.Ed25519, roomI MaxAge: maxAge.Milliseconds(), MaxMessages: maxMessages, IsScheduled: isScheduled, + KeySource: id.KeySourceDirect, }, nil } diff --git a/crypto/sql_store.go b/crypto/sql_store.go index ca75b3f6..138cc557 100644 --- a/crypto/sql_store.go +++ b/crypto/sql_store.go @@ -346,22 +346,23 @@ func (store *SQLCryptoStore) PutGroupSession(ctx context.Context, session *Inbou Int("max_messages", session.MaxMessages). Bool("is_scheduled", session.IsScheduled). Stringer("key_backup_version", session.KeyBackupVersion). + Stringer("key_source", session.KeySource). Msg("Upserting megolm inbound group session") _, err = store.DB.Exec(ctx, ` INSERT INTO crypto_megolm_inbound_session ( session_id, sender_key, signing_key, room_id, session, forwarding_chains, - ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, account_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source, account_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (session_id, account_id) DO UPDATE SET withheld_code=NULL, withheld_reason=NULL, sender_key=excluded.sender_key, signing_key=excluded.signing_key, room_id=excluded.room_id, session=excluded.session, forwarding_chains=excluded.forwarding_chains, ratchet_safety=excluded.ratchet_safety, received_at=excluded.received_at, max_age=excluded.max_age, max_messages=excluded.max_messages, is_scheduled=excluded.is_scheduled, - key_backup_version=excluded.key_backup_version + key_backup_version=excluded.key_backup_version, key_source=excluded.key_source `, session.ID(), session.SenderKey, session.SigningKey, session.RoomID, sessionBytes, forwardingChains, ratchetSafety, datePtr(session.ReceivedAt), dbutil.NumPtr(session.MaxAge), dbutil.NumPtr(session.MaxMessages), - session.IsScheduled, session.KeyBackupVersion, store.AccountID, + session.IsScheduled, session.KeyBackupVersion, session.KeySource, store.AccountID, ) return err } @@ -374,12 +375,13 @@ func (store *SQLCryptoStore) GetGroupSession(ctx context.Context, roomID id.Room var maxAge, maxMessages sql.NullInt64 var isScheduled bool var version id.KeyBackupVersion + var keySource id.KeySource err := store.DB.QueryRow(ctx, ` - SELECT sender_key, signing_key, session, forwarding_chains, withheld_code, withheld_reason, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version + SELECT sender_key, signing_key, session, forwarding_chains, withheld_code, withheld_reason, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source FROM crypto_megolm_inbound_session WHERE room_id=$1 AND session_id=$2 AND account_id=$3`, roomID, sessionID, store.AccountID, - ).Scan(&senderKey, &signingKey, &sessionBytes, &forwardingChains, &withheldCode, &withheldReason, &ratchetSafetyBytes, &receivedAt, &maxAge, &maxMessages, &isScheduled, &version) + ).Scan(&senderKey, &signingKey, &sessionBytes, &forwardingChains, &withheldCode, &withheldReason, &ratchetSafetyBytes, &receivedAt, &maxAge, &maxMessages, &isScheduled, &version, &keySource) if errors.Is(err, sql.ErrNoRows) { return nil, nil } else if err != nil { @@ -410,6 +412,7 @@ func (store *SQLCryptoStore) GetGroupSession(ctx context.Context, roomID id.Room MaxMessages: int(maxMessages.Int64), IsScheduled: isScheduled, KeyBackupVersion: version, + KeySource: keySource, }, nil } @@ -534,7 +537,8 @@ func (store *SQLCryptoStore) scanInboundGroupSession(rows dbutil.Scannable) (*In var maxAge, maxMessages sql.NullInt64 var isScheduled bool var version id.KeyBackupVersion - err := rows.Scan(&roomID, &senderKey, &signingKey, &sessionBytes, &forwardingChains, &ratchetSafetyBytes, &receivedAt, &maxAge, &maxMessages, &isScheduled, &version) + var keySource id.KeySource + err := rows.Scan(&roomID, &senderKey, &signingKey, &sessionBytes, &forwardingChains, &ratchetSafetyBytes, &receivedAt, &maxAge, &maxMessages, &isScheduled, &version, &keySource) if err != nil { return nil, err } @@ -554,12 +558,13 @@ func (store *SQLCryptoStore) scanInboundGroupSession(rows dbutil.Scannable) (*In MaxMessages: int(maxMessages.Int64), IsScheduled: isScheduled, KeyBackupVersion: version, + KeySource: keySource, }, nil } func (store *SQLCryptoStore) GetGroupSessionsForRoom(ctx context.Context, roomID id.RoomID) dbutil.RowIter[*InboundGroupSession] { rows, err := store.DB.Query(ctx, ` - SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version + SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source FROM crypto_megolm_inbound_session WHERE room_id=$1 AND account_id=$2 AND session IS NOT NULL`, roomID, store.AccountID, ) @@ -568,7 +573,7 @@ func (store *SQLCryptoStore) GetGroupSessionsForRoom(ctx context.Context, roomID func (store *SQLCryptoStore) GetAllGroupSessions(ctx context.Context) dbutil.RowIter[*InboundGroupSession] { rows, err := store.DB.Query(ctx, ` - SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version + SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source FROM crypto_megolm_inbound_session WHERE account_id=$1 AND session IS NOT NULL`, store.AccountID, ) @@ -577,7 +582,7 @@ func (store *SQLCryptoStore) GetAllGroupSessions(ctx context.Context) dbutil.Row func (store *SQLCryptoStore) GetGroupSessionsWithoutKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion) dbutil.RowIter[*InboundGroupSession] { rows, err := store.DB.Query(ctx, ` - SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version + SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source FROM crypto_megolm_inbound_session WHERE account_id=$1 AND session IS NOT NULL AND key_backup_version != $2`, store.AccountID, version, ) diff --git a/crypto/sql_store_upgrade/00-latest-revision.sql b/crypto/sql_store_upgrade/00-latest-revision.sql index af8ab5cc..3709f1e5 100644 --- a/crypto/sql_store_upgrade/00-latest-revision.sql +++ b/crypto/sql_store_upgrade/00-latest-revision.sql @@ -1,4 +1,4 @@ --- v0 -> v18 (compatible with v15+): Latest revision +-- v0 -> v19 (compatible with v15+): Latest revision CREATE TABLE IF NOT EXISTS crypto_account ( account_id TEXT PRIMARY KEY, device_id TEXT NOT NULL, @@ -71,6 +71,7 @@ CREATE TABLE IF NOT EXISTS crypto_megolm_inbound_session ( max_messages INTEGER, is_scheduled BOOLEAN NOT NULL DEFAULT false, key_backup_version TEXT NOT NULL DEFAULT '', + key_source TEXT NOT NULL DEFAULT '', PRIMARY KEY (account_id, session_id) ); -- Useful index to find keys that need backing up diff --git a/crypto/sql_store_upgrade/19-megolm-session-source.sql b/crypto/sql_store_upgrade/19-megolm-session-source.sql new file mode 100644 index 00000000..f624222f --- /dev/null +++ b/crypto/sql_store_upgrade/19-megolm-session-source.sql @@ -0,0 +1,2 @@ +-- v19 (compatible with v15+): Store megolm session source +ALTER TABLE crypto_megolm_inbound_session ADD COLUMN key_source TEXT NOT NULL DEFAULT ''; diff --git a/id/crypto.go b/id/crypto.go index 355a84a8..ee857f78 100644 --- a/id/crypto.go +++ b/id/crypto.go @@ -53,6 +53,34 @@ const ( KeyBackupAlgorithmMegolmBackupV1 KeyBackupAlgorithm = "m.megolm_backup.v1.curve25519-aes-sha2" ) +type KeySource string + +func (source KeySource) String() string { + return string(source) +} + +func (source KeySource) Int() int { + switch source { + case KeySourceDirect: + return 100 + case KeySourceBackup: + return 90 + case KeySourceImport: + return 80 + case KeySourceForward: + return 50 + default: + return 0 + } +} + +const ( + KeySourceDirect KeySource = "direct" + KeySourceBackup KeySource = "backup" + KeySourceImport KeySource = "import" + KeySourceForward KeySource = "forward" +) + // BackupVersion is an arbitrary string that identifies a server side key backup. type KeyBackupVersion string From 5779871f1b22e48433b37d68b9761d149422b590 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 21 Feb 2026 14:09:20 +0200 Subject: [PATCH 555/581] bridgev2/commands: add file info for QR codes --- bridgev2/commands/login.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bridgev2/commands/login.go b/bridgev2/commands/login.go index 80a7c733..c35b3952 100644 --- a/bridgev2/commands/login.go +++ b/bridgev2/commands/login.go @@ -251,14 +251,19 @@ func sendQR(ce *Event, qr string, prevEventID *id.EventID) error { return fmt.Errorf("failed to upload image: %w", err) } content := &event.MessageEventContent{ - MsgType: event.MsgImage, - FileName: "qr.png", - URL: qrMXC, - File: qrFile, - + MsgType: event.MsgImage, + FileName: "qr.png", + URL: qrMXC, + File: qrFile, Body: qr, Format: event.FormatHTML, FormattedBody: fmt.Sprintf("

%s
", html.EscapeString(qr)), + Info: &event.FileInfo{ + MimeType: "image/png", + Width: qrSizePx, + Height: qrSizePx, + Size: len(qrData), + }, } if *prevEventID != "" { content.SetEdit(*prevEventID) From 28b7bf7e567ed5bf7e80ae3b0e0abbe6042566aa Mon Sep 17 00:00:00 2001 From: timedout Date: Sun, 22 Feb 2026 19:37:19 +0000 Subject: [PATCH 556/581] federation/eventauth: Fix inverted membership check for 5.6.1 (#464) --- federation/eventauth/eventauth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/federation/eventauth/eventauth.go b/federation/eventauth/eventauth.go index eac110a3..c72933c2 100644 --- a/federation/eventauth/eventauth.go +++ b/federation/eventauth/eventauth.go @@ -505,7 +505,7 @@ func authorizeMember(roomVersion id.RoomVersion, evt, createEvt *pdu.PDU, authEv // 5.5.5. Otherwise, reject. return ErrInsufficientPermissionForKick case event.MembershipBan: - if senderMembership != event.MembershipLeave { + if senderMembership != event.MembershipJoin { // 5.6.1. If the sender’s current membership state is not join, reject. return ErrCantBanWithoutBeingInRoom } From 3efa3ef73a8230cf5b63a84d9184c04cfa7412d0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 23 Feb 2026 22:13:57 +0200 Subject: [PATCH 557/581] bridgev2/portal: log remote event timestamps by default --- bridgev2/portal.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index b72f00a6..718a5cb2 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -484,6 +484,11 @@ func (portal *Portal) getEventCtxWithLog(rawEvt any, idx int) context.Context { logWith = logWith.Int64("remote_stream_order", remoteStreamOrder) } } + if remoteMsg, ok := evt.evt.(RemoteEventWithTimestamp); ok { + if remoteTimestamp := remoteMsg.GetTimestamp(); !remoteTimestamp.IsZero() { + logWith = logWith.Time("remote_timestamp", remoteTimestamp) + } + } case *portalCreateEvent: return evt.ctx } From 7f24c7800222741910f359ff713333a518de3d50 Mon Sep 17 00:00:00 2001 From: Radon Rosborough Date: Wed, 25 Feb 2026 08:52:29 -0800 Subject: [PATCH 558/581] bridgev2/login: add attachments option to user input step type (#465) --- bridgev2/commands/login.go | 34 ++++++++++++++++++++++++++++++ bridgev2/login.go | 19 +++++++++++++++++ bridgev2/matrix/provisioning.yaml | 35 +++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/bridgev2/commands/login.go b/bridgev2/commands/login.go index c35b3952..9e706995 100644 --- a/bridgev2/commands/login.go +++ b/bridgev2/commands/login.go @@ -278,6 +278,36 @@ func sendQR(ce *Event, qr string, prevEventID *id.EventID) error { return nil } +func sendUserInputAttachments(ce *Event, atts []*bridgev2.LoginUserInputAttachment) error { + for _, att := range atts { + if att.FileName == "" { + return fmt.Errorf("missing attachment filename") + } + mxc, file, err := ce.Bot.UploadMedia(ce.Ctx, ce.RoomID, att.Content, att.FileName, att.Info.MimeType) + if err != nil { + return fmt.Errorf("failed to upload attachment %q: %w", att.FileName, err) + } + content := &event.MessageEventContent{ + MsgType: att.Type, + FileName: att.FileName, + URL: mxc, + File: file, + Info: &event.FileInfo{ + MimeType: att.Info.MimeType, + Width: att.Info.Width, + Height: att.Info.Height, + Size: att.Info.Size, + }, + Body: att.FileName, + } + _, err = ce.Bot.SendMessage(ce.Ctx, ce.RoomID, event.EventMessage, &event.Content{Parsed: content}, nil) + if err != nil { + return nil + } + } + return nil +} + type contextKey int const ( @@ -483,6 +513,10 @@ func doLoginStep(ce *Event, login bridgev2.LoginProcess, step *bridgev2.LoginSte Override: override, }).prompt(ce) case bridgev2.LoginStepTypeUserInput: + err := sendUserInputAttachments(ce, step.UserInputParams.Attachments) + if err != nil { + ce.Reply("Failed to send attachments: %v", err) + } (&userInputLoginCommandState{ Login: login.(bridgev2.LoginProcessUserInput), RemainingFields: step.UserInputParams.Fields, diff --git a/bridgev2/login.go b/bridgev2/login.go index 4ddbf13e..b8321719 100644 --- a/bridgev2/login.go +++ b/bridgev2/login.go @@ -13,6 +13,7 @@ import ( "strings" "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" ) // LoginProcess represents a single occurrence of a user logging into the remote network. @@ -179,6 +180,7 @@ const ( LoginInputFieldTypeURL LoginInputFieldType = "url" LoginInputFieldTypeDomain LoginInputFieldType = "domain" LoginInputFieldTypeSelect LoginInputFieldType = "select" + LoginInputFieldTypeCaptchaCode LoginInputFieldType = "captcha_code" ) type LoginInputDataField struct { @@ -271,6 +273,23 @@ func (f *LoginInputDataField) FillDefaultValidate() { type LoginUserInputParams struct { // The fields that the user needs to fill in. Fields []LoginInputDataField `json:"fields"` + + // Attachments to display alongside the input fields. + Attachments []*LoginUserInputAttachment `json:"attachments"` +} + +type LoginUserInputAttachment struct { + Type event.MessageType `json:"type,omitempty"` + FileName string `json:"filename,omitempty"` + Content []byte `json:"content,omitempty"` + Info LoginUserInputAttachmentInfo `json:"info,omitempty"` +} + +type LoginUserInputAttachmentInfo struct { + MimeType string `json:"mimetype,omitempty"` + Width int `json:"w,omitempty"` + Height int `json:"h,omitempty"` + Size int `json:"size,omitempty"` } type LoginCompleteParams struct { diff --git a/bridgev2/matrix/provisioning.yaml b/bridgev2/matrix/provisioning.yaml index d19a7e83..26068db4 100644 --- a/bridgev2/matrix/provisioning.yaml +++ b/bridgev2/matrix/provisioning.yaml @@ -740,6 +740,41 @@ components: description: For fields of type select, the valid options. items: type: string + attachments: + type: array + description: A list of media attachments to show the user alongside the form fields. + items: + type: object + description: A media attachment to show the user. + required: [ type, filename, content ] + properties: + type: + type: string + description: The type of media attachment, using the same media type identifiers as Matrix attachments. Only some are supported. + enum: [ m.image, m.audio ] + filename: + type: string + description: The filename for the media attachment. + content: + type: string + description: The raw file content for the attachment encoded in base64. + info: + type: object + description: Optional but recommended metadata for the attachment. Can generally be derived from the raw content if omitted. + properties: + mimetype: + type: string + description: The MIME type for the media content. + examples: [ image/png, audio/mpeg ] + w: + type: number + description: The width of the media in pixels. Only applicable for images and videos. + h: + type: number + description: The height of the media in pixels. Only applicable for images and videos. + size: + type: number + description: The size of the media content in number of bytes. Strongly recommended to include. - description: Cookie login step required: [ type, cookies ] properties: From 98c830181ba1953d78b45761cce39e281b1d7089 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 26 Feb 2026 17:20:31 +0200 Subject: [PATCH 559/581] client: omit large request bodies from logs --- client.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/client.go b/client.go index 2503556a..0a43816c 100644 --- a/client.go +++ b/client.go @@ -386,7 +386,14 @@ func (cli *Client) LogRequestDone(req *http.Request, resp *http.Response, err er } } if body := req.Context().Value(LogBodyContextKey); body != nil { - evt.Interface("req_body", body) + switch typedLogBody := body.(type) { + case json.RawMessage: + evt.RawJSON("req_body", typedLogBody) + case string: + evt.Str("req_body", typedLogBody) + default: + panic(fmt.Errorf("invalid type for LogBodyContextKey: %T", body)) + } } if errors.Is(err, context.Canceled) { evt.Msg("Request canceled") @@ -450,8 +457,10 @@ func (params *FullRequest) compileRequest(ctx context.Context) (*http.Request, e } if params.SensitiveContent && !logSensitiveContent { logBody = "" + } else if len(jsonStr) > 32768 { + logBody = fmt.Sprintf("", len(jsonStr)) } else { - logBody = params.RequestJSON + logBody = json.RawMessage(jsonStr) } reqBody = bytes.NewReader(jsonStr) reqLen = int64(len(jsonStr)) @@ -476,7 +485,7 @@ func (params *FullRequest) compileRequest(ctx context.Context) (*http.Request, e } } else if params.Method != http.MethodGet && params.Method != http.MethodHead { params.RequestJSON = struct{}{} - logBody = params.RequestJSON + logBody = json.RawMessage("{}") reqBody = bytes.NewReader([]byte("{}")) reqLen = 2 } From dd51c562abb36f8e325acefe8d9fd6a43644f0b0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 26 Feb 2026 17:21:10 +0200 Subject: [PATCH 560/581] crypto: log destination map when sharing megolm sessions --- crypto/decryptolm.go | 3 +++ crypto/encryptmegolm.go | 15 +++++---------- crypto/encryptolm.go | 16 ++++++++++------ 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/crypto/decryptolm.go b/crypto/decryptolm.go index cd02726d..aea5e6dc 100644 --- a/crypto/decryptolm.go +++ b/crypto/decryptolm.go @@ -134,6 +134,9 @@ func (mach *OlmMachine) decryptAndParseOlmCiphertext(ctx context.Context, evt *e } func olmMessageHash(ciphertext string) ([32]byte, error) { + if ciphertext == "" { + return [32]byte{}, fmt.Errorf("empty ciphertext") + } ciphertextBytes, err := base64.RawStdEncoding.DecodeString(ciphertext) return sha256.Sum256(ciphertextBytes), err } diff --git a/crypto/encryptmegolm.go b/crypto/encryptmegolm.go index 806a227d..88f9c8d4 100644 --- a/crypto/encryptmegolm.go +++ b/crypto/encryptmegolm.go @@ -370,26 +370,19 @@ func (mach *OlmMachine) encryptAndSendGroupSession(ctx context.Context, session log.Trace().Msg("Encrypting group session for all found devices") deviceCount := 0 toDevice := &mautrix.ReqSendToDevice{Messages: make(map[id.UserID]map[id.DeviceID]*event.Content)} + logUsers := zerolog.Dict() for userID, sessions := range olmSessions { if len(sessions) == 0 { continue } + logDevices := zerolog.Dict() output := make(map[id.DeviceID]*event.Content) toDevice.Messages[userID] = output for deviceID, device := range sessions { - log.Trace(). - Stringer("target_user_id", userID). - Stringer("target_device_id", deviceID). - Stringer("target_identity_key", device.identity.IdentityKey). - Msg("Encrypting group session for device") content := mach.encryptOlmEvent(ctx, device.session, device.identity, event.ToDeviceRoomKey, session.ShareContent()) output[deviceID] = &event.Content{Parsed: content} + logDevices.Str(string(deviceID), string(device.identity.IdentityKey)) deviceCount++ - log.Debug(). - Stringer("target_user_id", userID). - Stringer("target_device_id", deviceID). - Stringer("target_identity_key", device.identity.IdentityKey). - Msg("Encrypted group session for device") if !mach.DisableSharedGroupSessionTracking { err := mach.CryptoStore.MarkOutboundGroupSessionShared(ctx, userID, device.identity.IdentityKey, session.id) if err != nil { @@ -403,11 +396,13 @@ func (mach *OlmMachine) encryptAndSendGroupSession(ctx context.Context, session } } } + logUsers.Dict(string(userID), logDevices) } log.Debug(). Int("device_count", deviceCount). Int("user_count", len(toDevice.Messages)). + Dict("destination_map", logUsers). Msg("Sending to-device messages to share group session") _, err := mach.Client.SendToDevice(ctx, event.ToDeviceEncrypted, toDevice) return err diff --git a/crypto/encryptolm.go b/crypto/encryptolm.go index 80b76dc5..765307af 100644 --- a/crypto/encryptolm.go +++ b/crypto/encryptolm.go @@ -96,15 +96,19 @@ func (mach *OlmMachine) encryptOlmEvent(ctx context.Context, session *OlmSession panic(err) } log := mach.machOrContextLog(ctx) - log.Debug(). - Str("recipient_identity_key", recipient.IdentityKey.String()). - Str("olm_session_id", session.ID().String()). - Str("olm_session_description", session.Describe()). - Msg("Encrypting olm message") msgType, ciphertext, err := session.Encrypt(plaintext) if err != nil { panic(err) } + ciphertextStr := string(ciphertext) + ciphertextHash, _ := olmMessageHash(ciphertextStr) + log.Debug(). + Stringer("event_type", evtType). + Str("recipient_identity_key", recipient.IdentityKey.String()). + Str("olm_session_id", session.ID().String()). + Str("olm_session_description", session.Describe()). + Hex("ciphertext_hash", ciphertextHash[:]). + Msg("Encrypted olm message") err = mach.CryptoStore.UpdateSession(ctx, recipient.IdentityKey, session) if err != nil { log.Error().Err(err).Msg("Failed to update olm session in crypto store after encrypting") @@ -115,7 +119,7 @@ func (mach *OlmMachine) encryptOlmEvent(ctx context.Context, session *OlmSession OlmCiphertext: event.OlmCiphertexts{ recipient.IdentityKey: { Type: msgType, - Body: string(ciphertext), + Body: ciphertextStr, }, }, } From 36c353abc7b40d8d9a951286ca7824bd3bfc6744 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 1 Mar 2026 12:37:13 +0200 Subject: [PATCH 561/581] federation/pdu: add AddSignature helper method --- federation/pdu/pdu.go | 13 +++++++++++++ federation/pdu/signature.go | 8 +------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/federation/pdu/pdu.go b/federation/pdu/pdu.go index cecee5b9..17db6995 100644 --- a/federation/pdu/pdu.go +++ b/federation/pdu/pdu.go @@ -123,6 +123,19 @@ func (pdu *PDU) ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error) return evt, nil } +func (pdu *PDU) AddSignature(serverName string, keyID id.KeyID, signature string) { + if signature == "" { + return + } + if pdu.Signatures == nil { + pdu.Signatures = make(map[string]map[id.KeyID]string) + } + if _, ok := pdu.Signatures[serverName]; !ok { + pdu.Signatures[serverName] = make(map[id.KeyID]string) + } + pdu.Signatures[serverName][keyID] = signature +} + func marshalCanonical(data any) (jsontext.Value, error) { marshaledBytes, err := json.Marshal(data) if err != nil { diff --git a/federation/pdu/signature.go b/federation/pdu/signature.go index a7685cc6..04e7c5ef 100644 --- a/federation/pdu/signature.go +++ b/federation/pdu/signature.go @@ -28,13 +28,7 @@ func (pdu *PDU) Sign(roomVersion id.RoomVersion, serverName string, keyID id.Key return fmt.Errorf("failed to marshal redacted PDU to sign: %w", err) } signature := ed25519.Sign(privateKey, rawJSON) - if pdu.Signatures == nil { - pdu.Signatures = make(map[string]map[id.KeyID]string) - } - if _, ok := pdu.Signatures[serverName]; !ok { - pdu.Signatures[serverName] = make(map[id.KeyID]string) - } - pdu.Signatures[serverName][keyID] = base64.RawStdEncoding.EncodeToString(signature) + pdu.AddSignature(serverName, keyID, base64.RawStdEncoding.EncodeToString(signature)) return nil } From f8234ecf8556f72cf4711cf23e3d51411027c910 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 1 Mar 2026 13:23:32 +0200 Subject: [PATCH 562/581] event: add m.room.policy event type --- event/content.go | 3 +++ event/state.go | 12 ++++++++++++ event/type.go | 5 ++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/event/content.go b/event/content.go index d1ced268..4aa0593d 100644 --- a/event/content.go +++ b/event/content.go @@ -40,6 +40,9 @@ var TypeMap = map[Type]reflect.Type{ StateSpaceParent: reflect.TypeOf(SpaceParentEventContent{}), StateSpaceChild: reflect.TypeOf(SpaceChildEventContent{}), + StateRoomPolicy: reflect.TypeOf(RoomPolicyEventContent{}), + StateUnstableRoomPolicy: reflect.TypeOf(RoomPolicyEventContent{}), + StateLegacyPolicyRoom: reflect.TypeOf(ModPolicyContent{}), StateLegacyPolicyServer: reflect.TypeOf(ModPolicyContent{}), StateLegacyPolicyUser: reflect.TypeOf(ModPolicyContent{}), diff --git a/event/state.go b/event/state.go index 6d027e04..1df43351 100644 --- a/event/state.go +++ b/event/state.go @@ -343,3 +343,15 @@ func (efmc *ElementFunctionalMembersContent) Add(mxid id.UserID) bool { efmc.ServiceMembers = append(efmc.ServiceMembers, mxid) return true } + +type PolicyServerPublicKeys struct { + Ed25519 id.Ed25519 `json:"ed25519,omitempty"` +} + +type RoomPolicyEventContent struct { + Via string `json:"via,omitempty"` + PublicKeys *PolicyServerPublicKeys `json:"public_keys,omitempty"` + + // Deprecated, only for legacy use + PublicKey id.Ed25519 `json:"public_key"` +} diff --git a/event/type.go b/event/type.go index b193dc59..f337c127 100644 --- a/event/type.go +++ b/event/type.go @@ -113,7 +113,7 @@ func (et *Type) GuessClass() TypeClass { StatePinnedEvents.Type, StateTombstone.Type, StateEncryption.Type, StateBridge.Type, StateHalfShotBridge.Type, StateSpaceParent.Type, StateSpaceChild.Type, StatePolicyRoom.Type, StatePolicyServer.Type, StatePolicyUser.Type, StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type, StateBeeperDisappearingTimer.Type, - StateMSC4391BotCommand.Type: + StateMSC4391BotCommand.Type, StateRoomPolicy.Type, StateUnstableRoomPolicy.Type: return StateEventType case EphemeralEventReceipt.Type, EphemeralEventTyping.Type, EphemeralEventPresence.Type: return EphemeralEventType @@ -195,6 +195,9 @@ var ( StateSpaceChild = Type{"m.space.child", StateEventType} StateSpaceParent = Type{"m.space.parent", StateEventType} + StateRoomPolicy = Type{"m.room.policy", StateEventType} + StateUnstableRoomPolicy = Type{"org.matrix.msc4284.policy", StateEventType} + StateLegacyPolicyRoom = Type{"m.room.rule.room", StateEventType} StateLegacyPolicyServer = Type{"m.room.rule.server", StateEventType} StateLegacyPolicyUser = Type{"m.room.rule.user", StateEventType} From 26a62a7eec2b30cb88baffe30596e3ba0d278f9d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 1 Mar 2026 13:49:04 +0200 Subject: [PATCH 563/581] event: add missing omitempty --- event/state.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/event/state.go b/event/state.go index 1df43351..ace170a5 100644 --- a/event/state.go +++ b/event/state.go @@ -353,5 +353,5 @@ type RoomPolicyEventContent struct { PublicKeys *PolicyServerPublicKeys `json:"public_keys,omitempty"` // Deprecated, only for legacy use - PublicKey id.Ed25519 `json:"public_key"` + PublicKey id.Ed25519 `json:"public_key,omitempty"` } From e1529f9616a95ea18506fb99b8e835c44631735d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 3 Mar 2026 17:28:19 +0200 Subject: [PATCH 564/581] bridgev2/provisioning: log when returning login steps in provisioning API --- bridgev2/matrix/provisioning.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 17e827e3..8989ad51 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -407,6 +407,10 @@ func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Reque } func (prov *ProvisioningAPI) handleCompleteStep(ctx context.Context, login *ProvLogin, step *bridgev2.LoginStep) { + zerolog.Ctx(ctx).Info(). + Str("step_id", step.StepID). + Str("user_login_id", string(step.CompleteParams.UserLoginID)). + Msg("Login completed successfully") prov.deleteLogin(login, false) if login.Override == nil || login.Override.ID == step.CompleteParams.UserLoginID { return @@ -506,6 +510,8 @@ func (prov *ProvisioningAPI) PostLoginSubmitInput(w http.ResponseWriter, r *http login.NextStep = nextStep if nextStep.Type == bridgev2.LoginStepTypeComplete { prov.handleCompleteStep(r.Context(), login, nextStep) + } else { + zerolog.Ctx(r.Context()).Debug().Str("step_id", nextStep.StepID).Msg("Returning next login step") } exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: nextStep}) } @@ -525,6 +531,8 @@ func (prov *ProvisioningAPI) PostLoginWait(w http.ResponseWriter, r *http.Reques login.NextStep = nextStep if nextStep.Type == bridgev2.LoginStepTypeComplete { prov.handleCompleteStep(r.Context(), login, nextStep) + } else { + zerolog.Ctx(r.Context()).Debug().Str("step_id", nextStep.StepID).Msg("Returning next login step") } exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: nextStep}) } From 77f0658365509428ce4c4784e1bf2d192b4a483b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 3 Mar 2026 17:33:51 +0200 Subject: [PATCH 565/581] bridgev2/{commands,provisioning}: log full login step data --- bridgev2/commands/login.go | 2 ++ bridgev2/matrix/provisioning.go | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/bridgev2/commands/login.go b/bridgev2/commands/login.go index 9e706995..96d62d3e 100644 --- a/bridgev2/commands/login.go +++ b/bridgev2/commands/login.go @@ -121,6 +121,7 @@ func fnLogin(ce *Event) { ce.Reply("Failed to start login: %v", err) return } + ce.Log.Debug().Any("first_step", nextStep).Msg("Created login process") nextStep = checkLoginCommandDirectParams(ce, login, nextStep) if nextStep != nil { @@ -499,6 +500,7 @@ func maybeURLDecodeCookie(val string, field *bridgev2.LoginCookieField) string { } func doLoginStep(ce *Event, login bridgev2.LoginProcess, step *bridgev2.LoginStep, override *bridgev2.UserLogin) { + ce.Log.Debug().Any("next_step", step).Msg("Got next login step") if step.Instructions != "" { ce.Reply(step.Instructions) } diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 8989ad51..02a0dac9 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -403,6 +403,9 @@ func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Reque Override: overrideLogin, } prov.loginsLock.Unlock() + zerolog.Ctx(r.Context()).Info(). + Any("first_step", firstStep). + Msg("Created login process") exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: loginID, LoginStep: firstStep}) } @@ -511,7 +514,7 @@ func (prov *ProvisioningAPI) PostLoginSubmitInput(w http.ResponseWriter, r *http if nextStep.Type == bridgev2.LoginStepTypeComplete { prov.handleCompleteStep(r.Context(), login, nextStep) } else { - zerolog.Ctx(r.Context()).Debug().Str("step_id", nextStep.StepID).Msg("Returning next login step") + zerolog.Ctx(r.Context()).Debug().Any("next_step", nextStep).Msg("Returning next login step") } exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: nextStep}) } @@ -532,7 +535,7 @@ func (prov *ProvisioningAPI) PostLoginWait(w http.ResponseWriter, r *http.Reques if nextStep.Type == bridgev2.LoginStepTypeComplete { prov.handleCompleteStep(r.Context(), login, nextStep) } else { - zerolog.Ctx(r.Context()).Debug().Str("step_id", nextStep.StepID).Msg("Returning next login step") + zerolog.Ctx(r.Context()).Debug().Any("next_step", nextStep).Msg("Returning next login step") } exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: nextStep}) } From fef4326fbce6a20eac52028fb18a9da2ffd28061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Wed, 4 Mar 2026 01:38:50 +0100 Subject: [PATCH 566/581] client,event,bridgev2: add support for Beeper's custom ephemeral events and AI stream events (#457) --- appservice/intent.go | 11 ++ bridgev2/errors.go | 1 + bridgev2/matrix/connector.go | 2 + bridgev2/matrix/intent.go | 16 +++ bridgev2/matrix/matrix.go | 5 +- bridgev2/matrixinterface.go | 5 + bridgev2/networkinterface.go | 6 ++ bridgev2/portal.go | 46 ++++++++ client.go | 42 ++++++++ client_ephemeral_test.go | 158 ++++++++++++++++++++++++++++ crypto/decryptmegolm.go | 1 + event/beeper.go | 9 ++ event/content.go | 8 +- event/powerlevels.go | 38 +++++++ event/powerlevels_ephemeral_test.go | 67 ++++++++++++ event/type.go | 10 +- versions.go | 1 + 17 files changed, 418 insertions(+), 8 deletions(-) create mode 100644 client_ephemeral_test.go create mode 100644 event/powerlevels_ephemeral_test.go diff --git a/appservice/intent.go b/appservice/intent.go index e4d8e100..0ec10b77 100644 --- a/appservice/intent.go +++ b/appservice/intent.go @@ -222,6 +222,17 @@ func (intent *IntentAPI) SendMessageEvent(ctx context.Context, roomID id.RoomID, return intent.Client.SendMessageEvent(ctx, roomID, eventType, contentJSON, extra...) } +func (intent *IntentAPI) BeeperSendEphemeralEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON any, extra ...mautrix.ReqSendEvent) (*mautrix.RespSendEvent, error) { + if err := intent.EnsureJoined(ctx, roomID); err != nil { + return nil, err + } + if !intent.SpecVersions.Supports(mautrix.BeeperFeatureEphemeralEvents) { + return nil, mautrix.MUnrecognized.WithMessage("Homeserver does not advertise com.beeper.ephemeral support") + } + contentJSON = intent.AddDoublePuppetValue(contentJSON) + return intent.Client.BeeperSendEphemeralEvent(ctx, roomID, eventType, contentJSON, extra...) +} + // Deprecated: use SendMessageEvent with mautrix.ReqSendEvent.Timestamp instead func (intent *IntentAPI) SendMassagedMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) { return intent.SendMessageEvent(ctx, roomID, eventType, contentJSON, mautrix.ReqSendEvent{Timestamp: ts}) diff --git a/bridgev2/errors.go b/bridgev2/errors.go index 514dc238..f6677d2e 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -75,6 +75,7 @@ var ( ErrMediaConvertFailed error = WrapErrorInStatus(errors.New("failed to convert media")).WithMessage("failed to convert media").WithIsCertain(true).WithSendNotice(true) ErrMembershipNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group membership")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) ErrDeleteChatNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting chats")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrBeeperAIStreamNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support Beeper AI stream events")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) ErrPowerLevelsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group power levels")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) ErrRemoteEchoTimeout = WrapErrorInStatus(errors.New("remote echo timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) ErrRemoteAckTimeout = WrapErrorInStatus(errors.New("remote ack timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index aed6d3bd..b6da16ac 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -144,6 +144,7 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) { br.EventProcessor.On(event.EventReaction, br.handleRoomEvent) br.EventProcessor.On(event.EventRedaction, br.handleRoomEvent) br.EventProcessor.On(event.EventEncrypted, br.handleEncryptedEvent) + br.EventProcessor.On(event.EphemeralEventEncrypted, br.handleEncryptedEvent) br.EventProcessor.On(event.StateMember, br.handleRoomEvent) br.EventProcessor.On(event.StatePowerLevels, br.handleRoomEvent) br.EventProcessor.On(event.StateRoomName, br.handleRoomEvent) @@ -156,6 +157,7 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) { br.EventProcessor.On(event.BeeperAcceptMessageRequest, br.handleRoomEvent) br.EventProcessor.On(event.EphemeralEventReceipt, br.handleEphemeralEvent) br.EventProcessor.On(event.EphemeralEventTyping, br.handleEphemeralEvent) + br.EventProcessor.On(event.BeeperEphemeralEventAIStream, br.handleEphemeralEvent) br.Bot = br.AS.BotIntent() br.Crypto = NewCryptoHelper(br) br.Bridge.Commands.(*commands.Processor).AddHandlers( diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index 173f7c15..83318493 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -43,6 +43,7 @@ type ASIntent struct { var _ bridgev2.MatrixAPI = (*ASIntent)(nil) var _ bridgev2.MarkAsDMMatrixAPI = (*ASIntent)(nil) +var _ bridgev2.EphemeralSendingMatrixAPI = (*ASIntent)(nil) func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, extra *bridgev2.MatrixSendExtra) (*mautrix.RespSendEvent, error) { if extra == nil { @@ -84,6 +85,21 @@ func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType return as.Matrix.SendMessageEvent(ctx, roomID, eventType, content, mautrix.ReqSendEvent{Timestamp: extra.Timestamp.UnixMilli()}) } +func (as *ASIntent) BeeperSendEphemeralEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, txnID string) (*mautrix.RespSendEvent, error) { + if !as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureEphemeralEvents) { + return nil, mautrix.MUnrecognized.WithMessage("Homeserver does not advertise com.beeper.ephemeral support") + } + if encrypted, err := as.Matrix.StateStore.IsEncrypted(ctx, roomID); err != nil { + return nil, fmt.Errorf("failed to check if room is encrypted: %w", err) + } else if encrypted && as.Connector.Crypto != nil { + if err = as.Connector.Crypto.Encrypt(ctx, roomID, eventType, content); err != nil { + return nil, err + } + eventType = event.EventEncrypted + } + return as.Matrix.BeeperSendEphemeralEvent(ctx, roomID, eventType, content, mautrix.ReqSendEvent{TransactionID: txnID}) +} + func (as *ASIntent) fillMemberEvent(ctx context.Context, roomID id.RoomID, userID id.UserID, content *event.Content) { targetContent, ok := content.Parsed.(*event.MemberEventContent) if !ok || targetContent.Displayname != "" || targetContent.AvatarURL != "" { diff --git a/bridgev2/matrix/matrix.go b/bridgev2/matrix/matrix.go index 570ae5f1..954d0ad9 100644 --- a/bridgev2/matrix/matrix.go +++ b/bridgev2/matrix/matrix.go @@ -68,6 +68,10 @@ func (br *Connector) handleEphemeralEvent(ctx context.Context, evt *event.Event) case event.EphemeralEventTyping: typingContent := evt.Content.AsTyping() typingContent.UserIDs = slices.DeleteFunc(typingContent.UserIDs, br.shouldIgnoreEventFromUser) + case event.BeeperEphemeralEventAIStream: + if br.shouldIgnoreEvent(evt) { + return + } } br.Bridge.QueueMatrixEvent(ctx, evt) } @@ -231,7 +235,6 @@ func (br *Connector) postDecrypt(ctx context.Context, original, decrypted *event go br.sendSuccessCheckpoint(ctx, decrypted, status.MsgStepDecrypted, retryCount) decrypted.Mautrix.CheckpointSent = true decrypted.Mautrix.DecryptionDuration = duration - decrypted.Mautrix.EventSource |= event.SourceDecrypted br.EventProcessor.Dispatch(ctx, decrypted) if errorEventID != nil && *errorEventID != "" { _, _ = br.Bot.RedactEvent(ctx, decrypted.RoomID, *errorEventID) diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index 57f786bb..768c57d1 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -217,3 +217,8 @@ type MarkAsDMMatrixAPI interface { MatrixAPI MarkAsDM(ctx context.Context, roomID id.RoomID, otherUser id.UserID) error } + +type EphemeralSendingMatrixAPI interface { + MatrixAPI + BeeperSendEphemeralEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, txnID string) (*mautrix.RespSendEvent, error) +} diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 0e9a8543..efc5f100 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -726,6 +726,11 @@ type MessageRequestAcceptingNetworkAPI interface { HandleMatrixAcceptMessageRequest(ctx context.Context, msg *MatrixAcceptMessageRequest) error } +type BeeperAIStreamHandlingNetworkAPI interface { + NetworkAPI + HandleMatrixBeeperAIStream(ctx context.Context, msg *MatrixBeeperAIStream) error +} + type ResolveIdentifierResponse struct { // Ghost is the ghost of the user that the identifier resolves to. // This field should be set whenever possible. However, it is not required, @@ -1439,6 +1444,7 @@ type MatrixViewingChat struct { type MatrixDeleteChat = MatrixEventBase[*event.BeeperChatDeleteEventContent] type MatrixAcceptMessageRequest = MatrixEventBase[*event.BeeperAcceptMessageRequestEventContent] +type MatrixBeeperAIStream = MatrixEventBase[*event.BeeperAIStreamEventContent] type MatrixMarkedUnread = MatrixRoomMeta[*event.MarkedUnreadEventContent] type MatrixMute = MatrixRoomMeta[*event.BeeperMuteEventContent] type MatrixRoomTag = MatrixRoomMeta[*event.TagEventContent] diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 718a5cb2..5c0a7695 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -697,6 +697,8 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * return portal.handleMatrixReceipts(ctx, evt) case event.EphemeralEventTyping: return portal.handleMatrixTyping(ctx, evt) + case event.BeeperEphemeralEventAIStream: + return portal.handleMatrixAIStream(ctx, sender, evt) default: return EventHandlingResultIgnored } @@ -941,6 +943,50 @@ func (portal *Portal) handleMatrixTyping(ctx context.Context, evt *event.Event) return EventHandlingResultSuccess } +func (portal *Portal) handleMatrixAIStream(ctx context.Context, sender *User, evt *event.Event) EventHandlingResult { + log := zerolog.Ctx(ctx) + if sender == nil { + log.Error().Msg("Missing sender for Matrix AI stream event") + return EventHandlingResultIgnored + } + login, _, err := portal.FindPreferredLogin(ctx, sender, true) + if err != nil { + log.Err(err).Msg("Failed to get user login to handle Matrix AI stream event") + return EventHandlingResultFailed.WithMSSError(err) + } + var origSender *OrigSender + if login == nil { + if portal.Relay == nil { + return EventHandlingResultIgnored + } + login = portal.Relay + origSender = &OrigSender{ + User: sender, + UserID: sender.MXID, + } + } + content, ok := evt.Content.Parsed.(*event.BeeperAIStreamEventContent) + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + api, ok := login.Client.(BeeperAIStreamHandlingNetworkAPI) + if !ok { + return EventHandlingResultIgnored.WithMSSError(ErrBeeperAIStreamNotSupported) + } + err = api.HandleMatrixBeeperAIStream(ctx, &MatrixBeeperAIStream{ + Event: evt, + Content: content, + Portal: portal, + OrigSender: origSender, + }) + if err != nil { + log.Err(err).Msg("Failed to handle Matrix AI stream event") + return EventHandlingResultFailed.WithMSSError(err) + } + return EventHandlingResultSuccess.WithMSS() +} + func (portal *Portal) sendTypings(ctx context.Context, userIDs []id.UserID, typing bool) { for _, userID := range userIDs { login, ok := portal.currentlyTypingLogins[userID] diff --git a/client.go b/client.go index 0a43816c..982f7454 100644 --- a/client.go +++ b/client.go @@ -1359,6 +1359,48 @@ func (cli *Client) SendMessageEvent(ctx context.Context, roomID id.RoomID, event return } +// BeeperSendEphemeralEvent sends an ephemeral event into a room using Beeper's unstable endpoint. +// contentJSON should be a value that can be encoded as JSON using json.Marshal. +func (cli *Client) BeeperSendEphemeralEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON any, extra ...ReqSendEvent) (resp *RespSendEvent, err error) { + var req ReqSendEvent + if len(extra) > 0 { + req = extra[0] + } + + var txnID string + if len(req.TransactionID) > 0 { + txnID = req.TransactionID + } else { + txnID = cli.TxnID() + } + + queryParams := map[string]string{} + if req.Timestamp > 0 { + queryParams["ts"] = strconv.FormatInt(req.Timestamp, 10) + } + + if !req.DontEncrypt && cli != nil && cli.Crypto != nil && eventType != event.EventEncrypted { + var isEncrypted bool + isEncrypted, err = cli.StateStore.IsEncrypted(ctx, roomID) + if err != nil { + err = fmt.Errorf("failed to check if room is encrypted: %w", err) + return + } + if isEncrypted { + if contentJSON, err = cli.Crypto.Encrypt(ctx, roomID, eventType, contentJSON); err != nil { + err = fmt.Errorf("failed to encrypt event: %w", err) + return + } + eventType = event.EventEncrypted + } + } + + urlData := ClientURLPath{"unstable", "com.beeper.ephemeral", "rooms", roomID, "ephemeral", eventType.String(), txnID} + urlPath := cli.BuildURLWithQuery(urlData, queryParams) + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, contentJSON, &resp) + return +} + // SendStateEvent sends a state event into a room. See https://spec.matrix.org/v1.16/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey // contentJSON should be a pointer to something that can be encoded as JSON using json.Marshal. func (cli *Client) SendStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON any, extra ...ReqSendEvent) (resp *RespSendEvent, err error) { diff --git a/client_ephemeral_test.go b/client_ephemeral_test.go new file mode 100644 index 00000000..c2846427 --- /dev/null +++ b/client_ephemeral_test.go @@ -0,0 +1,158 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mautrix_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func TestClient_SendEphemeralEvent_UsesUnstablePathTxnAndTS(t *testing.T) { + roomID := id.RoomID("!room:example.com") + evtType := event.Type{Type: "com.example.ephemeral", Class: event.EphemeralEventType} + txnID := "txn-123" + + var gotPath string + var gotQueryTS string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQueryTS = r.URL.Query().Get("ts") + assert.Equal(t, http.MethodPut, r.Method) + _, _ = w.Write([]byte(`{"event_id":"$evt"}`)) + })) + defer ts.Close() + + cli, err := mautrix.NewClient(ts.URL, "", "") + require.NoError(t, err) + + _, err = cli.BeeperSendEphemeralEvent( + context.Background(), + roomID, + evtType, + map[string]any{"foo": "bar"}, + mautrix.ReqSendEvent{TransactionID: txnID, Timestamp: 1234}, + ) + require.NoError(t, err) + + assert.True(t, strings.Contains(gotPath, "/_matrix/client/unstable/com.beeper.ephemeral/rooms/")) + assert.True(t, strings.HasSuffix(gotPath, "/ephemeral/com.example.ephemeral/"+txnID)) + assert.Equal(t, "1234", gotQueryTS) +} + +func TestClient_SendEphemeralEvent_UnsupportedReturnsMUnrecognized(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"errcode":"M_UNRECOGNIZED","error":"Unrecognized endpoint"}`)) + })) + defer ts.Close() + + cli, err := mautrix.NewClient(ts.URL, "", "") + require.NoError(t, err) + + _, err = cli.BeeperSendEphemeralEvent( + context.Background(), + id.RoomID("!room:example.com"), + event.Type{Type: "com.example.ephemeral", Class: event.EphemeralEventType}, + map[string]any{"foo": "bar"}, + ) + require.Error(t, err) + assert.True(t, errors.Is(err, mautrix.MUnrecognized)) +} + +func TestClient_SendEphemeralEvent_EncryptsInEncryptedRooms(t *testing.T) { + roomID := id.RoomID("!room:example.com") + evtType := event.Type{Type: "com.example.ephemeral", Class: event.EphemeralEventType} + txnID := "txn-encrypted" + + stateStore := mautrix.NewMemoryStateStore() + err := stateStore.SetEncryptionEvent(context.Background(), roomID, &event.EncryptionEventContent{ + Algorithm: id.AlgorithmMegolmV1, + }) + require.NoError(t, err) + + fakeCrypto := &fakeCryptoHelper{ + encryptedContent: &event.EncryptedEventContent{ + Algorithm: id.AlgorithmMegolmV1, + MegolmCiphertext: []byte("ciphertext"), + }, + } + + var gotPath string + var gotBody map[string]any + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + assert.Equal(t, http.MethodPut, r.Method) + err := json.NewDecoder(r.Body).Decode(&gotBody) + require.NoError(t, err) + _, _ = w.Write([]byte(`{"event_id":"$evt"}`)) + })) + defer ts.Close() + + cli, err := mautrix.NewClient(ts.URL, "", "") + require.NoError(t, err) + cli.StateStore = stateStore + cli.Crypto = fakeCrypto + + _, err = cli.BeeperSendEphemeralEvent( + context.Background(), + roomID, + evtType, + map[string]any{"foo": "bar"}, + mautrix.ReqSendEvent{TransactionID: txnID}, + ) + require.NoError(t, err) + + assert.True(t, strings.HasSuffix(gotPath, "/ephemeral/m.room.encrypted/"+txnID)) + assert.Equal(t, string(id.AlgorithmMegolmV1), gotBody["algorithm"]) + assert.Equal(t, 1, fakeCrypto.encryptCalls) + assert.Equal(t, roomID, fakeCrypto.lastRoomID) + assert.Equal(t, evtType, fakeCrypto.lastEventType) +} + +type fakeCryptoHelper struct { + encryptCalls int + lastRoomID id.RoomID + lastEventType event.Type + lastEncryptInput any + encryptedContent *event.EncryptedEventContent +} + +func (f *fakeCryptoHelper) Encrypt(_ context.Context, roomID id.RoomID, eventType event.Type, content any) (*event.EncryptedEventContent, error) { + f.encryptCalls++ + f.lastRoomID = roomID + f.lastEventType = eventType + f.lastEncryptInput = content + return f.encryptedContent, nil +} + +func (f *fakeCryptoHelper) Decrypt(context.Context, *event.Event) (*event.Event, error) { + return nil, nil +} + +func (f *fakeCryptoHelper) WaitForSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, time.Duration) bool { + return false +} + +func (f *fakeCryptoHelper) RequestSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, id.UserID, id.DeviceID) { +} + +func (f *fakeCryptoHelper) Init(context.Context) error { + return nil +} diff --git a/crypto/decryptmegolm.go b/crypto/decryptmegolm.go index 9753eabd..457d5a0c 100644 --- a/crypto/decryptmegolm.go +++ b/crypto/decryptmegolm.go @@ -213,6 +213,7 @@ func (mach *OlmMachine) DecryptMegolmEvent(ctx context.Context, evt *event.Event TrustSource: device, ForwardedKeys: forwardedKeys, WasEncrypted: true, + EventSource: evt.Mautrix.EventSource | event.SourceDecrypted, ReceivedAt: evt.Mautrix.ReceivedAt, }, }, nil diff --git a/event/beeper.go b/event/beeper.go index 6de41df6..a1a60b35 100644 --- a/event/beeper.go +++ b/event/beeper.go @@ -214,6 +214,15 @@ func (content *MessageEventContent) RemovePerMessageProfileFallback() { } } +type BeeperAIStreamEventContent struct { + TurnID string `json:"turn_id"` + Seq int `json:"seq"` + Part map[string]any `json:"part"` + TargetEvent id.EventID `json:"target_event,omitempty"` + AgentID string `json:"agent_id,omitempty"` + RelatesTo *RelatesTo `json:"m.relates_to,omitempty"` +} + type BeeperEncodedOrder struct { order int64 suborder int16 diff --git a/event/content.go b/event/content.go index 4aa0593d..814aeec4 100644 --- a/event/content.go +++ b/event/content.go @@ -76,9 +76,11 @@ var TypeMap = map[Type]reflect.Type{ AccountDataMarkedUnread: reflect.TypeOf(MarkedUnreadEventContent{}), AccountDataBeeperMute: reflect.TypeOf(BeeperMuteEventContent{}), - EphemeralEventTyping: reflect.TypeOf(TypingEventContent{}), - EphemeralEventReceipt: reflect.TypeOf(ReceiptEventContent{}), - EphemeralEventPresence: reflect.TypeOf(PresenceEventContent{}), + EphemeralEventTyping: reflect.TypeOf(TypingEventContent{}), + EphemeralEventReceipt: reflect.TypeOf(ReceiptEventContent{}), + EphemeralEventPresence: reflect.TypeOf(PresenceEventContent{}), + EphemeralEventEncrypted: reflect.TypeOf(EncryptedEventContent{}), + BeeperEphemeralEventAIStream: reflect.TypeOf(BeeperAIStreamEventContent{}), InRoomVerificationReady: reflect.TypeOf(VerificationReadyEventContent{}), InRoomVerificationStart: reflect.TypeOf(VerificationStartEventContent{}), diff --git a/event/powerlevels.go b/event/powerlevels.go index 708721f9..668eb6d3 100644 --- a/event/powerlevels.go +++ b/event/powerlevels.go @@ -28,6 +28,9 @@ type PowerLevelsEventContent struct { Events map[string]int `json:"events,omitempty"` EventsDefault int `json:"events_default,omitempty"` + beeperEphemeralLock sync.RWMutex + BeeperEphemeral map[string]int `json:"com.beeper.ephemeral,omitempty"` + Notifications *NotificationPowerLevels `json:"notifications,omitempty"` StateDefaultPtr *int `json:"state_default,omitempty"` @@ -37,6 +40,8 @@ type PowerLevelsEventContent struct { BanPtr *int `json:"ban,omitempty"` RedactPtr *int `json:"redact,omitempty"` + BeeperEphemeralDefaultPtr *int `json:"com.beeper.ephemeral_default,omitempty"` + // This is not a part of power levels, it's added by mautrix-go internally in certain places // in order to detect creator power accurately. CreateEvent *Event `json:"-"` @@ -51,6 +56,7 @@ func (pl *PowerLevelsEventContent) Clone() *PowerLevelsEventContent { UsersDefault: pl.UsersDefault, Events: maps.Clone(pl.Events), EventsDefault: pl.EventsDefault, + BeeperEphemeral: maps.Clone(pl.BeeperEphemeral), StateDefaultPtr: ptr.Clone(pl.StateDefaultPtr), Notifications: pl.Notifications.Clone(), @@ -60,6 +66,8 @@ func (pl *PowerLevelsEventContent) Clone() *PowerLevelsEventContent { BanPtr: ptr.Clone(pl.BanPtr), RedactPtr: ptr.Clone(pl.RedactPtr), + BeeperEphemeralDefaultPtr: ptr.Clone(pl.BeeperEphemeralDefaultPtr), + CreateEvent: pl.CreateEvent, } } @@ -119,6 +127,13 @@ func (pl *PowerLevelsEventContent) StateDefault() int { return 50 } +func (pl *PowerLevelsEventContent) BeeperEphemeralDefault() int { + if pl.BeeperEphemeralDefaultPtr != nil { + return *pl.BeeperEphemeralDefaultPtr + } + return pl.EventsDefault +} + func (pl *PowerLevelsEventContent) GetUserLevel(userID id.UserID) int { if pl.isCreator(userID) { return math.MaxInt @@ -202,6 +217,29 @@ func (pl *PowerLevelsEventContent) GetEventLevel(eventType Type) int { return level } +func (pl *PowerLevelsEventContent) GetBeeperEphemeralLevel(eventType Type) int { + pl.beeperEphemeralLock.RLock() + defer pl.beeperEphemeralLock.RUnlock() + level, ok := pl.BeeperEphemeral[eventType.String()] + if !ok { + return pl.BeeperEphemeralDefault() + } + return level +} + +func (pl *PowerLevelsEventContent) SetBeeperEphemeralLevel(eventType Type, level int) { + pl.beeperEphemeralLock.Lock() + defer pl.beeperEphemeralLock.Unlock() + if level == pl.BeeperEphemeralDefault() { + delete(pl.BeeperEphemeral, eventType.String()) + } else { + if pl.BeeperEphemeral == nil { + pl.BeeperEphemeral = make(map[string]int) + } + pl.BeeperEphemeral[eventType.String()] = level + } +} + func (pl *PowerLevelsEventContent) SetEventLevel(eventType Type, level int) { pl.eventsLock.Lock() defer pl.eventsLock.Unlock() diff --git a/event/powerlevels_ephemeral_test.go b/event/powerlevels_ephemeral_test.go new file mode 100644 index 00000000..f5861583 --- /dev/null +++ b/event/powerlevels_ephemeral_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/event" +) + +func TestPowerLevelsEventContent_BeeperEphemeralDefaultFallsBackToEventsDefault(t *testing.T) { + pl := &event.PowerLevelsEventContent{ + EventsDefault: 45, + } + + assert.Equal(t, 45, pl.BeeperEphemeralDefault()) + + override := 60 + pl.BeeperEphemeralDefaultPtr = &override + assert.Equal(t, 60, pl.BeeperEphemeralDefault()) +} + +func TestPowerLevelsEventContent_GetSetBeeperEphemeralLevel(t *testing.T) { + pl := &event.PowerLevelsEventContent{ + EventsDefault: 25, + } + evtType := event.Type{Type: "com.example.ephemeral", Class: event.EphemeralEventType} + + assert.Equal(t, 25, pl.GetBeeperEphemeralLevel(evtType)) + + pl.SetBeeperEphemeralLevel(evtType, 50) + assert.Equal(t, 50, pl.GetBeeperEphemeralLevel(evtType)) + require.NotNil(t, pl.BeeperEphemeral) + assert.Equal(t, 50, pl.BeeperEphemeral[evtType.String()]) + + pl.SetBeeperEphemeralLevel(evtType, 25) + _, exists := pl.BeeperEphemeral[evtType.String()] + assert.False(t, exists) +} + +func TestPowerLevelsEventContent_CloneCopiesBeeperEphemeralFields(t *testing.T) { + override := 70 + pl := &event.PowerLevelsEventContent{ + EventsDefault: 35, + BeeperEphemeral: map[string]int{"com.example.ephemeral": 90}, + BeeperEphemeralDefaultPtr: &override, + } + + cloned := pl.Clone() + require.NotNil(t, cloned) + require.NotNil(t, cloned.BeeperEphemeralDefaultPtr) + assert.Equal(t, 70, *cloned.BeeperEphemeralDefaultPtr) + assert.Equal(t, 90, cloned.BeeperEphemeral["com.example.ephemeral"]) + + cloned.BeeperEphemeral["com.example.ephemeral"] = 99 + *cloned.BeeperEphemeralDefaultPtr = 71 + + assert.Equal(t, 90, pl.BeeperEphemeral["com.example.ephemeral"]) + assert.Equal(t, 70, *pl.BeeperEphemeralDefaultPtr) +} diff --git a/event/type.go b/event/type.go index f337c127..80b86728 100644 --- a/event/type.go +++ b/event/type.go @@ -115,7 +115,7 @@ func (et *Type) GuessClass() TypeClass { StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type, StateBeeperDisappearingTimer.Type, StateMSC4391BotCommand.Type, StateRoomPolicy.Type, StateUnstableRoomPolicy.Type: return StateEventType - case EphemeralEventReceipt.Type, EphemeralEventTyping.Type, EphemeralEventPresence.Type: + case EphemeralEventReceipt.Type, EphemeralEventTyping.Type, EphemeralEventPresence.Type, BeeperEphemeralEventAIStream.Type: return EphemeralEventType case AccountDataDirectChats.Type, AccountDataPushRules.Type, AccountDataRoomTags.Type, AccountDataFullyRead.Type, AccountDataIgnoredUserList.Type, AccountDataMarkedUnread.Type, @@ -250,9 +250,11 @@ var ( // Ephemeral events var ( - EphemeralEventReceipt = Type{"m.receipt", EphemeralEventType} - EphemeralEventTyping = Type{"m.typing", EphemeralEventType} - EphemeralEventPresence = Type{"m.presence", EphemeralEventType} + EphemeralEventReceipt = Type{"m.receipt", EphemeralEventType} + EphemeralEventTyping = Type{"m.typing", EphemeralEventType} + EphemeralEventPresence = Type{"m.presence", EphemeralEventType} + EphemeralEventEncrypted = Type{"m.room.encrypted", EphemeralEventType} + BeeperEphemeralEventAIStream = Type{"com.beeper.ai.stream_event", EphemeralEventType} ) // Account data events diff --git a/versions.go b/versions.go index 8ae82a06..69233730 100644 --- a/versions.go +++ b/versions.go @@ -80,6 +80,7 @@ var ( BeeperFeatureAccountDataMute = UnstableFeature{UnstableFlag: "com.beeper.account_data_mute"} BeeperFeatureInboxState = UnstableFeature{UnstableFlag: "com.beeper.inbox_state"} BeeperFeatureArbitraryMemberChange = UnstableFeature{UnstableFlag: "com.beeper.arbitrary_member_change"} + BeeperFeatureEphemeralEvents = UnstableFeature{UnstableFlag: "com.beeper.ephemeral"} ) func (versions *RespVersions) Supports(feature UnstableFeature) bool { From ed9820356e983f9c6489e7e1bb4b75514cf8f3e6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 4 Mar 2026 13:58:07 +0200 Subject: [PATCH 567/581] bridgev2/portalreid: try to fix deadlock when racing with room creation --- bridgev2/portal.go | 3 +++ bridgev2/portalreid.go | 28 +++++++++++++++++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 5c0a7695..8df41644 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -5363,6 +5363,9 @@ func (portal *Portal) removeInPortalCache(ctx context.Context) { } func (portal *Portal) unlockedDelete(ctx context.Context) error { + if portal.deleted.IsSet() { + return nil + } err := portal.safeDBDelete(ctx) if err != nil { return err diff --git a/bridgev2/portalreid.go b/bridgev2/portalreid.go index 6a5091fc..c976d97c 100644 --- a/bridgev2/portalreid.go +++ b/bridgev2/portalreid.go @@ -38,17 +38,20 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta Stringer("target_portal_key", target). Logger() ctx = log.WithContext(ctx) - if !br.cacheLock.TryLock() { - log.Debug().Msg("Waiting for cache lock") - br.cacheLock.Lock() - log.Debug().Msg("Acquired cache lock after waiting") - } defer func() { - br.cacheLock.Unlock() log.Debug().Msg("Finished handling portal re-ID") }() + acquireCacheLock := func() { + if !br.cacheLock.TryLock() { + log.Debug().Msg("Waiting for global cache lock") + br.cacheLock.Lock() + log.Debug().Msg("Acquired global cache lock after waiting") + } else { + log.Trace().Msg("Acquired global cache lock without waiting") + } + } log.Debug().Msg("Re-ID'ing portal") - sourcePortal, err := br.UnlockedGetPortalByKey(ctx, source, true) + sourcePortal, err := br.GetExistingPortalByKey(ctx, source) if err != nil { return ReIDResultError, nil, fmt.Errorf("failed to get source portal: %w", err) } else if sourcePortal == nil { @@ -75,18 +78,24 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta log.UpdateContext(func(c zerolog.Context) zerolog.Context { return c.Stringer("source_portal_mxid", sourcePortal.MXID) }) + + acquireCacheLock() targetPortal, err := br.UnlockedGetPortalByKey(ctx, target, true) if err != nil { + br.cacheLock.Unlock() return ReIDResultError, nil, fmt.Errorf("failed to get target portal: %w", err) } if targetPortal == nil { log.Info().Msg("Target portal doesn't exist, re-ID'ing source portal") err = sourcePortal.unlockedReID(ctx, target) + br.cacheLock.Unlock() if err != nil { return ReIDResultError, nil, fmt.Errorf("failed to re-ID source portal: %w", err) } return ReIDResultSourceReIDd, sourcePortal, nil } + br.cacheLock.Unlock() + if !targetPortal.roomCreateLock.TryLock() { if cancelCreate := targetPortal.cancelRoomCreate.Swap(nil); cancelCreate != nil { (*cancelCreate)() @@ -98,6 +107,8 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta defer targetPortal.roomCreateLock.Unlock() if targetPortal.MXID == "" { log.Info().Msg("Target portal row exists, but doesn't have a Matrix room. Deleting target portal row and re-ID'ing source portal") + acquireCacheLock() + defer br.cacheLock.Unlock() err = targetPortal.unlockedDelete(ctx) if err != nil { return ReIDResultError, nil, fmt.Errorf("failed to delete target portal: %w", err) @@ -112,6 +123,9 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta return c.Stringer("target_portal_mxid", targetPortal.MXID) }) log.Info().Msg("Both target and source portals have Matrix rooms, tombstoning source portal") + sourcePortal.removeInPortalCache(ctx) + acquireCacheLock() + defer br.cacheLock.Unlock() err = sourcePortal.unlockedDelete(ctx) if err != nil { return ReIDResultError, nil, fmt.Errorf("failed to delete source portal row: %w", err) From ed6dbcaaeeeb8707c643c08a2f5990caa954a491 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 4 Mar 2026 22:50:43 +0200 Subject: [PATCH 568/581] client: log content length when uploading to external url --- client.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client.go b/client.go index 982f7454..0a9704a9 100644 --- a/client.go +++ b/client.go @@ -2041,7 +2041,10 @@ type ReqUploadMedia struct { } func (cli *Client) tryUploadMediaToURL(ctx context.Context, url, contentType string, content io.Reader, contentLength int64) (*http.Response, error) { - cli.Log.Debug().Str("url", url).Msg("Uploading media to external URL") + cli.Log.Debug(). + Str("url", url). + Int64("content_length", contentLength). + Msg("Uploading media to external URL") req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, content) if err != nil { return nil, err From 0f6a779dd2b55916ee4a2b27a46d2bd6e0f9d592 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 5 Mar 2026 11:59:11 +0200 Subject: [PATCH 569/581] readme: update --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ac41ca78..b1a2edf8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # mautrix-go [![GoDoc](https://pkg.go.dev/badge/maunium.net/go/mautrix)](https://pkg.go.dev/maunium.net/go/mautrix) -A Golang Matrix framework. Used by [gomuks](https://matrix.org/docs/projects/client/gomuks), -[go-neb](https://github.com/matrix-org/go-neb), [mautrix-whatsapp](https://github.com/mautrix/whatsapp) +A Golang Matrix framework. Used by [gomuks](https://gomuks.app), +[go-neb](https://github.com/matrix-org/go-neb), +[mautrix-whatsapp](https://github.com/mautrix/whatsapp) and others. Matrix room: [`#go:maunium.net`](https://matrix.to/#/#go:maunium.net) @@ -13,9 +14,10 @@ The original project is licensed under [Apache 2.0](https://github.com/matrix-or In addition to the basic client API features the original project has, this framework also has: * Appservice support (Intent API like mautrix-python, room state storage, etc) -* End-to-end encryption support (incl. interactive SAS verification) +* End-to-end encryption support (incl. key backup, cross-signing, interactive verification, etc) * High-level module for building puppeting bridges -* High-level module for building chat clients +* Partial federation module (making requests, PDU processing and event authorization) +* A media proxy server which can be used to expose anything as a Matrix media repo * Wrapper functions for the Synapse admin API * Structs for parsing event content * Helpers for parsing and generating Matrix HTML From 7836f35a1a7431a3eb7f1a09697d324058dbde01 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 5 Mar 2026 23:57:35 +0200 Subject: [PATCH 570/581] bridgev2/portal: fix third matrix reaction not removing previous one on single-reaction networks --- bridgev2/portal.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 8df41644..d8acf88e 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1632,6 +1632,10 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi // Keep n-1 previous reactions and remove the rest react.ExistingReactionsToKeep = allReactions[:preResp.MaxReactions-1] for _, oldReaction := range allReactions[preResp.MaxReactions-1:] { + if existing != nil && oldReaction.EmojiID == existing.EmojiID { + // Don't double-delete on networks that only allow one emoji + continue + } // Intentionally defer in a loop, there won't be that many items, // and we want all of them to be done after this function completes successfully //goland:noinspection GoDeferInLoop From 7a53f3928a01fa646cfdd5d1a950e04a687e09cb Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 6 Mar 2026 14:25:52 +0200 Subject: [PATCH 571/581] bridgev2/portal: redact conflicting reactions before sending MSS success --- bridgev2/portal.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index d8acf88e..48a17e91 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1587,6 +1587,12 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi if portal.Bridge.Config.OutgoingMessageReID { deterministicID = portal.Bridge.Matrix.GenerateReactionEventID(portal.MXID, reactionTarget, preResp.SenderID, preResp.EmojiID) } + defer func() { + // Do this in a defer so that it happens after any potential defer calls to removeOutdatedReaction + if handleRes.Success { + portal.sendSuccessStatus(ctx, evt, 0, deterministicID) + } + }() removeOutdatedReaction := func(oldReact *database.Reaction, deleteDB bool) { if !handleRes.Success { return @@ -1684,7 +1690,6 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi if err != nil { log.Err(err).Msg("Failed to save reaction to database") } - portal.sendSuccessStatus(ctx, evt, 0, deterministicID) return EventHandlingResultSuccess.WithEventID(deterministicID) } From 531822f6dcf54f82f1a93156c670ed33f8277b2b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 6 Mar 2026 16:08:15 +0200 Subject: [PATCH 572/581] bridgev2/config: add limit for unknown error auto-reconnects --- bridgev2/bridgeconfig/config.go | 47 +++++++++++----------- bridgev2/bridgeconfig/upgrade.go | 1 + bridgev2/bridgestate.go | 10 ++++- bridgev2/matrix/mxmain/example-config.yaml | 3 ++ 4 files changed, 37 insertions(+), 24 deletions(-) diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index 8b9aa019..c301b8d0 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -62,29 +62,30 @@ type CleanupOnLogouts struct { } type BridgeConfig struct { - CommandPrefix string `yaml:"command_prefix"` - PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"` - PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"` - AsyncEvents bool `yaml:"async_events"` - SplitPortals bool `yaml:"split_portals"` - ResendBridgeInfo bool `yaml:"resend_bridge_info"` - NoBridgeInfoStateKey bool `yaml:"no_bridge_info_state_key"` - BridgeStatusNotices string `yaml:"bridge_status_notices"` - UnknownErrorAutoReconnect time.Duration `yaml:"unknown_error_auto_reconnect"` - BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"` - BridgeNotices bool `yaml:"bridge_notices"` - TagOnlyOnCreate bool `yaml:"tag_only_on_create"` - OnlyBridgeTags []event.RoomTag `yaml:"only_bridge_tags"` - MuteOnlyOnCreate bool `yaml:"mute_only_on_create"` - DeduplicateMatrixMessages bool `yaml:"deduplicate_matrix_messages"` - CrossRoomReplies bool `yaml:"cross_room_replies"` - OutgoingMessageReID bool `yaml:"outgoing_message_re_id"` - RevertFailedStateChanges bool `yaml:"revert_failed_state_changes"` - KickMatrixUsers bool `yaml:"kick_matrix_users"` - CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"` - Relay RelayConfig `yaml:"relay"` - Permissions PermissionConfig `yaml:"permissions"` - Backfill BackfillConfig `yaml:"backfill"` + CommandPrefix string `yaml:"command_prefix"` + PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"` + PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"` + AsyncEvents bool `yaml:"async_events"` + SplitPortals bool `yaml:"split_portals"` + ResendBridgeInfo bool `yaml:"resend_bridge_info"` + NoBridgeInfoStateKey bool `yaml:"no_bridge_info_state_key"` + BridgeStatusNotices string `yaml:"bridge_status_notices"` + UnknownErrorAutoReconnect time.Duration `yaml:"unknown_error_auto_reconnect"` + UnknownErrorMaxAutoReconnects int `yaml:"unknown_error_max_auto_reconnects"` + BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"` + BridgeNotices bool `yaml:"bridge_notices"` + TagOnlyOnCreate bool `yaml:"tag_only_on_create"` + OnlyBridgeTags []event.RoomTag `yaml:"only_bridge_tags"` + MuteOnlyOnCreate bool `yaml:"mute_only_on_create"` + DeduplicateMatrixMessages bool `yaml:"deduplicate_matrix_messages"` + CrossRoomReplies bool `yaml:"cross_room_replies"` + OutgoingMessageReID bool `yaml:"outgoing_message_re_id"` + RevertFailedStateChanges bool `yaml:"revert_failed_state_changes"` + KickMatrixUsers bool `yaml:"kick_matrix_users"` + CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"` + Relay RelayConfig `yaml:"relay"` + Permissions PermissionConfig `yaml:"permissions"` + Backfill BackfillConfig `yaml:"backfill"` } type MatrixConfig struct { diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index a0278672..ef51335e 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -33,6 +33,7 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.Bool, "bridge", "no_bridge_info_state_key") helper.Copy(up.Str|up.Null, "bridge", "bridge_status_notices") helper.Copy(up.Str|up.Int|up.Null, "bridge", "unknown_error_auto_reconnect") + helper.Copy(up.Int, "bridge", "unknown_error_max_auto_reconnects") helper.Copy(up.Bool, "bridge", "bridge_matrix_leave") helper.Copy(up.Bool, "bridge", "bridge_notices") helper.Copy(up.Bool, "bridge", "tag_only_on_create") diff --git a/bridgev2/bridgestate.go b/bridgev2/bridgestate.go index babbccab..96d9fd5c 100644 --- a/bridgev2/bridgestate.go +++ b/bridgev2/bridgestate.go @@ -37,6 +37,8 @@ type BridgeStateQueue struct { stopChan chan struct{} stopReconnect atomic.Pointer[context.CancelFunc] + + unknownErrorReconnects int } func (br *Bridge) SendGlobalBridgeState(state status.BridgeState) { @@ -192,8 +194,14 @@ func (bsq *BridgeStateQueue) unknownErrorReconnect(triggeredBy status.BridgeStat } else if prevUnsent.StateEvent != status.StateUnknownError || prev.StateEvent != status.StateUnknownError { log.Debug().Msg("Not reconnecting as the previous state was not an unknown error") return + } else if bsq.unknownErrorReconnects > bsq.bridge.Config.UnknownErrorMaxAutoReconnects { + log.Warn().Msg("Not reconnecting as the maximum number of unknown error reconnects has been reached") + return } - log.Info().Msg("Disconnecting and reconnecting login due to unknown error") + bsq.unknownErrorReconnects++ + log.Info(). + Int("reconnect_num", bsq.unknownErrorReconnects). + Msg("Disconnecting and reconnecting login due to unknown error") bsq.login.Disconnect() log.Debug().Msg("Disconnection finished, recreating client and reconnecting") err := bsq.login.recreateClient(ctx) diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index b0e83696..75d0edbf 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -29,6 +29,9 @@ bridge: # How long after an unknown error should the bridge attempt a full reconnect? # Must be at least 1 minute. The bridge will add an extra ±20% jitter to this value. unknown_error_auto_reconnect: null + # Maximum number of times to do the auto-reconnect above. + # The counter is per login, but is never reset except on logout and restart. + unknown_error_max_auto_reconnects: 10 # Should leaving Matrix rooms be bridged as leaving groups on the remote network? bridge_matrix_leave: false From df24fb96e2e5bcbd451bc0b9340338415075519f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 6 Mar 2026 20:58:18 +0200 Subject: [PATCH 573/581] client: update MSC2666 implementation --- client.go | 9 +++++++-- responses.go | 1 + versions.go | 3 ++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/client.go b/client.go index 0a9704a9..fbb9333f 100644 --- a/client.go +++ b/client.go @@ -1158,7 +1158,9 @@ func (cli *Client) SearchUserDirectory(ctx context.Context, query string, limit } func (cli *Client) GetMutualRooms(ctx context.Context, otherUserID id.UserID, extras ...ReqMutualRooms) (resp *RespMutualRooms, err error) { - if cli.SpecVersions != nil && !cli.SpecVersions.Supports(FeatureMutualRooms) { + supportsStable := cli.SpecVersions.Supports(FeatureStableMutualRooms) + supportsUnstable := cli.SpecVersions.Supports(FeatureUnstableMutualRooms) + if cli.SpecVersions != nil && !supportsUnstable && !supportsStable { err = fmt.Errorf("server does not support fetching mutual rooms") return } @@ -1168,7 +1170,10 @@ func (cli *Client) GetMutualRooms(ctx context.Context, otherUserID id.UserID, ex if len(extras) > 0 { query["from"] = extras[0].From } - urlPath := cli.BuildURLWithQuery(ClientURLPath{"unstable", "uk.half-shot.msc2666", "user", "mutual_rooms"}, query) + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v1", "user", "mutual_rooms"}, query) + if !supportsStable && supportsUnstable { + urlPath = cli.BuildURLWithQuery(ClientURLPath{"unstable", "uk.half-shot.msc2666", "user", "mutual_rooms"}, query) + } _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) return } diff --git a/responses.go b/responses.go index 20286431..4fbe1fbc 100644 --- a/responses.go +++ b/responses.go @@ -258,6 +258,7 @@ func (r *UserDirectoryEntry) MarshalJSON() ([]byte, error) { type RespMutualRooms struct { Joined []id.RoomID `json:"joined"` NextBatch string `json:"next_batch,omitempty"` + Count int `json:"count,omitempty"` } type RespRoomSummary struct { diff --git a/versions.go b/versions.go index 69233730..61b2e4ea 100644 --- a/versions.go +++ b/versions.go @@ -63,7 +63,8 @@ var ( FeatureAsyncUploads = UnstableFeature{UnstableFlag: "fi.mau.msc2246.stable", SpecVersion: SpecV17} FeatureAppservicePing = UnstableFeature{UnstableFlag: "fi.mau.msc2659.stable", SpecVersion: SpecV17} FeatureAuthenticatedMedia = UnstableFeature{UnstableFlag: "org.matrix.msc3916.stable", SpecVersion: SpecV111} - FeatureMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms"} + FeatureUnstableMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms"} + FeatureStableMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms.stable" /*, SpecVersion: SpecV118*/} FeatureUserRedaction = UnstableFeature{UnstableFlag: "org.matrix.msc4194"} FeatureViewRedactedContent = UnstableFeature{UnstableFlag: "fi.mau.msc2815"} FeatureUnstableAccountModeration = UnstableFeature{UnstableFlag: "uk.timedout.msc4323"} From c107c25d078ee2de4304baa5b2fb109c70edae3d Mon Sep 17 00:00:00 2001 From: timedout Date: Sat, 7 Mar 2026 14:26:42 +0000 Subject: [PATCH 574/581] client: add type parameter to UIA request bodies (#469) --- appservice/intent.go | 2 +- client.go | 14 +++++++------- crypto/cross_sign_key.go | 2 +- mockserver/mockserver.go | 2 +- requests.go | 16 ++++++++-------- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/appservice/intent.go b/appservice/intent.go index 0ec10b77..5d43f190 100644 --- a/appservice/intent.go +++ b/appservice/intent.go @@ -51,7 +51,7 @@ func (as *AppService) NewIntentAPI(localpart string) *IntentAPI { } func (intent *IntentAPI) Register(ctx context.Context) error { - _, err := intent.Client.MakeRequest(ctx, http.MethodPost, intent.BuildClientURL("v3", "register"), &mautrix.ReqRegister{ + _, err := intent.Client.MakeRequest(ctx, http.MethodPost, intent.BuildClientURL("v3", "register"), &mautrix.ReqRegister[any]{ Username: intent.Localpart, Type: mautrix.AuthTypeAppservice, InhibitLogin: true, diff --git a/client.go b/client.go index fbb9333f..7062d9b9 100644 --- a/client.go +++ b/client.go @@ -918,7 +918,7 @@ func (cli *Client) RegisterAvailable(ctx context.Context, username string) (resp return } -func (cli *Client) register(ctx context.Context, url string, req *ReqRegister) (resp *RespRegister, uiaResp *RespUserInteractive, err error) { +func (cli *Client) register(ctx context.Context, url string, req *ReqRegister[any]) (resp *RespRegister, uiaResp *RespUserInteractive, err error) { var bodyBytes []byte bodyBytes, err = cli.MakeFullRequest(ctx, FullRequest{ Method: http.MethodPost, @@ -942,7 +942,7 @@ func (cli *Client) register(ctx context.Context, url string, req *ReqRegister) ( // Register makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3register // // Registers with kind=user. For kind=guest, see RegisterGuest. -func (cli *Client) Register(ctx context.Context, req *ReqRegister) (*RespRegister, *RespUserInteractive, error) { +func (cli *Client) Register(ctx context.Context, req *ReqRegister[any]) (*RespRegister, *RespUserInteractive, error) { u := cli.BuildClientURL("v3", "register") return cli.register(ctx, u, req) } @@ -951,7 +951,7 @@ func (cli *Client) Register(ctx context.Context, req *ReqRegister) (*RespRegiste // with kind=guest. // // For kind=user, see Register. -func (cli *Client) RegisterGuest(ctx context.Context, req *ReqRegister) (*RespRegister, *RespUserInteractive, error) { +func (cli *Client) RegisterGuest(ctx context.Context, req *ReqRegister[any]) (*RespRegister, *RespUserInteractive, error) { query := map[string]string{ "kind": "guest", } @@ -974,7 +974,7 @@ func (cli *Client) RegisterGuest(ctx context.Context, req *ReqRegister) (*RespRe // panic(err) // } // token := res.AccessToken -func (cli *Client) RegisterDummy(ctx context.Context, req *ReqRegister) (*RespRegister, error) { +func (cli *Client) RegisterDummy(ctx context.Context, req *ReqRegister[any]) (*RespRegister, error) { _, uia, err := cli.Register(ctx, req) if err != nil && uia == nil { return nil, err @@ -2687,13 +2687,13 @@ func (cli *Client) SetDeviceInfo(ctx context.Context, deviceID id.DeviceID, req return err } -func (cli *Client) DeleteDevice(ctx context.Context, deviceID id.DeviceID, req *ReqDeleteDevice) error { +func (cli *Client) DeleteDevice(ctx context.Context, deviceID id.DeviceID, req *ReqDeleteDevice[any]) error { urlPath := cli.BuildClientURL("v3", "devices", deviceID) _, err := cli.MakeRequest(ctx, http.MethodDelete, urlPath, req, nil) return err } -func (cli *Client) DeleteDevices(ctx context.Context, req *ReqDeleteDevices) error { +func (cli *Client) DeleteDevices(ctx context.Context, req *ReqDeleteDevices[any]) error { urlPath := cli.BuildClientURL("v3", "delete_devices") _, err := cli.MakeRequest(ctx, http.MethodPost, urlPath, req, nil) return err @@ -2704,7 +2704,7 @@ type UIACallback = func(*RespUserInteractive) interface{} // UploadCrossSigningKeys uploads the given cross-signing keys to the server. // Because the endpoint requires user-interactive authentication a callback must be provided that, // given the UI auth parameters, produces the required result (or nil to end the flow). -func (cli *Client) UploadCrossSigningKeys(ctx context.Context, keys *UploadCrossSigningKeysReq, uiaCallback UIACallback) error { +func (cli *Client) UploadCrossSigningKeys(ctx context.Context, keys *UploadCrossSigningKeysReq[any], uiaCallback UIACallback) error { content, err := cli.MakeFullRequest(ctx, FullRequest{ Method: http.MethodPost, URL: cli.BuildClientURL("v3", "keys", "device_signing", "upload"), diff --git a/crypto/cross_sign_key.go b/crypto/cross_sign_key.go index 4094f695..5d9bf5b3 100644 --- a/crypto/cross_sign_key.go +++ b/crypto/cross_sign_key.go @@ -135,7 +135,7 @@ func (mach *OlmMachine) PublishCrossSigningKeys(ctx context.Context, keys *Cross } userKey.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, keys.MasterKey.PublicKey().String(), userSig) - err = mach.Client.UploadCrossSigningKeys(ctx, &mautrix.UploadCrossSigningKeysReq{ + err = mach.Client.UploadCrossSigningKeys(ctx, &mautrix.UploadCrossSigningKeysReq[any]{ Master: masterKey, SelfSigning: selfKey, UserSigning: userKey, diff --git a/mockserver/mockserver.go b/mockserver/mockserver.go index e52c387a..507c24a5 100644 --- a/mockserver/mockserver.go +++ b/mockserver/mockserver.go @@ -231,7 +231,7 @@ func (ms *MockServer) postKeysUpload(w http.ResponseWriter, r *http.Request) { } func (ms *MockServer) postDeviceSigningUpload(w http.ResponseWriter, r *http.Request) { - var req mautrix.UploadCrossSigningKeysReq + var req mautrix.UploadCrossSigningKeysReq[any] mustDecode(r, &req) userID := ms.getUserID(r).UserID diff --git a/requests.go b/requests.go index 397d30de..cc8b7266 100644 --- a/requests.go +++ b/requests.go @@ -66,14 +66,14 @@ const ( ) // ReqRegister is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3register -type ReqRegister struct { +type ReqRegister[UIAType any] struct { Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` DeviceID id.DeviceID `json:"device_id,omitempty"` InitialDeviceDisplayName string `json:"initial_device_display_name,omitempty"` InhibitLogin bool `json:"inhibit_login,omitempty"` RefreshToken bool `json:"refresh_token,omitempty"` - Auth interface{} `json:"auth,omitempty"` + Auth UIAType `json:"auth,omitempty"` // Type for registration, only used for appservice user registrations // https://spec.matrix.org/v1.2/application-service-api/#server-admin-style-permissions @@ -320,11 +320,11 @@ func (csk *CrossSigningKeys) FirstKey() id.Ed25519 { return "" } -type UploadCrossSigningKeysReq struct { +type UploadCrossSigningKeysReq[UIAType any] struct { Master CrossSigningKeys `json:"master_key"` SelfSigning CrossSigningKeys `json:"self_signing_key"` UserSigning CrossSigningKeys `json:"user_signing_key"` - Auth interface{} `json:"auth,omitempty"` + Auth UIAType `json:"auth,omitempty"` } type KeyMap map[id.DeviceKeyID]string @@ -392,14 +392,14 @@ type ReqDeviceInfo struct { } // ReqDeleteDevice is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#delete_matrixclientv3devicesdeviceid -type ReqDeleteDevice struct { - Auth interface{} `json:"auth,omitempty"` +type ReqDeleteDevice[UIAType any] struct { + Auth UIAType `json:"auth,omitempty"` } // ReqDeleteDevices is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3delete_devices -type ReqDeleteDevices struct { +type ReqDeleteDevices[UIAType any] struct { Devices []id.DeviceID `json:"devices"` - Auth interface{} `json:"auth,omitempty"` + Auth UIAType `json:"auth,omitempty"` } type ReqPutPushRule struct { From c243dad24a9cea4811cdf54b35c2df92f0428cf1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 9 Mar 2026 14:26:55 +0200 Subject: [PATCH 575/581] bridgev2/portal: include portal receiver in logs --- bridgev2/portal.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 48a17e91..155ca52b 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -169,7 +169,9 @@ func (br *Bridge) loadPortal(ctx context.Context, dbPortal *database.Portal, que } func (portal *Portal) updateLogger() { - logWith := portal.Bridge.Log.With().Str("portal_id", string(portal.ID)) + logWith := portal.Bridge.Log.With(). + Str("portal_id", string(portal.ID)). + Str("portal_receiver", string(portal.Receiver)) if portal.MXID != "" { logWith = logWith.Stringer("portal_mxid", portal.MXID) } From 8fb92239dc0a96ee73a5483dbc5ea1e2890acae9 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 10 Mar 2026 13:00:00 +0200 Subject: [PATCH 576/581] bridgev2: fix bugs with threads --- bridgev2/database/message.go | 4 ++-- bridgev2/portal.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bridgev2/database/message.go b/bridgev2/database/message.go index 43f33666..4fd599a8 100644 --- a/bridgev2/database/message.go +++ b/bridgev2/database/message.go @@ -68,8 +68,8 @@ const ( getFirstMessagePartByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 ORDER BY part_id ASC LIMIT 1` getMessagesBetweenTimeQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND timestamp>$4 AND timestamp<=$5` getOldestMessageInPortal = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 ORDER BY timestamp ASC, part_id ASC LIMIT 1` - getFirstMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY timestamp ASC, part_id ASC LIMIT 1` - getLastMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY timestamp DESC, part_id DESC LIMIT 1` + getFirstMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY thread_root_id NULLS FIRST, timestamp ASC, part_id ASC LIMIT 1` + getLastMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY thread_root_id NULLS LAST, timestamp DESC, part_id DESC LIMIT 1` getLastNInPortal = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 ORDER BY timestamp DESC, part_id DESC LIMIT $4` getLastMessagePartAtOrBeforeTimeQuery = getMessageBaseQuery + `WHERE bridge_id = $1 AND room_id=$2 AND room_receiver=$3 AND timestamp<=$4 ORDER BY timestamp DESC, part_id DESC LIMIT 1` diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 155ca52b..16aa703b 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -2763,7 +2763,7 @@ func (portal *Portal) getRelationMeta( log.Err(err).Msg("Failed to get last thread message from database") } if prevThreadEvent == nil { - prevThreadEvent = threadRoot + prevThreadEvent = ptr.Clone(threadRoot) } } return From 92cfc0095df2b3621d6dd7830d8e98d058f18bca Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 13 Mar 2026 16:24:31 +0200 Subject: [PATCH 577/581] bridgev2: add support for custom profile fields for ghosts (#462) --- bridgev2/bridgeconfig/config.go | 13 ++-- bridgev2/bridgeconfig/upgrade.go | 1 + bridgev2/database/ghost.go | 68 +++++++++++++++++-- bridgev2/database/upgrades/00-latest.sql | 3 +- .../upgrades/27-ghost-extra-profile.sql | 2 + bridgev2/ghost.go | 48 ++++++++----- bridgev2/matrix/connector.go | 2 + bridgev2/matrix/intent.go | 61 +++++++++++++++-- bridgev2/matrix/mxmain/example-config.yaml | 3 + bridgev2/matrixinterface.go | 1 + 10 files changed, 169 insertions(+), 33 deletions(-) create mode 100644 bridgev2/database/upgrades/27-ghost-extra-profile.sql diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index c301b8d0..bd6b9c06 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -89,12 +89,13 @@ type BridgeConfig struct { } type MatrixConfig struct { - MessageStatusEvents bool `yaml:"message_status_events"` - DeliveryReceipts bool `yaml:"delivery_receipts"` - MessageErrorNotices bool `yaml:"message_error_notices"` - SyncDirectChatList bool `yaml:"sync_direct_chat_list"` - FederateRooms bool `yaml:"federate_rooms"` - UploadFileThreshold int64 `yaml:"upload_file_threshold"` + MessageStatusEvents bool `yaml:"message_status_events"` + DeliveryReceipts bool `yaml:"delivery_receipts"` + MessageErrorNotices bool `yaml:"message_error_notices"` + SyncDirectChatList bool `yaml:"sync_direct_chat_list"` + FederateRooms bool `yaml:"federate_rooms"` + UploadFileThreshold int64 `yaml:"upload_file_threshold"` + GhostExtraProfileInfo bool `yaml:"ghost_extra_profile_info"` } type AnalyticsConfig struct { diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index ef51335e..92515ea0 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -101,6 +101,7 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.Bool, "matrix", "sync_direct_chat_list") helper.Copy(up.Bool, "matrix", "federate_rooms") helper.Copy(up.Int, "matrix", "upload_file_threshold") + helper.Copy(up.Bool, "matrix", "ghost_extra_profile_info") helper.Copy(up.Str|up.Null, "analytics", "token") helper.Copy(up.Str|up.Null, "analytics", "url") diff --git a/bridgev2/database/ghost.go b/bridgev2/database/ghost.go index c32929ad..16af35ca 100644 --- a/bridgev2/database/ghost.go +++ b/bridgev2/database/ghost.go @@ -7,12 +7,17 @@ package database import ( + "bytes" "context" "encoding/hex" + "encoding/json" + "fmt" "go.mau.fi/util/dbutil" + "go.mau.fi/util/exerrors" "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/crypto/canonicaljson" "maunium.net/go/mautrix/id" ) @@ -22,6 +27,55 @@ type GhostQuery struct { *dbutil.QueryHelper[*Ghost] } +type ExtraProfile map[string]json.RawMessage + +func (ep *ExtraProfile) Set(key string, value any) error { + if key == "displayname" || key == "avatar_url" { + return fmt.Errorf("cannot set reserved profile key %q", key) + } + marshaled, err := json.Marshal(value) + if err != nil { + return err + } + if *ep == nil { + *ep = make(ExtraProfile) + } + (*ep)[key] = canonicaljson.CanonicalJSONAssumeValid(marshaled) + return nil +} + +func (ep *ExtraProfile) With(key string, value any) *ExtraProfile { + exerrors.PanicIfNotNil(ep.Set(key, value)) + return ep +} + +func canonicalizeIfObject(data json.RawMessage) json.RawMessage { + if len(data) > 0 && (data[0] == '{' || data[0] == '[') { + return canonicaljson.CanonicalJSONAssumeValid(data) + } + return data +} + +func (ep *ExtraProfile) CopyTo(dest *ExtraProfile) (changed bool) { + if len(*ep) == 0 { + return + } + if *dest == nil { + *dest = make(ExtraProfile) + } + for key, val := range *ep { + if key == "displayname" || key == "avatar_url" { + continue + } + existing, exists := (*dest)[key] + if !exists || !bytes.Equal(canonicalizeIfObject(existing), val) { + (*dest)[key] = val + changed = true + } + } + return +} + type Ghost struct { BridgeID networkid.BridgeID ID networkid.UserID @@ -35,13 +89,14 @@ type Ghost struct { ContactInfoSet bool IsBot bool Identifiers []string + ExtraProfile ExtraProfile Metadata any } const ( getGhostBaseQuery = ` SELECT bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, - name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata + name_set, avatar_set, contact_info_set, is_bot, identifiers, extra_profile, metadata FROM ghost ` getGhostByIDQuery = getGhostBaseQuery + `WHERE bridge_id=$1 AND id=$2` @@ -49,13 +104,14 @@ const ( insertGhostQuery = ` INSERT INTO ghost ( bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, - name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata + name_set, avatar_set, contact_info_set, is_bot, identifiers, extra_profile, metadata ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ` updateGhostQuery = ` UPDATE ghost SET name=$3, avatar_id=$4, avatar_hash=$5, avatar_mxc=$6, - name_set=$7, avatar_set=$8, contact_info_set=$9, is_bot=$10, identifiers=$11, metadata=$12 + name_set=$7, avatar_set=$8, contact_info_set=$9, is_bot=$10, + identifiers=$11, extra_profile=$12, metadata=$13 WHERE bridge_id=$1 AND id=$2 ` ) @@ -86,7 +142,7 @@ func (g *Ghost) Scan(row dbutil.Scannable) (*Ghost, error) { &g.BridgeID, &g.ID, &g.Name, &g.AvatarID, &avatarHash, &g.AvatarMXC, &g.NameSet, &g.AvatarSet, &g.ContactInfoSet, &g.IsBot, - dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: g.Metadata}, + dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: &g.ExtraProfile}, dbutil.JSON{Data: g.Metadata}, ) if err != nil { return nil, err @@ -116,6 +172,6 @@ func (g *Ghost) sqlVariables() []any { g.BridgeID, g.ID, g.Name, g.AvatarID, avatarHash, g.AvatarMXC, g.NameSet, g.AvatarSet, g.ContactInfoSet, g.IsBot, - dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: g.Metadata}, + dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: g.ExtraProfile}, dbutil.JSON{Data: g.Metadata}, } } diff --git a/bridgev2/database/upgrades/00-latest.sql b/bridgev2/database/upgrades/00-latest.sql index b193d314..6092dc24 100644 --- a/bridgev2/database/upgrades/00-latest.sql +++ b/bridgev2/database/upgrades/00-latest.sql @@ -1,4 +1,4 @@ --- v0 -> v26 (compatible with v9+): Latest revision +-- v0 -> v27 (compatible with v9+): Latest revision CREATE TABLE "user" ( bridge_id TEXT NOT NULL, mxid TEXT NOT NULL, @@ -80,6 +80,7 @@ CREATE TABLE ghost ( contact_info_set BOOLEAN NOT NULL, is_bot BOOLEAN NOT NULL, identifiers jsonb NOT NULL, + extra_profile jsonb, metadata jsonb NOT NULL, PRIMARY KEY (bridge_id, id) diff --git a/bridgev2/database/upgrades/27-ghost-extra-profile.sql b/bridgev2/database/upgrades/27-ghost-extra-profile.sql new file mode 100644 index 00000000..e8e0549a --- /dev/null +++ b/bridgev2/database/upgrades/27-ghost-extra-profile.sql @@ -0,0 +1,2 @@ +-- v27 (compatible with v9+): Add column for extra ghost profile metadata +ALTER TABLE ghost ADD COLUMN extra_profile jsonb; diff --git a/bridgev2/ghost.go b/bridgev2/ghost.go index f7072a9c..590dd1dc 100644 --- a/bridgev2/ghost.go +++ b/bridgev2/ghost.go @@ -9,12 +9,15 @@ package bridgev2 import ( "context" "crypto/sha256" + "encoding/json" "fmt" + "maps" "net/http" + "slices" "github.com/rs/zerolog" + "go.mau.fi/util/exerrors" "go.mau.fi/util/exmime" - "golang.org/x/exp/slices" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" @@ -134,10 +137,11 @@ func (a *Avatar) Reupload(ctx context.Context, intent MatrixAPI, currentHash [32 } type UserInfo struct { - Identifiers []string - Name *string - Avatar *Avatar - IsBot *bool + Identifiers []string + Name *string + Avatar *Avatar + IsBot *bool + ExtraProfile database.ExtraProfile ExtraUpdates ExtraUpdater[*Ghost] } @@ -185,9 +189,9 @@ func (ghost *Ghost) UpdateAvatar(ctx context.Context, avatar *Avatar) bool { return true } -func (ghost *Ghost) getExtraProfileMeta() *event.BeeperProfileExtra { +func (ghost *Ghost) getExtraProfileMeta() any { bridgeName := ghost.Bridge.Network.GetName() - return &event.BeeperProfileExtra{ + baseExtra := &event.BeeperProfileExtra{ RemoteID: string(ghost.ID), Identifiers: ghost.Identifiers, Service: bridgeName.BeeperBridgeType, @@ -195,23 +199,35 @@ func (ghost *Ghost) getExtraProfileMeta() *event.BeeperProfileExtra { IsBridgeBot: false, IsNetworkBot: ghost.IsBot, } + if len(ghost.ExtraProfile) == 0 { + return baseExtra + } + mergedExtra := maps.Clone(ghost.ExtraProfile) + baseExtraMarshaled := exerrors.Must(json.Marshal(baseExtra)) + exerrors.PanicIfNotNil(json.Unmarshal(baseExtraMarshaled, &mergedExtra)) + return mergedExtra } -func (ghost *Ghost) UpdateContactInfo(ctx context.Context, identifiers []string, isBot *bool) bool { - if identifiers != nil { - slices.Sort(identifiers) - } - if ghost.ContactInfoSet && - (identifiers == nil || slices.Equal(identifiers, ghost.Identifiers)) && - (isBot == nil || *isBot == ghost.IsBot) { +func (ghost *Ghost) UpdateContactInfo(ctx context.Context, identifiers []string, isBot *bool, extraProfile database.ExtraProfile) bool { + if !ghost.Bridge.Matrix.GetCapabilities().ExtraProfileMeta { + ghost.ContactInfoSet = false return false } if identifiers != nil { + slices.Sort(identifiers) + } + changed := extraProfile.CopyTo(&ghost.ExtraProfile) + if identifiers != nil { + changed = changed || !slices.Equal(identifiers, ghost.Identifiers) ghost.Identifiers = identifiers } if isBot != nil { + changed = changed || *isBot != ghost.IsBot ghost.IsBot = *isBot } + if ghost.ContactInfoSet && !changed { + return false + } err := ghost.Intent.SetExtraProfileMeta(ctx, ghost.getExtraProfileMeta()) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to set extra profile metadata") @@ -287,8 +303,8 @@ func (ghost *Ghost) UpdateInfo(ctx context.Context, info *UserInfo) { ghost.AvatarSet = true update = true } - if info.Identifiers != nil || info.IsBot != nil { - update = ghost.UpdateContactInfo(ctx, info.Identifiers, info.IsBot) || update + if info.Identifiers != nil || info.IsBot != nil || info.ExtraProfile != nil { + update = ghost.UpdateContactInfo(ctx, info.Identifiers, info.IsBot, info.ExtraProfile) || update } if info.ExtraUpdates != nil { update = info.ExtraUpdates(ctx, ghost) || update diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index b6da16ac..5a2df953 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -369,6 +369,8 @@ func (br *Connector) ensureConnection(ctx context.Context) { br.Capabilities.AutoJoinInvites = br.SpecVersions.Supports(mautrix.BeeperFeatureAutojoinInvites) br.Capabilities.BatchSending = br.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) br.Capabilities.ArbitraryMemberChange = br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryMemberChange) + br.Capabilities.ExtraProfileMeta = br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) || + (br.SpecVersions.Supports(mautrix.FeatureArbitraryProfileFields) && br.Config.Matrix.GhostExtraProfileInfo) break } } diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index 83318493..f7254bd4 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -9,6 +9,7 @@ package matrix import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -27,6 +28,7 @@ import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/bridgeconfig" "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/crypto/canonicaljson" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "maunium.net/go/mautrix/pushrules" @@ -484,11 +486,62 @@ func (as *ASIntent) SetAvatarURL(ctx context.Context, avatarURL id.ContentURIStr return as.Matrix.SetAvatarURL(ctx, parsedAvatarURL) } -func (as *ASIntent) SetExtraProfileMeta(ctx context.Context, data any) error { - if !as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) { - return nil +func dataToFields(data any) (map[string]json.RawMessage, error) { + fields, ok := data.(map[string]json.RawMessage) + if ok { + return fields, nil } - return as.Matrix.BeeperUpdateProfile(ctx, data) + d, err := json.Marshal(data) + if err != nil { + return nil, err + } + d = canonicaljson.CanonicalJSONAssumeValid(d) + err = json.Unmarshal(d, &fields) + return fields, err +} + +func marshalField(val any) json.RawMessage { + data, _ := json.Marshal(val) + if len(data) > 0 && (data[0] == '{' || data[0] == '[') { + return canonicaljson.CanonicalJSONAssumeValid(data) + } + return data +} + +var nullJSON = json.RawMessage("null") + +func (as *ASIntent) SetExtraProfileMeta(ctx context.Context, data any) error { + if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) { + return as.Matrix.BeeperUpdateProfile(ctx, data) + } else if as.Connector.SpecVersions.Supports(mautrix.FeatureArbitraryProfileFields) && as.Connector.Config.Matrix.GhostExtraProfileInfo { + fields, err := dataToFields(data) + if err != nil { + return fmt.Errorf("failed to marshal fields: %w", err) + } + currentProfile, err := as.Matrix.GetProfile(ctx, as.Matrix.UserID) + if err != nil { + return fmt.Errorf("failed to get current profile: %w", err) + } + for key, val := range fields { + existing, ok := currentProfile.Extra[key] + if !ok { + if bytes.Equal(val, nullJSON) { + continue + } + err = as.Matrix.SetProfileField(ctx, key, val) + } else if !bytes.Equal(marshalField(existing), val) { + if bytes.Equal(val, nullJSON) { + err = as.Matrix.DeleteProfileField(ctx, key) + } else { + err = as.Matrix.SetProfileField(ctx, key, val) + } + } + if err != nil { + return fmt.Errorf("failed to set profile field %q: %w", key, err) + } + } + } + return nil } func (as *ASIntent) GetMXID() id.UserID { diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 75d0edbf..ccc81c4b 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -244,6 +244,9 @@ matrix: # The threshold as bytes after which the bridge should roundtrip uploads via the disk # rather than keeping the whole file in memory. upload_file_threshold: 5242880 + # Should the bridge set additional custom profile info for ghosts? + # This can make a lot of requests, as there's no batch profile update endpoint. + ghost_extra_profile_info: false # Segment-compatible analytics endpoint for tracking some events, like provisioning API login and encryption errors. analytics: diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index 768c57d1..be26db49 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -28,6 +28,7 @@ type MatrixCapabilities struct { AutoJoinInvites bool BatchSending bool ArbitraryMemberChange bool + ExtraProfileMeta bool } type MatrixConnector interface { From b42ac0e83d44c2393ca703a0a31f6f92a2b0d85c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 13 Mar 2026 16:27:45 +0200 Subject: [PATCH 578/581] bridgev2/status: make RemoteProfile a non-pointer Closes #468 --- bridgev2/database/userlogin.go | 2 +- bridgev2/matrix/provisioning.go | 2 +- bridgev2/status/bridgestate.go | 7 +++---- bridgev2/userlogin.go | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/bridgev2/database/userlogin.go b/bridgev2/database/userlogin.go index 9fa6569a..00ff01c9 100644 --- a/bridgev2/database/userlogin.go +++ b/bridgev2/database/userlogin.go @@ -116,7 +116,7 @@ func (u *UserLogin) ensureHasMetadata(metaType MetaTypeCreator) *UserLogin { func (u *UserLogin) sqlVariables() []any { var remoteProfile dbutil.JSON - if !u.RemoteProfile.IsEmpty() { + if !u.RemoteProfile.IsZero() { remoteProfile.Data = &u.RemoteProfile } return []any{u.BridgeID, u.UserMXID, u.ID, u.RemoteName, remoteProfile, dbutil.StrPtr(u.SpaceRoom), dbutil.JSON{Data: u.Metadata}} diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 02a0dac9..243b91da 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -324,7 +324,7 @@ func (prov *ProvisioningAPI) GetWhoami(w http.ResponseWriter, r *http.Request) { prevState.UserID = "" prevState.RemoteID = "" prevState.RemoteName = "" - prevState.RemoteProfile = nil + prevState.RemoteProfile = status.RemoteProfile{} resp.Logins[i] = RespWhoamiLogin{ StateEvent: prevState.StateEvent, StateTS: prevState.Timestamp, diff --git a/bridgev2/status/bridgestate.go b/bridgev2/status/bridgestate.go index 430d4c7c..5925dd4f 100644 --- a/bridgev2/status/bridgestate.go +++ b/bridgev2/status/bridgestate.go @@ -19,7 +19,6 @@ import ( "github.com/tidwall/sjson" "go.mau.fi/util/jsontime" - "go.mau.fi/util/ptr" "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2/networkid" @@ -112,7 +111,7 @@ func (rp *RemoteProfile) Merge(other RemoteProfile) RemoteProfile { return other } -func (rp *RemoteProfile) IsEmpty() bool { +func (rp *RemoteProfile) IsZero() bool { return rp == nil || (rp.Phone == "" && rp.Email == "" && rp.Username == "" && rp.Name == "" && rp.Avatar == "" && rp.AvatarFile == nil) } @@ -130,7 +129,7 @@ type BridgeState struct { UserID id.UserID `json:"user_id,omitempty"` RemoteID networkid.UserLoginID `json:"remote_id,omitempty"` RemoteName string `json:"remote_name,omitempty"` - RemoteProfile *RemoteProfile `json:"remote_profile,omitempty"` + RemoteProfile RemoteProfile `json:"remote_profile,omitzero"` Reason string `json:"reason,omitempty"` Info map[string]interface{} `json:"info,omitempty"` @@ -210,7 +209,7 @@ func (pong *BridgeState) ShouldDeduplicate(newPong *BridgeState) bool { pong.StateEvent == newPong.StateEvent && pong.RemoteName == newPong.RemoteName && pong.UserAction == newPong.UserAction && - ptr.Val(pong.RemoteProfile) == ptr.Val(newPong.RemoteProfile) && + pong.RemoteProfile == newPong.RemoteProfile && pong.Error == newPong.Error && maps.EqualFunc(pong.Info, newPong.Info, reflect.DeepEqual) && pong.Timestamp.Add(time.Duration(pong.TTL)*time.Second).After(time.Now()) diff --git a/bridgev2/userlogin.go b/bridgev2/userlogin.go index 35443025..d56dc4cc 100644 --- a/bridgev2/userlogin.go +++ b/bridgev2/userlogin.go @@ -512,7 +512,7 @@ func (ul *UserLogin) FillBridgeState(state status.BridgeState) status.BridgeStat state.UserID = ul.UserMXID state.RemoteID = ul.ID state.RemoteName = ul.RemoteName - state.RemoteProfile = &ul.RemoteProfile + state.RemoteProfile = ul.RemoteProfile filler, ok := ul.Client.(status.BridgeStateFiller) if ok { return filler.FillBridgeState(state) From ef6de851a2fe2f641813b3000157f32f212332af Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 13 Mar 2026 18:33:22 +0200 Subject: [PATCH 579/581] format/htmlparser: fix generating markdown for code blocks with backticks --- format/htmlparser.go | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/format/htmlparser.go b/format/htmlparser.go index e5f92896..e0507d93 100644 --- a/format/htmlparser.go +++ b/format/htmlparser.go @@ -93,6 +93,30 @@ func DefaultPillConverter(displayname, mxid, eventID string, ctx Context) string } } +func onlyBacktickCount(line string) (count int) { + for i := 0; i < len(line); i++ { + if line[i] != '`' { + return -1 + } + count++ + } + return +} + +func DefaultMonospaceBlockConverter(code, language string, ctx Context) string { + if len(code) == 0 || code[len(code)-1] != '\n' { + code += "\n" + } + fence := "```" + for line := range strings.SplitSeq(code, "\n") { + count := onlyBacktickCount(strings.TrimSpace(line)) + if count >= len(fence) { + fence = strings.Repeat("`", count+1) + } + } + return fmt.Sprintf("%s%s\n%s%s", fence, language, code, fence) +} + // HTMLParser is a somewhat customizable Matrix HTML parser. type HTMLParser struct { PillConverter PillConverter @@ -348,10 +372,7 @@ func (parser *HTMLParser) tagToString(node *html.Node, ctx Context) string { if parser.MonospaceBlockConverter != nil { return parser.MonospaceBlockConverter(preStr, language, ctx) } - if len(preStr) == 0 || preStr[len(preStr)-1] != '\n' { - preStr += "\n" - } - return fmt.Sprintf("```%s\n%s```", language, preStr) + return DefaultMonospaceBlockConverter(preStr, language, ctx) default: return parser.nodeToTagAwareString(node.FirstChild, ctx) } From 8e564c38dff579808d3542616c374deb67c53a61 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 13 Mar 2026 19:20:34 +0200 Subject: [PATCH 580/581] client: fix stable mutual rooms path --- client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client.go b/client.go index 7062d9b9..045d7b8e 100644 --- a/client.go +++ b/client.go @@ -1170,7 +1170,7 @@ func (cli *Client) GetMutualRooms(ctx context.Context, otherUserID id.UserID, ex if len(extras) > 0 { query["from"] = extras[0].From } - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v1", "user", "mutual_rooms"}, query) + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v1", "mutual_rooms"}, query) if !supportsStable && supportsUnstable { urlPath = cli.BuildURLWithQuery(ClientURLPath{"unstable", "uk.half-shot.msc2666", "user", "mutual_rooms"}, query) } From 1953538cb6e31c016d53513af0c4938aff873379 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 15 Mar 2026 12:39:17 +0200 Subject: [PATCH 581/581] bridgev2/portal: add event being handled to context variable --- bridgev2/networkinterface.go | 5 +++++ bridgev2/portal.go | 31 ++++++++++++++++++++++++++++++- bridgev2/simplevent/meta.go | 13 +++++++++++-- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index efc5f100..b706aedb 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -1115,6 +1115,11 @@ type RemoteEvent interface { GetSender() EventSender } +type RemoteEventWithContextMutation interface { + RemoteEvent + MutateContext(ctx context.Context) context.Context +} + type RemoteEventWithUncertainPortalReceiver interface { RemoteEvent PortalReceiverIsUncertain() bool diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 16aa703b..5ba29507 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -448,6 +448,23 @@ func (portal *Portal) handleSingleEventWithDelayLogging(idx int, rawEvt any) (ou return } +type contextKey int + +const ( + contextKeyRemoteEvent contextKey = iota + contextKeyMatrixEvent +) + +func GetMatrixEventFromContext(ctx context.Context) (evt *event.Event) { + evt, _ = ctx.Value(contextKeyMatrixEvent).(*event.Event) + return +} + +func GetRemoteEventFromContext(ctx context.Context) (evt RemoteEvent) { + evt, _ = ctx.Value(contextKeyRemoteEvent).(RemoteEvent) + return +} + func (portal *Portal) getEventCtxWithLog(rawEvt any, idx int) context.Context { var logWith zerolog.Context switch evt := rawEvt.(type) { @@ -461,6 +478,10 @@ func (portal *Portal) getEventCtxWithLog(rawEvt any, idx int) context.Context { Stringer("event_id", evt.evt.ID). Stringer("sender", evt.sender.MXID) } + ctx := portal.Bridge.BackgroundCtx + ctx = context.WithValue(ctx, contextKeyMatrixEvent, evt.evt) + ctx = logWith.Logger().WithContext(ctx) + return ctx case *portalRemoteEvent: evt.evtType = evt.evt.GetType() logWith = portal.Log.With().Int("event_loop_index", idx). @@ -491,10 +512,18 @@ func (portal *Portal) getEventCtxWithLog(rawEvt any, idx int) context.Context { logWith = logWith.Time("remote_timestamp", remoteTimestamp) } } + ctx := portal.Bridge.BackgroundCtx + ctx = context.WithValue(ctx, contextKeyRemoteEvent, evt.evt) + ctx = logWith.Logger().WithContext(ctx) + if ctxMut, ok := evt.evt.(RemoteEventWithContextMutation); ok { + ctx = ctxMut.MutateContext(ctx) + } + return ctx case *portalCreateEvent: return evt.ctx + default: + panic(fmt.Errorf("invalid type %T in getEventCtxWithLog", evt)) } - return logWith.Logger().WithContext(portal.Bridge.BackgroundCtx) } func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCallback func(res EventHandlingResult)) { diff --git a/bridgev2/simplevent/meta.go b/bridgev2/simplevent/meta.go index 449a8773..96c8a9c5 100644 --- a/bridgev2/simplevent/meta.go +++ b/bridgev2/simplevent/meta.go @@ -27,8 +27,9 @@ type EventMeta struct { Timestamp time.Time StreamOrder int64 - PreHandleFunc func(context.Context, *bridgev2.Portal) - PostHandleFunc func(context.Context, *bridgev2.Portal) + PreHandleFunc func(context.Context, *bridgev2.Portal) + PostHandleFunc func(context.Context, *bridgev2.Portal) + MutateContextFunc func(context.Context) context.Context } var ( @@ -39,6 +40,7 @@ var ( _ bridgev2.RemoteEventWithStreamOrder = (*EventMeta)(nil) _ bridgev2.RemotePreHandler = (*EventMeta)(nil) _ bridgev2.RemotePostHandler = (*EventMeta)(nil) + _ bridgev2.RemoteEventWithContextMutation = (*EventMeta)(nil) ) func (evt *EventMeta) AddLogContext(c zerolog.Context) zerolog.Context { @@ -91,6 +93,13 @@ func (evt *EventMeta) PostHandle(ctx context.Context, portal *bridgev2.Portal) { } } +func (evt *EventMeta) MutateContext(ctx context.Context) context.Context { + if evt.MutateContextFunc == nil { + return ctx + } + return evt.MutateContextFunc(ctx) +} + func (evt EventMeta) WithType(t bridgev2.RemoteEventType) EventMeta { evt.Type = t return evt