From 002fe05a203e316818c108a0dac438e5cd796a68 Mon Sep 17 00:00:00 2001 From: Kegsay Date: Wed, 24 Jun 2020 15:06:14 +0100 Subject: [PATCH] Add PerformInvite and refactor how errors get handled (#1158) * Add PerformInvite and refactor how errors get handled - Rename `JoinError` to `PerformError` - Remove `error` from the API function signature entirely. This forces errors to be bundled into `PerformError` which makes it easier for callers to detect and handle errors. On network errors, HTTP clients will make a `PerformError`. * Unbreak everything; thanks Go! * Send back JSONResponse according to the PerformError * Update federation invite code too --- clientapi/routing/createroom.go | 8 +- clientapi/routing/joinroom.go | 31 +--- clientapi/routing/membership.go | 8 +- federationapi/routing/invite.go | 6 +- federationapi/routing/send_test.go | 10 +- roomserver/api/api.go | 8 +- roomserver/api/api_trace.go | 16 +- roomserver/api/input.go | 14 +- roomserver/api/perform.go | 71 ++++++-- roomserver/api/wrapper.go | 26 +-- roomserver/internal/input.go | 12 -- roomserver/internal/input_events.go | 195 -------------------- roomserver/internal/perform_invite.go | 249 ++++++++++++++++++++++++++ roomserver/internal/perform_join.go | 104 +++++++---- roomserver/inthttp/client.go | 28 ++- roomserver/inthttp/server.go | 15 +- 16 files changed, 469 insertions(+), 332 deletions(-) create mode 100644 roomserver/internal/perform_invite.go diff --git a/clientapi/routing/createroom.go b/clientapi/routing/createroom.go index be712482..8682b03a 100644 --- a/clientapi/routing/createroom.go +++ b/clientapi/routing/createroom.go @@ -403,15 +403,15 @@ func createRoom( } } // Send the invite event to the roomserver. - if err = roomserverAPI.SendInvite( + if perr := roomserverAPI.SendInvite( req.Context(), rsAPI, inviteEvent.Headered(roomVersion), strippedState, // invite room state cfg.Matrix.ServerName, // send as server nil, // transaction ID - ); err != nil { - util.GetLogger(req.Context()).WithError(err).Error("SendInvite failed") - return jsonerror.InternalServerError() + ); perr != nil { + util.GetLogger(req.Context()).WithError(perr).Error("SendInvite failed") + return perr.JSONResponse() } } diff --git a/clientapi/routing/joinroom.go b/clientapi/routing/joinroom.go index e606e35f..cb68fe19 100644 --- a/clientapi/routing/joinroom.go +++ b/clientapi/routing/joinroom.go @@ -15,12 +15,10 @@ package routing import ( - "errors" "net/http" "github.com/matrix-org/dendrite/clientapi/auth/authtypes" "github.com/matrix-org/dendrite/clientapi/httputil" - "github.com/matrix-org/dendrite/clientapi/jsonerror" roomserverAPI "github.com/matrix-org/dendrite/roomserver/api" "github.com/matrix-org/dendrite/userapi/api" "github.com/matrix-org/dendrite/userapi/storage/accounts" @@ -65,32 +63,9 @@ func JoinRoomByIDOrAlias( } // Ask the roomserver to perform the join. - err = rsAPI.PerformJoin(req.Context(), &joinReq, &joinRes) - // Handle known errors first, if this is 0 then there will be no matches (eg on success) - switch joinRes.Error { - case roomserverAPI.JoinErrorBadRequest: - return util.JSONResponse{ - Code: http.StatusBadRequest, - JSON: jsonerror.Unknown(joinRes.ErrMsg), - } - case roomserverAPI.JoinErrorNoRoom: - return util.JSONResponse{ - Code: http.StatusNotFound, - JSON: jsonerror.NotFound(joinRes.ErrMsg), - } - case roomserverAPI.JoinErrorNotAllowed: - return util.JSONResponse{ - Code: http.StatusForbidden, - JSON: jsonerror.Forbidden(joinRes.ErrMsg), - } - } - // this is always populated on generic errors - if joinRes.ErrMsg != "" { - return util.ErrorResponse(errors.New(joinRes.ErrMsg)) - } - // this is set on network errors in polylith mode - if err != nil { - return util.ErrorResponse(err) + rsAPI.PerformJoin(req.Context(), &joinReq, &joinRes) + if joinRes.Error != nil { + return joinRes.Error.JSONResponse() } return util.JSONResponse{ diff --git a/clientapi/routing/membership.go b/clientapi/routing/membership.go index 0d4b0d88..aff1730c 100644 --- a/clientapi/routing/membership.go +++ b/clientapi/routing/membership.go @@ -111,16 +111,16 @@ func SendMembership( switch membership { case gomatrixserverlib.Invite: // Invites need to be handled specially - err = roomserverAPI.SendInvite( + perr := roomserverAPI.SendInvite( req.Context(), rsAPI, event.Headered(verRes.RoomVersion), nil, // ask the roomserver to draw up invite room state for us cfg.Matrix.ServerName, nil, ) - if err != nil { - util.GetLogger(req.Context()).WithError(err).Error("producer.SendInvite failed") - return jsonerror.InternalServerError() + if perr != nil { + util.GetLogger(req.Context()).WithError(perr).Error("producer.SendInvite failed") + return perr.JSONResponse() } case gomatrixserverlib.Join: // The join membership requires the room id to be sent in the response diff --git a/federationapi/routing/invite.go b/federationapi/routing/invite.go index 7d02bc1d..b1d84f25 100644 --- a/federationapi/routing/invite.go +++ b/federationapi/routing/invite.go @@ -98,15 +98,15 @@ func Invite( ) // Add the invite event to the roomserver. - if err = api.SendInvite( + if perr := api.SendInvite( httpReq.Context(), rsAPI, signedEvent.Headered(inviteReq.RoomVersion()), inviteReq.InviteRoomState(), event.Origin(), nil, - ); err != nil { + ); perr != nil { util.GetLogger(httpReq.Context()).WithError(err).Error("producer.SendInvite failed") - return jsonerror.InternalServerError() + return perr.JSONResponse() } // Return the signed event to the originating server, it should then tell diff --git a/federationapi/routing/send_test.go b/federationapi/routing/send_test.go index e512f4b4..3f5d5f4e 100644 --- a/federationapi/routing/send_test.go +++ b/federationapi/routing/send_test.go @@ -97,12 +97,18 @@ func (t *testRoomserverAPI) InputRoomEvents( return nil } +func (t *testRoomserverAPI) PerformInvite( + ctx context.Context, + req *api.PerformInviteRequest, + res *api.PerformInviteResponse, +) { +} + func (t *testRoomserverAPI) PerformJoin( ctx context.Context, req *api.PerformJoinRequest, res *api.PerformJoinResponse, -) error { - return nil +) { } func (t *testRoomserverAPI) PerformLeave( diff --git a/roomserver/api/api.go b/roomserver/api/api.go index 967f58ba..26ec8ca1 100644 --- a/roomserver/api/api.go +++ b/roomserver/api/api.go @@ -18,11 +18,17 @@ type RoomserverInternalAPI interface { response *InputRoomEventsResponse, ) error + PerformInvite( + ctx context.Context, + req *PerformInviteRequest, + res *PerformInviteResponse, + ) + PerformJoin( ctx context.Context, req *PerformJoinRequest, res *PerformJoinResponse, - ) error + ) PerformLeave( ctx context.Context, diff --git a/roomserver/api/api_trace.go b/roomserver/api/api_trace.go index a478eeb9..8645b6f2 100644 --- a/roomserver/api/api_trace.go +++ b/roomserver/api/api_trace.go @@ -29,14 +29,22 @@ func (t *RoomserverInternalAPITrace) InputRoomEvents( return err } +func (t *RoomserverInternalAPITrace) PerformInvite( + ctx context.Context, + req *PerformInviteRequest, + res *PerformInviteResponse, +) { + t.Impl.PerformInvite(ctx, req, res) + util.GetLogger(ctx).Infof("PerformInvite req=%+v res=%+v", js(req), js(res)) +} + func (t *RoomserverInternalAPITrace) PerformJoin( ctx context.Context, req *PerformJoinRequest, res *PerformJoinResponse, -) error { - err := t.Impl.PerformJoin(ctx, req, res) - util.GetLogger(ctx).WithError(err).Infof("PerformJoin req=%+v res=%+v", js(req), js(res)) - return err +) { + t.Impl.PerformJoin(ctx, req, res) + util.GetLogger(ctx).Infof("PerformJoin req=%+v res=%+v", js(req), js(res)) } func (t *RoomserverInternalAPITrace) PerformLeave( diff --git a/roomserver/api/input.go b/roomserver/api/input.go index 6c3c8941..05c981df 100644 --- a/roomserver/api/input.go +++ b/roomserver/api/input.go @@ -76,21 +76,9 @@ type TransactionID struct { TransactionID string `json:"id"` } -// InputInviteEvent is a matrix invite event received over federation without -// the usual context a matrix room event would have. We usually do not have -// access to the events needed to check the event auth rules for the invite. -type InputInviteEvent struct { - RoomVersion gomatrixserverlib.RoomVersion `json:"room_version"` - Event gomatrixserverlib.HeaderedEvent `json:"event"` - InviteRoomState []gomatrixserverlib.InviteV2StrippedState `json:"invite_room_state"` - SendAsServer string `json:"send_as_server"` - TransactionID *TransactionID `json:"transaction_id"` -} - // InputRoomEventsRequest is a request to InputRoomEvents type InputRoomEventsRequest struct { - InputRoomEvents []InputRoomEvent `json:"input_room_events"` - InputInviteEvents []InputInviteEvent `json:"input_invite_events"` + InputRoomEvents []InputRoomEvent `json:"input_room_events"` } // InputRoomEventsResponse is a response to InputRoomEvents diff --git a/roomserver/api/perform.go b/roomserver/api/perform.go index 0f5394c9..0b8e6df2 100644 --- a/roomserver/api/perform.go +++ b/roomserver/api/perform.go @@ -1,19 +1,57 @@ package api import ( + "fmt" + "net/http" + + "github.com/matrix-org/dendrite/clientapi/jsonerror" "github.com/matrix-org/gomatrixserverlib" "github.com/matrix-org/util" ) -type JoinError int +type PerformErrorCode int + +type PerformError struct { + Msg string + Code PerformErrorCode +} + +func (p *PerformError) Error() string { + return fmt.Sprintf("%d : %s", p.Code, p.Msg) +} + +// JSONResponse maps error codes to suitable HTTP error codes, defaulting to 500. +func (p *PerformError) JSONResponse() util.JSONResponse { + switch p.Code { + case PerformErrorBadRequest: + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.Unknown(p.Msg), + } + case PerformErrorNoRoom: + return util.JSONResponse{ + Code: http.StatusNotFound, + JSON: jsonerror.NotFound(p.Msg), + } + case PerformErrorNotAllowed: + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden(p.Msg), + } + default: + return util.ErrorResponse(p) + } +} const ( - // JoinErrorNotAllowed means the user is not allowed to join this room (e.g join_rule:invite or banned) - JoinErrorNotAllowed JoinError = 1 - // JoinErrorBadRequest means the request was wrong in some way (invalid user ID, wrong server, etc) - JoinErrorBadRequest JoinError = 2 - // JoinErrorNoRoom means that the room being joined doesn't exist. - JoinErrorNoRoom JoinError = 3 + // PerformErrorNotAllowed means the user is not allowed to invite/join/etc this room (e.g join_rule:invite or banned) + PerformErrorNotAllowed PerformErrorCode = 1 + // PerformErrorBadRequest means the request was wrong in some way (invalid user ID, wrong server, etc) + PerformErrorBadRequest PerformErrorCode = 2 + // PerformErrorNoRoom means that the room being joined doesn't exist. + PerformErrorNoRoom PerformErrorCode = 3 + // PerformErrorNoOperation means that the request resulted in nothing happening e.g invite->invite or leave->leave. + PerformErrorNoOperation PerformErrorCode = 4 ) type PerformJoinRequest struct { @@ -26,10 +64,8 @@ type PerformJoinRequest struct { type PerformJoinResponse struct { // The room ID, populated on success. RoomID string `json:"room_id"` - // The reason why the join failed. Can be blank. - Error JoinError `json:"error"` - // Debugging description of the error. Always present on failure. - ErrMsg string `json:"err_msg"` + // If non-nil, the join request failed. Contains more information why it failed. + Error *PerformError } type PerformLeaveRequest struct { @@ -40,6 +76,19 @@ type PerformLeaveRequest struct { type PerformLeaveResponse struct { } +type PerformInviteRequest struct { + RoomVersion gomatrixserverlib.RoomVersion `json:"room_version"` + Event gomatrixserverlib.HeaderedEvent `json:"event"` + InviteRoomState []gomatrixserverlib.InviteV2StrippedState `json:"invite_room_state"` + SendAsServer string `json:"send_as_server"` + TransactionID *TransactionID `json:"transaction_id"` +} + +type PerformInviteResponse struct { + // If non-nil, the invite request failed. Contains more information why it failed. + Error *PerformError +} + // PerformBackfillRequest is a request to PerformBackfill. type PerformBackfillRequest struct { // The room to backfill diff --git a/roomserver/api/wrapper.go b/roomserver/api/wrapper.go index 97940e0c..b73cd190 100644 --- a/roomserver/api/wrapper.go +++ b/roomserver/api/wrapper.go @@ -98,16 +98,20 @@ func SendInvite( rsAPI RoomserverInternalAPI, inviteEvent gomatrixserverlib.HeaderedEvent, inviteRoomState []gomatrixserverlib.InviteV2StrippedState, sendAsServer gomatrixserverlib.ServerName, txnID *TransactionID, -) error { - request := InputRoomEventsRequest{ - InputInviteEvents: []InputInviteEvent{{ - Event: inviteEvent, - InviteRoomState: inviteRoomState, - RoomVersion: inviteEvent.RoomVersion, - SendAsServer: string(sendAsServer), - TransactionID: txnID, - }}, +) *PerformError { + request := PerformInviteRequest{ + Event: inviteEvent, + InviteRoomState: inviteRoomState, + RoomVersion: inviteEvent.RoomVersion, + SendAsServer: string(sendAsServer), + TransactionID: txnID, } - var response InputRoomEventsResponse - return rsAPI.InputRoomEvents(ctx, &request, &response) + var response PerformInviteResponse + rsAPI.PerformInvite(ctx, &request, &response) + // we need to do this because many places people will use `var err error` as the return + // arg and a nil interface != nil pointer to a concrete interface (in this case PerformError) + if response.Error != nil && response.Error.Msg != "" { + return response.Error + } + return nil } diff --git a/roomserver/internal/input.go b/roomserver/internal/input.go index e863af95..2af3e62d 100644 --- a/roomserver/internal/input.go +++ b/roomserver/internal/input.go @@ -74,18 +74,6 @@ func (r *RoomserverInternalAPI) InputRoomEvents( // We lock as processRoomEvent can only be called once at a time r.mutex.Lock() defer r.mutex.Unlock() - for i := range request.InputInviteEvents { - var loopback *api.InputRoomEvent - if loopback, err = r.processInviteEvent(ctx, r, request.InputInviteEvents[i]); err != nil { - return err - } - // The processInviteEvent function can optionally return a - // loopback room event containing the invite, for local invites. - // If it does, we should process it with the room events below. - if loopback != nil { - request.InputRoomEvents = append(request.InputRoomEvents, *loopback) - } - } for i := range request.InputRoomEvents { if response.EventID, err = r.processRoomEvent(ctx, request.InputRoomEvents[i]); err != nil { return err diff --git a/roomserver/internal/input_events.go b/roomserver/internal/input_events.go index fe3bdf4b..ae57f2e7 100644 --- a/roomserver/internal/input_events.go +++ b/roomserver/internal/input_events.go @@ -18,17 +18,12 @@ package internal import ( "context" - "errors" - "fmt" - "github.com/matrix-org/dendrite/internal/sqlutil" "github.com/matrix-org/dendrite/roomserver/api" "github.com/matrix-org/dendrite/roomserver/state" - "github.com/matrix-org/dendrite/roomserver/storage" "github.com/matrix-org/dendrite/roomserver/types" "github.com/matrix-org/gomatrixserverlib" "github.com/sirupsen/logrus" - log "github.com/sirupsen/logrus" ) // processRoomEvent can only be called once at a time @@ -148,193 +143,3 @@ func (r *RoomserverInternalAPI) calculateAndSetState( } return r.DB.SetState(ctx, stateAtEvent.EventNID, stateAtEvent.BeforeStateSnapshotNID) } - -func (r *RoomserverInternalAPI) processInviteEvent( - ctx context.Context, - ow *RoomserverInternalAPI, - input api.InputInviteEvent, -) (*api.InputRoomEvent, error) { - if input.Event.StateKey() == nil { - return nil, fmt.Errorf("invite must be a state event") - } - - roomID := input.Event.RoomID() - targetUserID := *input.Event.StateKey() - - log.WithFields(log.Fields{ - "event_id": input.Event.EventID(), - "room_id": roomID, - "room_version": input.RoomVersion, - "target_user_id": targetUserID, - }).Info("processing invite event") - - _, domain, _ := gomatrixserverlib.SplitID('@', targetUserID) - isTargetLocalUser := domain == r.Cfg.Matrix.ServerName - - updater, err := r.DB.MembershipUpdater(ctx, roomID, targetUserID, isTargetLocalUser, input.RoomVersion) - if err != nil { - return nil, err - } - succeeded := false - defer func() { - txerr := sqlutil.EndTransaction(updater, &succeeded) - if err == nil && txerr != nil { - err = txerr - } - }() - - if updater.IsJoin() { - // If the user is joined to the room then that takes precedence over this - // invite event. It makes little sense to move a user that is already - // joined to the room into the invite state. - // This could plausibly happen if an invite request raced with a join - // request for a user. For example if a user was invited to a public - // room and they joined the room at the same time as the invite was sent. - // The other way this could plausibly happen is if an invite raced with - // a kick. For example if a user was kicked from a room in error and in - // response someone else in the room re-invited them then it is possible - // for the invite request to race with the leave event so that the - // target receives invite before it learns that it has been kicked. - // There are a few ways this could be plausibly handled in the roomserver. - // 1) Store the invite, but mark it as retired. That will result in the - // permanent rejection of that invite event. So even if the target - // user leaves the room and the invite is retransmitted it will be - // ignored. However a new invite with a new event ID would still be - // accepted. - // 2) Silently discard the invite event. This means that if the event - // was retransmitted at a later date after the target user had left - // the room we would accept the invite. However since we hadn't told - // the sending server that the invite had been discarded it would - // have no reason to attempt to retry. - // 3) Signal the sending server that the user is already joined to the - // room. - // For now we will implement option 2. Since in the abesence of a retry - // mechanism it will be equivalent to option 1, and we don't have a - // signalling mechanism to implement option 3. - return nil, nil - } - - // Normally, with a federated invite, the federation sender would do - // the /v2/invite request (in which the remote server signs the invite) - // and then the signed event gets sent back to the roomserver as an input - // event. When the invite is local, we don't interact with the federation - // sender therefore we need to generate the loopback invite event for - // the room ourselves. - loopback, err := localInviteLoopback(ow, input) - if err != nil { - return nil, err - } - - event := input.Event.Unwrap() - if len(input.InviteRoomState) > 0 { - // If we were supplied with some invite room state already (which is - // most likely to be if the event came in over federation) then use - // that. - if err = event.SetUnsignedField("invite_room_state", input.InviteRoomState); err != nil { - return nil, err - } - } else { - // There's no invite room state, so let's have a go at building it - // up from local data (which is most likely to be if the event came - // from the CS API). If we know about the room then we can insert - // the invite room state, if we don't then we just fail quietly. - if irs, ierr := buildInviteStrippedState(ctx, r.DB, input); ierr == nil { - if err = event.SetUnsignedField("invite_room_state", irs); err != nil { - return nil, err - } - } - } - - outputUpdates, err := updateToInviteMembership(updater, &event, nil, input.Event.RoomVersion) - if err != nil { - return nil, err - } - - if err = ow.WriteOutputEvents(roomID, outputUpdates); err != nil { - return nil, err - } - - succeeded = true - return loopback, nil -} - -func localInviteLoopback( - ow *RoomserverInternalAPI, - input api.InputInviteEvent, -) (ire *api.InputRoomEvent, err error) { - if input.Event.StateKey() == nil { - return nil, errors.New("no state key on invite event") - } - ourServerName := string(ow.Cfg.Matrix.ServerName) - _, theirServerName, err := gomatrixserverlib.SplitID('@', *input.Event.StateKey()) - if err != nil { - return nil, err - } - // Check if the invite originated locally and is destined locally. - if input.Event.Origin() == ow.Cfg.Matrix.ServerName && string(theirServerName) == ourServerName { - rsEvent := input.Event.Sign( - ourServerName, - ow.Cfg.Matrix.KeyID, - ow.Cfg.Matrix.PrivateKey, - ).Headered(input.RoomVersion) - ire = &api.InputRoomEvent{ - Kind: api.KindNew, - Event: rsEvent, - AuthEventIDs: rsEvent.AuthEventIDs(), - SendAsServer: ourServerName, - TransactionID: nil, - } - } - return ire, nil -} - -func buildInviteStrippedState( - ctx context.Context, - db storage.Database, - input api.InputInviteEvent, -) ([]gomatrixserverlib.InviteV2StrippedState, error) { - roomNID, err := db.RoomNID(ctx, input.Event.RoomID()) - if err != nil || roomNID == 0 { - return nil, fmt.Errorf("room %q unknown", input.Event.RoomID()) - } - stateWanted := []gomatrixserverlib.StateKeyTuple{} - // "If they are set on the room, at least the state for m.room.avatar, m.room.canonical_alias, m.room.join_rules, and m.room.name SHOULD be included." - // https://matrix.org/docs/spec/client_server/r0.6.0#m-room-member - for _, t := range []string{ - gomatrixserverlib.MRoomName, gomatrixserverlib.MRoomCanonicalAlias, - gomatrixserverlib.MRoomAliases, gomatrixserverlib.MRoomJoinRules, - "m.room.avatar", - } { - stateWanted = append(stateWanted, gomatrixserverlib.StateKeyTuple{ - EventType: t, - StateKey: "", - }) - } - _, currentStateSnapshotNID, _, err := db.LatestEventIDs(ctx, roomNID) - if err != nil { - return nil, err - } - roomState := state.NewStateResolution(db) - stateEntries, err := roomState.LoadStateAtSnapshotForStringTuples( - ctx, currentStateSnapshotNID, stateWanted, - ) - if err != nil { - return nil, err - } - stateNIDs := []types.EventNID{} - for _, stateNID := range stateEntries { - stateNIDs = append(stateNIDs, stateNID.EventNID) - } - stateEvents, err := db.Events(ctx, stateNIDs) - if err != nil { - return nil, err - } - inviteState := []gomatrixserverlib.InviteV2StrippedState{ - gomatrixserverlib.NewInviteV2StrippedState(&input.Event.Event), - } - stateEvents = append(stateEvents, types.Event{Event: input.Event.Unwrap()}) - for _, event := range stateEvents { - inviteState = append(inviteState, gomatrixserverlib.NewInviteV2StrippedState(&event.Event)) - } - return inviteState, nil -} diff --git a/roomserver/internal/perform_invite.go b/roomserver/internal/perform_invite.go new file mode 100644 index 00000000..c65c87f9 --- /dev/null +++ b/roomserver/internal/perform_invite.go @@ -0,0 +1,249 @@ +package internal + +import ( + "context" + "errors" + "fmt" + + "github.com/matrix-org/dendrite/internal/sqlutil" + "github.com/matrix-org/dendrite/roomserver/api" + "github.com/matrix-org/dendrite/roomserver/state" + "github.com/matrix-org/dendrite/roomserver/storage" + "github.com/matrix-org/dendrite/roomserver/types" + "github.com/matrix-org/gomatrixserverlib" + log "github.com/sirupsen/logrus" +) + +// PerformInvite handles inviting to matrix rooms, including over federation by talking to the federationsender. +func (r *RoomserverInternalAPI) PerformInvite( + ctx context.Context, + req *api.PerformInviteRequest, + res *api.PerformInviteResponse, +) { + err := r.performInvite(ctx, req) + if err != nil { + perr, ok := err.(*api.PerformError) + if ok { + res.Error = perr + } else { + res.Error = &api.PerformError{ + Msg: err.Error(), + } + } + } +} + +func (r *RoomserverInternalAPI) performInvite(ctx context.Context, + req *api.PerformInviteRequest, +) error { + loopback, err := r.processInviteEvent(ctx, r, req) + if err != nil { + return err + } + // The processInviteEvent function can optionally return a + // loopback room event containing the invite, for local invites. + // If it does, we should process it with the room events below. + if loopback != nil { + var loopbackRes api.InputRoomEventsResponse + err := r.InputRoomEvents(ctx, &api.InputRoomEventsRequest{ + InputRoomEvents: []api.InputRoomEvent{*loopback}, + }, &loopbackRes) + if err != nil { + return err + } + } + return nil +} + +func (r *RoomserverInternalAPI) processInviteEvent( + ctx context.Context, + ow *RoomserverInternalAPI, + input *api.PerformInviteRequest, +) (*api.InputRoomEvent, error) { + if input.Event.StateKey() == nil { + return nil, fmt.Errorf("invite must be a state event") + } + + roomID := input.Event.RoomID() + targetUserID := *input.Event.StateKey() + + log.WithFields(log.Fields{ + "event_id": input.Event.EventID(), + "room_id": roomID, + "room_version": input.RoomVersion, + "target_user_id": targetUserID, + }).Info("processing invite event") + + _, domain, _ := gomatrixserverlib.SplitID('@', targetUserID) + isTargetLocalUser := domain == r.Cfg.Matrix.ServerName + + updater, err := r.DB.MembershipUpdater(ctx, roomID, targetUserID, isTargetLocalUser, input.RoomVersion) + if err != nil { + return nil, err + } + succeeded := false + defer func() { + txerr := sqlutil.EndTransaction(updater, &succeeded) + if err == nil && txerr != nil { + err = txerr + } + }() + + if updater.IsJoin() { + // If the user is joined to the room then that takes precedence over this + // invite event. It makes little sense to move a user that is already + // joined to the room into the invite state. + // This could plausibly happen if an invite request raced with a join + // request for a user. For example if a user was invited to a public + // room and they joined the room at the same time as the invite was sent. + // The other way this could plausibly happen is if an invite raced with + // a kick. For example if a user was kicked from a room in error and in + // response someone else in the room re-invited them then it is possible + // for the invite request to race with the leave event so that the + // target receives invite before it learns that it has been kicked. + // There are a few ways this could be plausibly handled in the roomserver. + // 1) Store the invite, but mark it as retired. That will result in the + // permanent rejection of that invite event. So even if the target + // user leaves the room and the invite is retransmitted it will be + // ignored. However a new invite with a new event ID would still be + // accepted. + // 2) Silently discard the invite event. This means that if the event + // was retransmitted at a later date after the target user had left + // the room we would accept the invite. However since we hadn't told + // the sending server that the invite had been discarded it would + // have no reason to attempt to retry. + // 3) Signal the sending server that the user is already joined to the + // room. + // For now we will implement option 2. Since in the abesence of a retry + // mechanism it will be equivalent to option 1, and we don't have a + // signalling mechanism to implement option 3. + return nil, &api.PerformError{ + Code: api.PerformErrorNoOperation, + Msg: "user is already joined to room", + } + } + + // Normally, with a federated invite, the federation sender would do + // the /v2/invite request (in which the remote server signs the invite) + // and then the signed event gets sent back to the roomserver as an input + // event. When the invite is local, we don't interact with the federation + // sender therefore we need to generate the loopback invite event for + // the room ourselves. + loopback, err := localInviteLoopback(ow, input) + if err != nil { + return nil, err + } + + event := input.Event.Unwrap() + if len(input.InviteRoomState) > 0 { + // If we were supplied with some invite room state already (which is + // most likely to be if the event came in over federation) then use + // that. + if err = event.SetUnsignedField("invite_room_state", input.InviteRoomState); err != nil { + return nil, err + } + } else { + // There's no invite room state, so let's have a go at building it + // up from local data (which is most likely to be if the event came + // from the CS API). If we know about the room then we can insert + // the invite room state, if we don't then we just fail quietly. + if irs, ierr := buildInviteStrippedState(ctx, r.DB, input); ierr == nil { + if err = event.SetUnsignedField("invite_room_state", irs); err != nil { + return nil, err + } + } + } + + outputUpdates, err := updateToInviteMembership(updater, &event, nil, input.Event.RoomVersion) + if err != nil { + return nil, err + } + + if err = ow.WriteOutputEvents(roomID, outputUpdates); err != nil { + return nil, err + } + + succeeded = true + return loopback, nil +} + +func localInviteLoopback( + ow *RoomserverInternalAPI, + input *api.PerformInviteRequest, +) (ire *api.InputRoomEvent, err error) { + if input.Event.StateKey() == nil { + return nil, errors.New("no state key on invite event") + } + ourServerName := string(ow.Cfg.Matrix.ServerName) + _, theirServerName, err := gomatrixserverlib.SplitID('@', *input.Event.StateKey()) + if err != nil { + return nil, err + } + // Check if the invite originated locally and is destined locally. + if input.Event.Origin() == ow.Cfg.Matrix.ServerName && string(theirServerName) == ourServerName { + rsEvent := input.Event.Sign( + ourServerName, + ow.Cfg.Matrix.KeyID, + ow.Cfg.Matrix.PrivateKey, + ).Headered(input.RoomVersion) + ire = &api.InputRoomEvent{ + Kind: api.KindNew, + Event: rsEvent, + AuthEventIDs: rsEvent.AuthEventIDs(), + SendAsServer: ourServerName, + TransactionID: nil, + } + } + return ire, nil +} + +func buildInviteStrippedState( + ctx context.Context, + db storage.Database, + input *api.PerformInviteRequest, +) ([]gomatrixserverlib.InviteV2StrippedState, error) { + roomNID, err := db.RoomNID(ctx, input.Event.RoomID()) + if err != nil || roomNID == 0 { + return nil, fmt.Errorf("room %q unknown", input.Event.RoomID()) + } + stateWanted := []gomatrixserverlib.StateKeyTuple{} + // "If they are set on the room, at least the state for m.room.avatar, m.room.canonical_alias, m.room.join_rules, and m.room.name SHOULD be included." + // https://matrix.org/docs/spec/client_server/r0.6.0#m-room-member + for _, t := range []string{ + gomatrixserverlib.MRoomName, gomatrixserverlib.MRoomCanonicalAlias, + gomatrixserverlib.MRoomAliases, gomatrixserverlib.MRoomJoinRules, + "m.room.avatar", + } { + stateWanted = append(stateWanted, gomatrixserverlib.StateKeyTuple{ + EventType: t, + StateKey: "", + }) + } + _, currentStateSnapshotNID, _, err := db.LatestEventIDs(ctx, roomNID) + if err != nil { + return nil, err + } + roomState := state.NewStateResolution(db) + stateEntries, err := roomState.LoadStateAtSnapshotForStringTuples( + ctx, currentStateSnapshotNID, stateWanted, + ) + if err != nil { + return nil, err + } + stateNIDs := []types.EventNID{} + for _, stateNID := range stateEntries { + stateNIDs = append(stateNIDs, stateNID.EventNID) + } + stateEvents, err := db.Events(ctx, stateNIDs) + if err != nil { + return nil, err + } + inviteState := []gomatrixserverlib.InviteV2StrippedState{ + gomatrixserverlib.NewInviteV2StrippedState(&input.Event.Event), + } + stateEvents = append(stateEvents, types.Event{Event: input.Event.Unwrap()}) + for _, event := range stateEvents { + inviteState = append(inviteState, gomatrixserverlib.NewInviteV2StrippedState(&event.Event)) + } + return inviteState, nil +} diff --git a/roomserver/internal/perform_join.go b/roomserver/internal/perform_join.go index 44f84240..d409b684 100644 --- a/roomserver/internal/perform_join.go +++ b/roomserver/internal/perform_join.go @@ -14,40 +14,63 @@ import ( "github.com/sirupsen/logrus" ) -// WriteOutputEvents implements OutputRoomEventWriter +// PerformJoin handles joining matrix rooms, including over federation by talking to the federationsender. func (r *RoomserverInternalAPI) PerformJoin( ctx context.Context, req *api.PerformJoinRequest, res *api.PerformJoinResponse, -) error { +) { + roomID, err := r.performJoin(ctx, req) + if err != nil { + perr, ok := err.(*api.PerformError) + if ok { + res.Error = perr + } else { + res.Error = &api.PerformError{ + Msg: err.Error(), + } + } + } + res.RoomID = roomID +} + +func (r *RoomserverInternalAPI) performJoin( + ctx context.Context, + req *api.PerformJoinRequest, +) (string, error) { _, domain, err := gomatrixserverlib.SplitID('@', req.UserID) if err != nil { - res.Error = api.JoinErrorBadRequest - return fmt.Errorf("Supplied user ID %q in incorrect format", req.UserID) + return "", &api.PerformError{ + Code: api.PerformErrorBadRequest, + Msg: fmt.Sprintf("Supplied user ID %q in incorrect format", req.UserID), + } } if domain != r.Cfg.Matrix.ServerName { - res.Error = api.JoinErrorBadRequest - return fmt.Errorf("User %q does not belong to this homeserver", req.UserID) + return "", &api.PerformError{ + Code: api.PerformErrorBadRequest, + Msg: fmt.Sprintf("User %q does not belong to this homeserver", req.UserID), + } } if strings.HasPrefix(req.RoomIDOrAlias, "!") { - return r.performJoinRoomByID(ctx, req, res) + return r.performJoinRoomByID(ctx, req) } if strings.HasPrefix(req.RoomIDOrAlias, "#") { - return r.performJoinRoomByAlias(ctx, req, res) + return r.performJoinRoomByAlias(ctx, req) + } + return "", &api.PerformError{ + Code: api.PerformErrorBadRequest, + Msg: fmt.Sprintf("Room ID or alias %q is invalid", req.RoomIDOrAlias), } - res.Error = api.JoinErrorBadRequest - return fmt.Errorf("Room ID or alias %q is invalid", req.RoomIDOrAlias) } func (r *RoomserverInternalAPI) performJoinRoomByAlias( ctx context.Context, req *api.PerformJoinRequest, - res *api.PerformJoinResponse, -) error { +) (string, error) { // Get the domain part of the room alias. _, domain, err := gomatrixserverlib.SplitID('#', req.RoomIDOrAlias) if err != nil { - return fmt.Errorf("Alias %q is not in the correct format", req.RoomIDOrAlias) + return "", fmt.Errorf("Alias %q is not in the correct format", req.RoomIDOrAlias) } req.ServerNames = append(req.ServerNames, domain) @@ -65,7 +88,7 @@ func (r *RoomserverInternalAPI) performJoinRoomByAlias( err = r.fsAPI.PerformDirectoryLookup(ctx, &dirReq, &dirRes) if err != nil { logrus.WithError(err).Errorf("error looking up alias %q", req.RoomIDOrAlias) - return fmt.Errorf("Looking up alias %q over federation failed: %w", req.RoomIDOrAlias, err) + return "", fmt.Errorf("Looking up alias %q over federation failed: %w", req.RoomIDOrAlias, err) } roomID = dirRes.RoomID req.ServerNames = append(req.ServerNames, dirRes.ServerNames...) @@ -73,18 +96,18 @@ func (r *RoomserverInternalAPI) performJoinRoomByAlias( // Otherwise, look up if we know this room alias locally. roomID, err = r.DB.GetRoomIDForAlias(ctx, req.RoomIDOrAlias) if err != nil { - return fmt.Errorf("Lookup room alias %q failed: %w", req.RoomIDOrAlias, err) + return "", fmt.Errorf("Lookup room alias %q failed: %w", req.RoomIDOrAlias, err) } } // If the room ID is empty then we failed to look up the alias. if roomID == "" { - return fmt.Errorf("Alias %q not found", req.RoomIDOrAlias) + return "", fmt.Errorf("Alias %q not found", req.RoomIDOrAlias) } // If we do, then pluck out the room ID and continue the join. req.RoomIDOrAlias = roomID - return r.performJoinRoomByID(ctx, req, res) + return r.performJoinRoomByID(ctx, req) } // TODO: Break this function up a bit @@ -92,19 +115,14 @@ func (r *RoomserverInternalAPI) performJoinRoomByAlias( func (r *RoomserverInternalAPI) performJoinRoomByID( ctx context.Context, req *api.PerformJoinRequest, - res *api.PerformJoinResponse, // nolint:unparam -) error { - // By this point, if req.RoomIDOrAlias contained an alias, then - // it will have been overwritten with a room ID by performJoinRoomByAlias. - // We should now include this in the response so that the CS API can - // return the right room ID. - res.RoomID = req.RoomIDOrAlias - +) (string, error) { // Get the domain part of the room ID. _, domain, err := gomatrixserverlib.SplitID('!', req.RoomIDOrAlias) if err != nil { - res.Error = api.JoinErrorBadRequest - return fmt.Errorf("Room ID %q is invalid", req.RoomIDOrAlias) + return "", &api.PerformError{ + Code: api.PerformErrorBadRequest, + Msg: fmt.Sprintf("Room ID %q is invalid: %s", req.RoomIDOrAlias, err), + } } req.ServerNames = append(req.ServerNames, domain) @@ -118,7 +136,7 @@ func (r *RoomserverInternalAPI) performJoinRoomByID( Redacts: "", } if err = eb.SetUnsigned(struct{}{}); err != nil { - return fmt.Errorf("eb.SetUnsigned: %w", err) + return "", fmt.Errorf("eb.SetUnsigned: %w", err) } // It is possible for the request to include some "content" for the @@ -129,7 +147,7 @@ func (r *RoomserverInternalAPI) performJoinRoomByID( } req.Content["membership"] = gomatrixserverlib.Join if err = eb.SetContent(req.Content); err != nil { - return fmt.Errorf("eb.SetContent: %w", err) + return "", fmt.Errorf("eb.SetContent: %w", err) } // First work out if this is in response to an existing invite @@ -142,7 +160,7 @@ func (r *RoomserverInternalAPI) performJoinRoomByID( // Check if there's an invite pending. _, inviterDomain, ierr := gomatrixserverlib.SplitID('@', inviteSender) if ierr != nil { - return fmt.Errorf("gomatrixserverlib.SplitID: %w", err) + return "", fmt.Errorf("gomatrixserverlib.SplitID: %w", err) } // Check that the domain isn't ours. If it's local then we don't @@ -154,7 +172,7 @@ func (r *RoomserverInternalAPI) performJoinRoomByID( req.ServerNames = append(req.ServerNames, inviterDomain) // Perform a federated room join. - return r.performFederatedJoinRoomByID(ctx, req, res) + return req.RoomIDOrAlias, r.performFederatedJoinRoomByID(ctx, req) } } @@ -205,9 +223,12 @@ func (r *RoomserverInternalAPI) performJoinRoomByID( if err = r.InputRoomEvents(ctx, &inputReq, &inputRes); err != nil { var notAllowed *gomatrixserverlib.NotAllowed if errors.As(err, ¬Allowed) { - res.Error = api.JoinErrorNotAllowed + return "", &api.PerformError{ + Code: api.PerformErrorNotAllowed, + Msg: fmt.Sprintf("InputRoomEvents auth failed: %s", err), + } } - return fmt.Errorf("r.InputRoomEvents: %w", err) + return "", fmt.Errorf("r.InputRoomEvents: %w", err) } } @@ -216,25 +237,30 @@ func (r *RoomserverInternalAPI) performJoinRoomByID( // room. If it is then there's nothing more to do - the room just // hasn't been created yet. if domain == r.Cfg.Matrix.ServerName { - res.Error = api.JoinErrorNoRoom - return fmt.Errorf("Room ID %q does not exist", req.RoomIDOrAlias) + return "", &api.PerformError{ + Code: api.PerformErrorNoRoom, + Msg: fmt.Sprintf("Room ID %q does not exist", req.RoomIDOrAlias), + } } // Perform a federated room join. - return r.performFederatedJoinRoomByID(ctx, req, res) + return req.RoomIDOrAlias, r.performFederatedJoinRoomByID(ctx, req) default: // Something else went wrong. - return fmt.Errorf("Error joining local room: %q", err) + return "", fmt.Errorf("Error joining local room: %q", err) } - return nil + // By this point, if req.RoomIDOrAlias contained an alias, then + // it will have been overwritten with a room ID by performJoinRoomByAlias. + // We should now include this in the response so that the CS API can + // return the right room ID. + return req.RoomIDOrAlias, nil } func (r *RoomserverInternalAPI) performFederatedJoinRoomByID( ctx context.Context, req *api.PerformJoinRequest, - res *api.PerformJoinResponse, // nolint:unparam ) error { // Try joining by all of the supplied server names. fedReq := fsAPI.PerformJoinRequest{ diff --git a/roomserver/inthttp/client.go b/roomserver/inthttp/client.go index e41adb99..8a2b1204 100644 --- a/roomserver/inthttp/client.go +++ b/roomserver/inthttp/client.go @@ -3,6 +3,7 @@ package inthttp import ( "context" "errors" + "fmt" "net/http" fsInputAPI "github.com/matrix-org/dendrite/federationsender/api" @@ -24,6 +25,7 @@ const ( RoomserverInputRoomEventsPath = "/roomserver/inputRoomEvents" // Perform operations + RoomserverPerformInvitePath = "/roomserver/performInvite" RoomserverPerformJoinPath = "/roomserver/performJoin" RoomserverPerformLeavePath = "/roomserver/performLeave" RoomserverPerformBackfillPath = "/roomserver/performBackfill" @@ -146,16 +148,38 @@ func (h *httpRoomserverInternalAPI) InputRoomEvents( return httputil.PostJSON(ctx, span, h.httpClient, apiURL, request, response) } +func (h *httpRoomserverInternalAPI) PerformInvite( + ctx context.Context, + request *api.PerformInviteRequest, + response *api.PerformInviteResponse, +) { + span, ctx := opentracing.StartSpanFromContext(ctx, "PerformInvite") + defer span.Finish() + + apiURL := h.roomserverURL + RoomserverPerformInvitePath + err := httputil.PostJSON(ctx, span, h.httpClient, apiURL, request, response) + if err != nil { + response.Error = &api.PerformError{ + Msg: fmt.Sprintf("failed to communicate with roomserver: %s", err), + } + } +} + func (h *httpRoomserverInternalAPI) PerformJoin( ctx context.Context, request *api.PerformJoinRequest, response *api.PerformJoinResponse, -) error { +) { span, ctx := opentracing.StartSpanFromContext(ctx, "PerformJoin") defer span.Finish() apiURL := h.roomserverURL + RoomserverPerformJoinPath - return httputil.PostJSON(ctx, span, h.httpClient, apiURL, request, response) + err := httputil.PostJSON(ctx, span, h.httpClient, apiURL, request, response) + if err != nil { + response.Error = &api.PerformError{ + Msg: fmt.Sprintf("failed to communicate with roomserver: %s", err), + } + } } func (h *httpRoomserverInternalAPI) PerformLeave( diff --git a/roomserver/inthttp/server.go b/roomserver/inthttp/server.go index e3b81daa..1c47e87e 100644 --- a/roomserver/inthttp/server.go +++ b/roomserver/inthttp/server.go @@ -26,6 +26,17 @@ func AddRoutes(r api.RoomserverInternalAPI, internalAPIMux *mux.Router) { return util.JSONResponse{Code: http.StatusOK, JSON: &response} }), ) + internalAPIMux.Handle(RoomserverPerformInvitePath, + httputil.MakeInternalAPI("performInvite", func(req *http.Request) util.JSONResponse { + var request api.PerformInviteRequest + var response api.PerformInviteResponse + if err := json.NewDecoder(req.Body).Decode(&request); err != nil { + return util.MessageResponse(http.StatusBadRequest, err.Error()) + } + r.PerformInvite(req.Context(), &request, &response) + return util.JSONResponse{Code: http.StatusOK, JSON: &response} + }), + ) internalAPIMux.Handle(RoomserverPerformJoinPath, httputil.MakeInternalAPI("performJoin", func(req *http.Request) util.JSONResponse { var request api.PerformJoinRequest @@ -33,9 +44,7 @@ func AddRoutes(r api.RoomserverInternalAPI, internalAPIMux *mux.Router) { if err := json.NewDecoder(req.Body).Decode(&request); err != nil { return util.MessageResponse(http.StatusBadRequest, err.Error()) } - if err := r.PerformJoin(req.Context(), &request, &response); err != nil { - response.ErrMsg = err.Error() - } + r.PerformJoin(req.Context(), &request, &response) return util.JSONResponse{Code: http.StatusOK, JSON: &response} }), )