From 002429c9e24cc746e0929b41eccbe429f89a6e1f Mon Sep 17 00:00:00 2001 From: S7evinK <2353100+S7evinK@users.noreply.github.com> Date: Fri, 18 Feb 2022 16:05:03 +0100 Subject: [PATCH] Implement server notices (#2180) * Add server_notices config * Disallow rejecting "server notice" invites * Update config * Slightly refactor sendEvent and CreateRoom so it can be reused * Implement unspecced server notices * Validate the request * Set the user api when starting * Rename function/variables * Update comments * Update config * Set the avatar on account creation * Update test * Only create the account when starting Only add routes if sever notices are enabled * Use reserver username Check that we actually got roomData * Add check for admin account Enable server notices for CI Return same values as Synapse * Add custom error for rejecting server notice invite * Move building an invite to it's own function, for reusability * Don't create new rooms, use the existing one (follow Synapse behavior) Co-authored-by: kegsay --- clientapi/jsonerror/jsonerror.go | 9 + clientapi/routing/createroom.go | 79 ++--- clientapi/routing/leaveroom.go | 6 + clientapi/routing/membership.go | 35 +- clientapi/routing/routing.go | 45 +++ clientapi/routing/sendevent.go | 52 +-- clientapi/routing/server_notices.go | 343 +++++++++++++++++++ clientapi/routing/server_notices_test.go | 83 +++++ cmd/dendrite-monolith-server/main.go | 1 + dendrite-config.yaml | 12 + roomserver/api/api.go | 5 +- roomserver/api/api_trace.go | 10 +- roomserver/api/perform.go | 2 + roomserver/internal/api.go | 5 + roomserver/internal/perform/perform_leave.go | 43 ++- roomserver/inthttp/client.go | 6 + setup/config/config_global.go | 30 ++ setup/config/config_test.go | 5 + 18 files changed, 689 insertions(+), 82 deletions(-) create mode 100644 clientapi/routing/server_notices.go create mode 100644 clientapi/routing/server_notices_test.go diff --git a/clientapi/jsonerror/jsonerror.go b/clientapi/jsonerror/jsonerror.go index caa216e62..97c597030 100644 --- a/clientapi/jsonerror/jsonerror.go +++ b/clientapi/jsonerror/jsonerror.go @@ -149,6 +149,15 @@ func MissingParam(msg string) *MatrixError { return &MatrixError{"M_MISSING_PARAM", msg} } +// LeaveServerNoticeError is an error returned when trying to reject an invite +// for a server notice room. +func LeaveServerNoticeError() *MatrixError { + return &MatrixError{ + ErrCode: "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM", + Err: "You cannot reject this invite", + } +} + type IncompatibleRoomVersionError struct { RoomVersion string `json:"room_version"` Error string `json:"error"` diff --git a/clientapi/routing/createroom.go b/clientapi/routing/createroom.go index 80ac22935..fcacc76c0 100644 --- a/clientapi/routing/createroom.go +++ b/clientapi/routing/createroom.go @@ -15,6 +15,7 @@ package routing import ( + "context" "encoding/json" "fmt" "net/http" @@ -140,33 +141,14 @@ func CreateRoom( accountDB userdb.Database, rsAPI roomserverAPI.RoomserverInternalAPI, asAPI appserviceAPI.AppServiceQueryAPI, ) util.JSONResponse { - // TODO (#267): Check room ID doesn't clash with an existing one, and we - // probably shouldn't be using pseudo-random strings, maybe GUIDs? - roomID := fmt.Sprintf("!%s:%s", util.RandomString(16), cfg.Matrix.ServerName) - return createRoom(req, device, cfg, roomID, accountDB, rsAPI, asAPI) -} - -// createRoom implements /createRoom -// nolint: gocyclo -func createRoom( - req *http.Request, device *api.Device, - cfg *config.ClientAPI, roomID string, - accountDB userdb.Database, rsAPI roomserverAPI.RoomserverInternalAPI, - asAPI appserviceAPI.AppServiceQueryAPI, -) util.JSONResponse { - logger := util.GetLogger(req.Context()) - userID := device.UserID var r createRoomRequest resErr := httputil.UnmarshalJSONRequest(req, &r) if resErr != nil { return *resErr } - // TODO: apply rate-limit - if resErr = r.Validate(); resErr != nil { return *resErr } - evTime, err := httputil.ParseTSParam(req) if err != nil { return util.JSONResponse{ @@ -174,6 +156,25 @@ func createRoom( JSON: jsonerror.InvalidArgumentValue(err.Error()), } } + return createRoom(req.Context(), r, device, cfg, accountDB, rsAPI, asAPI, evTime) +} + +// createRoom implements /createRoom +// nolint: gocyclo +func createRoom( + ctx context.Context, + r createRoomRequest, device *api.Device, + cfg *config.ClientAPI, + accountDB userdb.Database, rsAPI roomserverAPI.RoomserverInternalAPI, + asAPI appserviceAPI.AppServiceQueryAPI, + evTime time.Time, +) util.JSONResponse { + // TODO (#267): Check room ID doesn't clash with an existing one, and we + // probably shouldn't be using pseudo-random strings, maybe GUIDs? + roomID := fmt.Sprintf("!%s:%s", util.RandomString(16), cfg.Matrix.ServerName) + + logger := util.GetLogger(ctx) + userID := device.UserID // Clobber keys: creator, room_version @@ -200,16 +201,16 @@ func createRoom( "roomVersion": roomVersion, }).Info("Creating new room") - profile, err := appserviceAPI.RetrieveUserProfile(req.Context(), userID, asAPI, accountDB) + profile, err := appserviceAPI.RetrieveUserProfile(ctx, userID, asAPI, accountDB) if err != nil { - util.GetLogger(req.Context()).WithError(err).Error("appserviceAPI.RetrieveUserProfile failed") + util.GetLogger(ctx).WithError(err).Error("appserviceAPI.RetrieveUserProfile failed") return jsonerror.InternalServerError() } createContent := map[string]interface{}{} if len(r.CreationContent) > 0 { if err = json.Unmarshal(r.CreationContent, &createContent); err != nil { - util.GetLogger(req.Context()).WithError(err).Error("json.Unmarshal for creation_content failed") + util.GetLogger(ctx).WithError(err).Error("json.Unmarshal for creation_content failed") return util.JSONResponse{ Code: http.StatusBadRequest, JSON: jsonerror.BadJSON("invalid create content"), @@ -230,7 +231,7 @@ func createRoom( // Merge powerLevelContentOverride fields by unmarshalling it atop the defaults err = json.Unmarshal(r.PowerLevelContentOverride, &powerLevelContent) if err != nil { - util.GetLogger(req.Context()).WithError(err).Error("json.Unmarshal for power_level_content_override failed") + util.GetLogger(ctx).WithError(err).Error("json.Unmarshal for power_level_content_override failed") return util.JSONResponse{ Code: http.StatusBadRequest, JSON: jsonerror.BadJSON("malformed power_level_content_override"), @@ -319,9 +320,9 @@ func createRoom( } var aliasResp roomserverAPI.GetRoomIDForAliasResponse - err = rsAPI.GetRoomIDForAlias(req.Context(), &hasAliasReq, &aliasResp) + err = rsAPI.GetRoomIDForAlias(ctx, &hasAliasReq, &aliasResp) if err != nil { - util.GetLogger(req.Context()).WithError(err).Error("aliasAPI.GetRoomIDForAlias failed") + util.GetLogger(ctx).WithError(err).Error("aliasAPI.GetRoomIDForAlias failed") return jsonerror.InternalServerError() } if aliasResp.RoomID != "" { @@ -426,7 +427,7 @@ func createRoom( } err = builder.SetContent(e.Content) if err != nil { - util.GetLogger(req.Context()).WithError(err).Error("builder.SetContent failed") + util.GetLogger(ctx).WithError(err).Error("builder.SetContent failed") return jsonerror.InternalServerError() } if i > 0 { @@ -435,12 +436,12 @@ func createRoom( var ev *gomatrixserverlib.Event ev, err = buildEvent(&builder, &authEvents, cfg, evTime, roomVersion) if err != nil { - util.GetLogger(req.Context()).WithError(err).Error("buildEvent failed") + util.GetLogger(ctx).WithError(err).Error("buildEvent failed") return jsonerror.InternalServerError() } if err = gomatrixserverlib.Allowed(ev, &authEvents); err != nil { - util.GetLogger(req.Context()).WithError(err).Error("gomatrixserverlib.Allowed failed") + util.GetLogger(ctx).WithError(err).Error("gomatrixserverlib.Allowed failed") return jsonerror.InternalServerError() } @@ -448,7 +449,7 @@ func createRoom( builtEvents = append(builtEvents, ev.Headered(roomVersion)) err = authEvents.AddEvent(ev) if err != nil { - util.GetLogger(req.Context()).WithError(err).Error("authEvents.AddEvent failed") + util.GetLogger(ctx).WithError(err).Error("authEvents.AddEvent failed") return jsonerror.InternalServerError() } } @@ -462,8 +463,8 @@ func createRoom( SendAsServer: roomserverAPI.DoNotSendToOtherServers, }) } - if err = roomserverAPI.SendInputRoomEvents(req.Context(), rsAPI, inputs, false); err != nil { - util.GetLogger(req.Context()).WithError(err).Error("roomserverAPI.SendInputRoomEvents failed") + if err = roomserverAPI.SendInputRoomEvents(ctx, rsAPI, inputs, false); err != nil { + util.GetLogger(ctx).WithError(err).Error("roomserverAPI.SendInputRoomEvents failed") return jsonerror.InternalServerError() } @@ -478,9 +479,9 @@ func createRoom( } var aliasResp roomserverAPI.SetRoomAliasResponse - err = rsAPI.SetRoomAlias(req.Context(), &aliasReq, &aliasResp) + err = rsAPI.SetRoomAlias(ctx, &aliasReq, &aliasResp) if err != nil { - util.GetLogger(req.Context()).WithError(err).Error("aliasAPI.SetRoomAlias failed") + util.GetLogger(ctx).WithError(err).Error("aliasAPI.SetRoomAlias failed") return jsonerror.InternalServerError() } @@ -519,11 +520,11 @@ func createRoom( for _, invitee := range r.Invite { // Build the invite event. inviteEvent, err := buildMembershipEvent( - req.Context(), invitee, "", accountDB, device, gomatrixserverlib.Invite, + ctx, invitee, "", accountDB, device, gomatrixserverlib.Invite, roomID, true, cfg, evTime, rsAPI, asAPI, ) if err != nil { - util.GetLogger(req.Context()).WithError(err).Error("buildMembershipEvent failed") + util.GetLogger(ctx).WithError(err).Error("buildMembershipEvent failed") continue } inviteStrippedState := append( @@ -532,7 +533,7 @@ func createRoom( ) // Send the invite event to the roomserver. err = roomserverAPI.SendInvite( - req.Context(), + ctx, rsAPI, inviteEvent.Headered(roomVersion), inviteStrippedState, // invite room state @@ -544,7 +545,7 @@ func createRoom( return e.JSONResponse() case nil: default: - util.GetLogger(req.Context()).WithError(err).Error("roomserverAPI.SendInvite failed") + util.GetLogger(ctx).WithError(err).Error("roomserverAPI.SendInvite failed") return util.JSONResponse{ Code: http.StatusInternalServerError, JSON: jsonerror.InternalServerError(), @@ -556,13 +557,13 @@ func createRoom( if r.Visibility == "public" { // expose this room in the published room list var pubRes roomserverAPI.PerformPublishResponse - rsAPI.PerformPublish(req.Context(), &roomserverAPI.PerformPublishRequest{ + rsAPI.PerformPublish(ctx, &roomserverAPI.PerformPublishRequest{ RoomID: roomID, Visibility: "public", }, &pubRes) if pubRes.Error != nil { // treat as non-fatal since the room is already made by this point - util.GetLogger(req.Context()).WithError(pubRes.Error).Error("failed to visibility:public") + util.GetLogger(ctx).WithError(pubRes.Error).Error("failed to visibility:public") } } diff --git a/clientapi/routing/leaveroom.go b/clientapi/routing/leaveroom.go index 38cef118e..a34dd02d3 100644 --- a/clientapi/routing/leaveroom.go +++ b/clientapi/routing/leaveroom.go @@ -38,6 +38,12 @@ func LeaveRoomByID( // Ask the roomserver to perform the leave. if err := rsAPI.PerformLeave(req.Context(), &leaveReq, &leaveRes); err != nil { + if leaveRes.Code != 0 { + return util.JSONResponse{ + Code: leaveRes.Code, + JSON: jsonerror.LeaveServerNoticeError(), + } + } return util.JSONResponse{ Code: http.StatusBadRequest, JSON: jsonerror.Unknown(err.Error()), diff --git a/clientapi/routing/membership.go b/clientapi/routing/membership.go index 112239241..ffe8da136 100644 --- a/clientapi/routing/membership.go +++ b/clientapi/routing/membership.go @@ -226,27 +226,42 @@ func SendInvite( } } + // We already received the return value, so no need to check for an error here. + response, _ := sendInvite(req.Context(), accountDB, device, roomID, body.UserID, body.Reason, cfg, rsAPI, asAPI, evTime) + return response +} + +// sendInvite sends an invitation to a user. Returns a JSONResponse and an error +func sendInvite( + ctx context.Context, + accountDB userdb.Database, + device *userapi.Device, + roomID, userID, reason string, + cfg *config.ClientAPI, + rsAPI roomserverAPI.RoomserverInternalAPI, + asAPI appserviceAPI.AppServiceQueryAPI, evTime time.Time, +) (util.JSONResponse, error) { event, err := buildMembershipEvent( - req.Context(), body.UserID, body.Reason, accountDB, device, "invite", + ctx, userID, reason, accountDB, device, "invite", roomID, false, cfg, evTime, rsAPI, asAPI, ) if err == errMissingUserID { return util.JSONResponse{ Code: http.StatusBadRequest, JSON: jsonerror.BadJSON(err.Error()), - } + }, err } else if err == eventutil.ErrRoomNoExists { return util.JSONResponse{ Code: http.StatusNotFound, JSON: jsonerror.NotFound(err.Error()), - } + }, err } else if err != nil { - util.GetLogger(req.Context()).WithError(err).Error("buildMembershipEvent failed") - return jsonerror.InternalServerError() + util.GetLogger(ctx).WithError(err).Error("buildMembershipEvent failed") + return jsonerror.InternalServerError(), err } err = roomserverAPI.SendInvite( - req.Context(), rsAPI, + ctx, rsAPI, event, nil, // ask the roomserver to draw up invite room state for us cfg.Matrix.ServerName, @@ -254,18 +269,18 @@ func SendInvite( ) switch e := err.(type) { case *roomserverAPI.PerformError: - return e.JSONResponse() + return e.JSONResponse(), err case nil: return util.JSONResponse{ Code: http.StatusOK, JSON: struct{}{}, - } + }, nil default: - util.GetLogger(req.Context()).WithError(err).Error("roomserverAPI.SendInvite failed") + util.GetLogger(ctx).WithError(err).Error("roomserverAPI.SendInvite failed") return util.JSONResponse{ Code: http.StatusInternalServerError, JSON: jsonerror.InternalServerError(), - } + }, err } } diff --git a/clientapi/routing/routing.go b/clientapi/routing/routing.go index 63dcaa413..d75f58b81 100644 --- a/clientapi/routing/routing.go +++ b/clientapi/routing/routing.go @@ -15,6 +15,7 @@ package routing import ( + "context" "encoding/json" "net/http" "strings" @@ -117,6 +118,50 @@ func Setup( ).Methods(http.MethodGet, http.MethodPost, http.MethodOptions) } + // server notifications + if cfg.Matrix.ServerNotices.Enabled { + logrus.Info("Enabling server notices at /_synapse/admin/v1/send_server_notice") + serverNotificationSender, err := getSenderDevice(context.Background(), userAPI, accountDB, cfg) + if err != nil { + logrus.WithError(err).Fatal("unable to get account for sending sending server notices") + } + + synapseAdminRouter.Handle("/admin/v1/send_server_notice/{txnID}", + httputil.MakeAuthAPI("send_server_notice", userAPI, func(req *http.Request, device *userapi.Device) util.JSONResponse { + // not specced, but ensure we're rate limiting requests to this endpoint + if r := rateLimits.Limit(req); r != nil { + return *r + } + vars, err := httputil.URLDecodeMapValues(mux.Vars(req)) + if err != nil { + return util.ErrorResponse(err) + } + txnID := vars["txnID"] + return SendServerNotice( + req, &cfg.Matrix.ServerNotices, + cfg, userAPI, rsAPI, accountDB, asAPI, + device, serverNotificationSender, + &txnID, transactionsCache, + ) + }), + ).Methods(http.MethodPut, http.MethodOptions) + + synapseAdminRouter.Handle("/admin/v1/send_server_notice", + httputil.MakeAuthAPI("send_server_notice", userAPI, func(req *http.Request, device *userapi.Device) util.JSONResponse { + // not specced, but ensure we're rate limiting requests to this endpoint + if r := rateLimits.Limit(req); r != nil { + return *r + } + return SendServerNotice( + req, &cfg.Matrix.ServerNotices, + cfg, userAPI, rsAPI, accountDB, asAPI, + device, serverNotificationSender, + nil, transactionsCache, + ) + }), + ).Methods(http.MethodPost, http.MethodOptions) + } + // You can't just do PathPrefix("/(r0|v3)") because regexps only apply when inside named path variables. // So make a named path variable called 'apiversion' (which we will never read in handlers) and then do // (r0|v3) - BUT this is a captured group, which makes no sense because you cannot extract this group diff --git a/clientapi/routing/sendevent.go b/clientapi/routing/sendevent.go index 606107b9f..23935b5d9 100644 --- a/clientapi/routing/sendevent.go +++ b/clientapi/routing/sendevent.go @@ -15,10 +15,16 @@ package routing import ( + "context" "net/http" "sync" "time" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" + "github.com/prometheus/client_golang/prometheus" + "github.com/sirupsen/logrus" + "github.com/matrix-org/dendrite/clientapi/httputil" "github.com/matrix-org/dendrite/clientapi/jsonerror" "github.com/matrix-org/dendrite/internal/eventutil" @@ -26,10 +32,6 @@ import ( "github.com/matrix-org/dendrite/roomserver/api" "github.com/matrix-org/dendrite/setup/config" userapi "github.com/matrix-org/dendrite/userapi/api" - "github.com/matrix-org/gomatrixserverlib" - "github.com/matrix-org/util" - "github.com/prometheus/client_golang/prometheus" - "github.com/sirupsen/logrus" ) // http://matrix.org/docs/spec/client_server/r0.2.0.html#put-matrix-client-r0-rooms-roomid-send-eventtype-txnid @@ -97,7 +99,22 @@ func SendEvent( defer mutex.(*sync.Mutex).Unlock() startedGeneratingEvent := time.Now() - e, resErr := generateSendEvent(req, device, roomID, eventType, stateKey, cfg, rsAPI) + + var r map[string]interface{} // must be a JSON object + resErr := httputil.UnmarshalJSONRequest(req, &r) + if resErr != nil { + return *resErr + } + + evTime, err := httputil.ParseTSParam(req) + if err != nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.InvalidArgumentValue(err.Error()), + } + } + + e, resErr := generateSendEvent(req.Context(), r, device, roomID, eventType, stateKey, cfg, rsAPI, evTime) if resErr != nil { return *resErr } @@ -153,27 +170,16 @@ func SendEvent( } func generateSendEvent( - req *http.Request, + ctx context.Context, + r map[string]interface{}, device *userapi.Device, roomID, eventType string, stateKey *string, cfg *config.ClientAPI, rsAPI api.RoomserverInternalAPI, + evTime time.Time, ) (*gomatrixserverlib.Event, *util.JSONResponse) { // parse the incoming http request userID := device.UserID - var r map[string]interface{} // must be a JSON object - resErr := httputil.UnmarshalJSONRequest(req, &r) - if resErr != nil { - return nil, resErr - } - - evTime, err := httputil.ParseTSParam(req) - if err != nil { - return nil, &util.JSONResponse{ - Code: http.StatusBadRequest, - JSON: jsonerror.InvalidArgumentValue(err.Error()), - } - } // create the new event and set all the fields we can builder := gomatrixserverlib.EventBuilder{ @@ -182,15 +188,15 @@ func generateSendEvent( Type: eventType, StateKey: stateKey, } - err = builder.SetContent(r) + err := builder.SetContent(r) if err != nil { - util.GetLogger(req.Context()).WithError(err).Error("builder.SetContent failed") + util.GetLogger(ctx).WithError(err).Error("builder.SetContent failed") resErr := jsonerror.InternalServerError() return nil, &resErr } var queryRes api.QueryLatestEventsAndStateResponse - e, err := eventutil.QueryAndBuildEvent(req.Context(), &builder, cfg.Matrix, evTime, rsAPI, &queryRes) + e, err := eventutil.QueryAndBuildEvent(ctx, &builder, cfg.Matrix, evTime, rsAPI, &queryRes) if err == eventutil.ErrRoomNoExists { return nil, &util.JSONResponse{ Code: http.StatusNotFound, @@ -213,7 +219,7 @@ func generateSendEvent( JSON: jsonerror.BadJSON(e.Error()), } } else if err != nil { - util.GetLogger(req.Context()).WithError(err).Error("eventutil.BuildEvent failed") + util.GetLogger(ctx).WithError(err).Error("eventutil.BuildEvent failed") resErr := jsonerror.InternalServerError() return nil, &resErr } diff --git a/clientapi/routing/server_notices.go b/clientapi/routing/server_notices.go new file mode 100644 index 000000000..42a303a6b --- /dev/null +++ b/clientapi/routing/server_notices.go @@ -0,0 +1,343 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + userdb "github.com/matrix-org/dendrite/userapi/storage" + "github.com/matrix-org/gomatrix" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/gomatrixserverlib/tokens" + "github.com/matrix-org/util" + "github.com/prometheus/client_golang/prometheus" + "github.com/sirupsen/logrus" + + appserviceAPI "github.com/matrix-org/dendrite/appservice/api" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/internal/eventutil" + "github.com/matrix-org/dendrite/internal/transactions" + "github.com/matrix-org/dendrite/roomserver/api" + "github.com/matrix-org/dendrite/setup/config" + userapi "github.com/matrix-org/dendrite/userapi/api" +) + +// Unspecced server notice request +// https://github.com/matrix-org/synapse/blob/develop/docs/admin_api/server_notices.md +type sendServerNoticeRequest struct { + UserID string `json:"user_id,omitempty"` + Content struct { + MsgType string `json:"msgtype,omitempty"` + Body string `json:"body,omitempty"` + } `json:"content,omitempty"` + Type string `json:"type,omitempty"` + StateKey string `json:"state_key,omitempty"` +} + +// SendServerNotice sends a message to a specific user. It can only be invoked by an admin. +func SendServerNotice( + req *http.Request, + cfgNotices *config.ServerNotices, + cfgClient *config.ClientAPI, + userAPI userapi.UserInternalAPI, + rsAPI api.RoomserverInternalAPI, + accountsDB userdb.Database, + asAPI appserviceAPI.AppServiceQueryAPI, + device *userapi.Device, + senderDevice *userapi.Device, + txnID *string, + txnCache *transactions.Cache, +) util.JSONResponse { + if device.AccountType != userapi.AccountTypeAdmin { + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("This API can only be used by admin users."), + } + } + + if txnID != nil { + // Try to fetch response from transactionsCache + if res, ok := txnCache.FetchTransaction(device.AccessToken, *txnID); ok { + return *res + } + } + + ctx := req.Context() + var r sendServerNoticeRequest + resErr := httputil.UnmarshalJSONRequest(req, &r) + if resErr != nil { + return *resErr + } + + // check that all required fields are set + if !r.valid() { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("Invalid request"), + } + } + + // get rooms for specified user + allUserRooms := []string{} + userRooms := api.QueryRoomsForUserResponse{} + if err := rsAPI.QueryRoomsForUser(ctx, &api.QueryRoomsForUserRequest{ + UserID: r.UserID, + WantMembership: "join", + }, &userRooms); err != nil { + return util.ErrorResponse(err) + } + allUserRooms = append(allUserRooms, userRooms.RoomIDs...) + // get invites for specified user + if err := rsAPI.QueryRoomsForUser(ctx, &api.QueryRoomsForUserRequest{ + UserID: r.UserID, + WantMembership: "invite", + }, &userRooms); err != nil { + return util.ErrorResponse(err) + } + allUserRooms = append(allUserRooms, userRooms.RoomIDs...) + // get left rooms for specified user + if err := rsAPI.QueryRoomsForUser(ctx, &api.QueryRoomsForUserRequest{ + UserID: r.UserID, + WantMembership: "leave", + }, &userRooms); err != nil { + return util.ErrorResponse(err) + } + allUserRooms = append(allUserRooms, userRooms.RoomIDs...) + + // get rooms of the sender + senderUserID := fmt.Sprintf("@%s:%s", cfgNotices.LocalPart, cfgClient.Matrix.ServerName) + senderRooms := api.QueryRoomsForUserResponse{} + if err := rsAPI.QueryRoomsForUser(ctx, &api.QueryRoomsForUserRequest{ + UserID: senderUserID, + WantMembership: "join", + }, &senderRooms); err != nil { + return util.ErrorResponse(err) + } + + // check if we have rooms in common + commonRooms := []string{} + for _, userRoomID := range allUserRooms { + for _, senderRoomID := range senderRooms.RoomIDs { + if userRoomID == senderRoomID { + commonRooms = append(commonRooms, senderRoomID) + } + } + } + + if len(commonRooms) > 1 { + return util.ErrorResponse(fmt.Errorf("expected to find one room, but got %d", len(commonRooms))) + } + + var ( + roomID string + roomVersion = gomatrixserverlib.RoomVersionV6 + ) + + // create a new room for the user + if len(commonRooms) == 0 { + powerLevelContent := eventutil.InitialPowerLevelsContent(senderUserID) + powerLevelContent.Users[r.UserID] = -10 // taken from Synapse + pl, err := json.Marshal(powerLevelContent) + if err != nil { + return util.ErrorResponse(err) + } + createContent := map[string]interface{}{} + createContent["m.federate"] = false + cc, err := json.Marshal(createContent) + if err != nil { + return util.ErrorResponse(err) + } + crReq := createRoomRequest{ + Invite: []string{r.UserID}, + Name: cfgNotices.RoomName, + Visibility: "private", + Preset: presetPrivateChat, + CreationContent: cc, + GuestCanJoin: false, + RoomVersion: roomVersion, + PowerLevelContentOverride: pl, + } + + roomRes := createRoom(ctx, crReq, senderDevice, cfgClient, accountsDB, rsAPI, asAPI, time.Now()) + + switch data := roomRes.JSON.(type) { + case createRoomResponse: + roomID = data.RoomID + + // tag the room, so we can later check if the user tries to reject an invite + serverAlertTag := gomatrix.TagContent{Tags: map[string]gomatrix.TagProperties{ + "m.server_notice": { + Order: 1.0, + }, + }} + if err = saveTagData(req, r.UserID, roomID, userAPI, serverAlertTag); err != nil { + util.GetLogger(ctx).WithError(err).Error("saveTagData failed") + return jsonerror.InternalServerError() + } + + default: + // if we didn't get a createRoomResponse, we probably received an error, so return that. + return roomRes + } + + } else { + // we've found a room in common, check the membership + roomID = commonRooms[0] + // re-invite the user + res, err := sendInvite(ctx, accountsDB, senderDevice, roomID, r.UserID, "Server notice room", cfgClient, rsAPI, asAPI, time.Now()) + if err != nil { + return res + } + } + + startedGeneratingEvent := time.Now() + + request := map[string]interface{}{ + "body": r.Content.Body, + "msgtype": r.Content.MsgType, + } + e, resErr := generateSendEvent(ctx, request, senderDevice, roomID, "m.room.message", nil, cfgClient, rsAPI, time.Now()) + if resErr != nil { + logrus.Errorf("failed to send message: %+v", resErr) + return *resErr + } + timeToGenerateEvent := time.Since(startedGeneratingEvent) + + var txnAndSessionID *api.TransactionID + if txnID != nil { + txnAndSessionID = &api.TransactionID{ + TransactionID: *txnID, + SessionID: device.SessionID, + } + } + + // pass the new event to the roomserver and receive the correct event ID + // event ID in case of duplicate transaction is discarded + startedSubmittingEvent := time.Now() + if err := api.SendEvents( + ctx, rsAPI, + api.KindNew, + []*gomatrixserverlib.HeaderedEvent{ + e.Headered(roomVersion), + }, + cfgClient.Matrix.ServerName, + cfgClient.Matrix.ServerName, + txnAndSessionID, + false, + ); err != nil { + util.GetLogger(ctx).WithError(err).Error("SendEvents failed") + return jsonerror.InternalServerError() + } + util.GetLogger(ctx).WithFields(logrus.Fields{ + "event_id": e.EventID(), + "room_id": roomID, + "room_version": roomVersion, + }).Info("Sent event to roomserver") + timeToSubmitEvent := time.Since(startedSubmittingEvent) + + res := util.JSONResponse{ + Code: http.StatusOK, + JSON: sendEventResponse{e.EventID()}, + } + // Add response to transactionsCache + if txnID != nil { + txnCache.AddTransaction(device.AccessToken, *txnID, &res) + } + + // Take a note of how long it took to generate the event vs submit + // it to the roomserver. + sendEventDuration.With(prometheus.Labels{"action": "build"}).Observe(float64(timeToGenerateEvent.Milliseconds())) + sendEventDuration.With(prometheus.Labels{"action": "submit"}).Observe(float64(timeToSubmitEvent.Milliseconds())) + + return res +} + +func (r sendServerNoticeRequest) valid() (ok bool) { + if r.UserID == "" { + return false + } + if r.Content.MsgType == "" || r.Content.Body == "" { + return false + } + return true +} + +// getSenderDevice creates a user account to be used when sending server notices. +// It returns an userapi.Device, which is used for building the event +func getSenderDevice( + ctx context.Context, + userAPI userapi.UserInternalAPI, + accountDB userdb.Database, + cfg *config.ClientAPI, +) (*userapi.Device, error) { + var accRes userapi.PerformAccountCreationResponse + // create account if it doesn't exist + err := userAPI.PerformAccountCreation(ctx, &userapi.PerformAccountCreationRequest{ + AccountType: userapi.AccountTypeUser, + Localpart: cfg.Matrix.ServerNotices.LocalPart, + OnConflict: userapi.ConflictUpdate, + }, &accRes) + if err != nil { + return nil, err + } + + // set the avatarurl for the user + if err = accountDB.SetAvatarURL(ctx, cfg.Matrix.ServerNotices.LocalPart, cfg.Matrix.ServerNotices.AvatarURL); err != nil { + util.GetLogger(ctx).WithError(err).Error("accountDB.SetAvatarURL failed") + return nil, err + } + + // Check if we got existing devices + deviceRes := &userapi.QueryDevicesResponse{} + err = userAPI.QueryDevices(ctx, &userapi.QueryDevicesRequest{ + UserID: accRes.Account.UserID, + }, deviceRes) + if err != nil { + return nil, err + } + + if len(deviceRes.Devices) > 0 { + return &deviceRes.Devices[0], nil + } + + // create an AccessToken + token, err := tokens.GenerateLoginToken(tokens.TokenOptions{ + ServerPrivateKey: cfg.Matrix.PrivateKey.Seed(), + ServerName: string(cfg.Matrix.ServerName), + UserID: accRes.Account.UserID, + }) + if err != nil { + return nil, err + } + + // create a new device, if we didn't find any + var devRes userapi.PerformDeviceCreationResponse + err = userAPI.PerformDeviceCreation(ctx, &userapi.PerformDeviceCreationRequest{ + Localpart: cfg.Matrix.ServerNotices.LocalPart, + DeviceDisplayName: &cfg.Matrix.ServerNotices.LocalPart, + AccessToken: token, + NoDeviceListUpdate: true, + }, &devRes) + + if err != nil { + return nil, err + } + return devRes.Device, nil +} diff --git a/clientapi/routing/server_notices_test.go b/clientapi/routing/server_notices_test.go new file mode 100644 index 000000000..2fac072cd --- /dev/null +++ b/clientapi/routing/server_notices_test.go @@ -0,0 +1,83 @@ +package routing + +import ( + "testing" +) + +func Test_sendServerNoticeRequest_validate(t *testing.T) { + type fields struct { + UserID string `json:"user_id,omitempty"` + Content struct { + MsgType string `json:"msgtype,omitempty"` + Body string `json:"body,omitempty"` + } `json:"content,omitempty"` + Type string `json:"type,omitempty"` + StateKey string `json:"state_key,omitempty"` + } + + content := struct { + MsgType string `json:"msgtype,omitempty"` + Body string `json:"body,omitempty"` + }{ + MsgType: "m.text", + Body: "Hello world!", + } + + tests := []struct { + name string + fields fields + wantOk bool + }{ + { + name: "empty request", + fields: fields{}, + }, + { + name: "msgtype empty", + fields: fields{ + UserID: "@alice:localhost", + Content: struct { + MsgType string `json:"msgtype,omitempty"` + Body string `json:"body,omitempty"` + }{ + Body: "Hello world!", + }, + }, + }, + { + name: "msg body empty", + fields: fields{ + UserID: "@alice:localhost", + }, + }, + { + name: "statekey empty", + fields: fields{ + UserID: "@alice:localhost", + Content: content, + }, + wantOk: true, + }, + { + name: "type empty", + fields: fields{ + UserID: "@alice:localhost", + Content: content, + }, + wantOk: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := sendServerNoticeRequest{ + UserID: tt.fields.UserID, + Content: tt.fields.Content, + Type: tt.fields.Type, + StateKey: tt.fields.StateKey, + } + if gotOk := r.valid(); gotOk != tt.wantOk { + t.Errorf("valid() = %v, want %v", gotOk, tt.wantOk) + } + }) + } +} diff --git a/cmd/dendrite-monolith-server/main.go b/cmd/dendrite-monolith-server/main.go index 4d0598f3f..bb2685208 100644 --- a/cmd/dendrite-monolith-server/main.go +++ b/cmd/dendrite-monolith-server/main.go @@ -132,6 +132,7 @@ func main() { // dependency. Other components also need updating after their dependencies are up. rsImpl.SetFederationAPI(fsAPI, keyRing) rsImpl.SetAppserviceAPI(asAPI) + rsImpl.SetUserAPI(userAPI) keyImpl.SetUserAPI(userAPI) eduInputAPI := eduserver.NewInternalAPI( diff --git a/dendrite-config.yaml b/dendrite-config.yaml index 35f72222e..6d086ed77 100644 --- a/dendrite-config.yaml +++ b/dendrite-config.yaml @@ -68,6 +68,18 @@ global: # to other servers and the federation API will not be exposed. disable_federation: false + # Server notices allows server admins to send messages to all users. + server_notices: + enabled: false + # The server localpart to be used when sending notices, ensure this is not yet taken + local_part: "_server" + # The displayname to be used when sending notices + display_name: "Server alerts" + # The mxid of the avatar to use + avatar_url: "" + # The roomname to be used when creating messages + room_name: "Server Alerts" + # Configuration for NATS JetStream jetstream: # A list of NATS Server addresses to connect to. If none are specified, an diff --git a/roomserver/api/api.go b/roomserver/api/api.go index e6d37e8f1..bcbf0e4f9 100644 --- a/roomserver/api/api.go +++ b/roomserver/api/api.go @@ -3,9 +3,11 @@ package api import ( "context" + "github.com/matrix-org/gomatrixserverlib" + asAPI "github.com/matrix-org/dendrite/appservice/api" fsAPI "github.com/matrix-org/dendrite/federationapi/api" - "github.com/matrix-org/gomatrixserverlib" + userapi "github.com/matrix-org/dendrite/userapi/api" ) // RoomserverInputAPI is used to write events to the room server. @@ -14,6 +16,7 @@ type RoomserverInternalAPI interface { // interdependencies between the roomserver and other input APIs SetFederationAPI(fsAPI fsAPI.FederationInternalAPI, keyRing *gomatrixserverlib.KeyRing) SetAppserviceAPI(asAPI asAPI.AppServiceQueryAPI) + SetUserAPI(userAPI userapi.UserInternalAPI) InputRoomEvents( ctx context.Context, diff --git a/roomserver/api/api_trace.go b/roomserver/api/api_trace.go index 16f52abb7..88b372154 100644 --- a/roomserver/api/api_trace.go +++ b/roomserver/api/api_trace.go @@ -5,10 +5,12 @@ import ( "encoding/json" "fmt" - asAPI "github.com/matrix-org/dendrite/appservice/api" - fsAPI "github.com/matrix-org/dendrite/federationapi/api" "github.com/matrix-org/gomatrixserverlib" "github.com/matrix-org/util" + + asAPI "github.com/matrix-org/dendrite/appservice/api" + fsAPI "github.com/matrix-org/dendrite/federationapi/api" + userapi "github.com/matrix-org/dendrite/userapi/api" ) // RoomserverInternalAPITrace wraps a RoomserverInternalAPI and logs the @@ -25,6 +27,10 @@ func (t *RoomserverInternalAPITrace) SetAppserviceAPI(asAPI asAPI.AppServiceQuer t.Impl.SetAppserviceAPI(asAPI) } +func (t *RoomserverInternalAPITrace) SetUserAPI(userAPI userapi.UserInternalAPI) { + t.Impl.SetUserAPI(userAPI) +} + func (t *RoomserverInternalAPITrace) InputRoomEvents( ctx context.Context, req *InputRoomEventsRequest, diff --git a/roomserver/api/perform.go b/roomserver/api/perform.go index 51cbcb1ad..d640858a6 100644 --- a/roomserver/api/perform.go +++ b/roomserver/api/perform.go @@ -95,6 +95,8 @@ type PerformLeaveRequest struct { } type PerformLeaveResponse struct { + Code int `json:"code,omitempty"` + Message interface{} `json:"message,omitempty"` } type PerformInviteRequest struct { diff --git a/roomserver/internal/api.go b/roomserver/internal/api.go index e58f11c13..10c8c844e 100644 --- a/roomserver/internal/api.go +++ b/roomserver/internal/api.go @@ -15,6 +15,7 @@ import ( "github.com/matrix-org/dendrite/roomserver/storage" "github.com/matrix-org/dendrite/setup/config" "github.com/matrix-org/dendrite/setup/process" + userapi "github.com/matrix-org/dendrite/userapi/api" "github.com/matrix-org/gomatrixserverlib" "github.com/nats-io/nats.go" "github.com/sirupsen/logrus" @@ -159,6 +160,10 @@ func (r *RoomserverInternalAPI) SetFederationAPI(fsAPI fsAPI.FederationInternalA } } +func (r *RoomserverInternalAPI) SetUserAPI(userAPI userapi.UserInternalAPI) { + r.Leaver.UserAPI = userAPI +} + func (r *RoomserverInternalAPI) SetAppserviceAPI(asAPI asAPI.AppServiceQueryAPI) { r.asAPI = asAPI } diff --git a/roomserver/internal/perform/perform_leave.go b/roomserver/internal/perform/perform_leave.go index 12784e5f5..49ddd4810 100644 --- a/roomserver/internal/perform/perform_leave.go +++ b/roomserver/internal/perform/perform_leave.go @@ -16,25 +16,29 @@ package perform import ( "context" + "encoding/json" "fmt" "strings" + "github.com/matrix-org/gomatrix" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" + "github.com/sirupsen/logrus" + fsAPI "github.com/matrix-org/dendrite/federationapi/api" "github.com/matrix-org/dendrite/roomserver/api" "github.com/matrix-org/dendrite/roomserver/internal/helpers" "github.com/matrix-org/dendrite/roomserver/internal/input" "github.com/matrix-org/dendrite/roomserver/storage" "github.com/matrix-org/dendrite/setup/config" - "github.com/matrix-org/gomatrixserverlib" - "github.com/matrix-org/util" - "github.com/sirupsen/logrus" + userapi "github.com/matrix-org/dendrite/userapi/api" ) type Leaver struct { - Cfg *config.RoomServer - DB storage.Database - FSAPI fsAPI.FederationInternalAPI - + Cfg *config.RoomServer + DB storage.Database + FSAPI fsAPI.FederationInternalAPI + UserAPI userapi.UserInternalAPI Inputer *input.Inputer } @@ -85,6 +89,31 @@ func (r *Leaver) performLeaveRoomByID( if host != r.Cfg.Matrix.ServerName { return r.performFederatedRejectInvite(ctx, req, res, senderUser, eventID) } + // check that this is not a "server notice room" + accData := &userapi.QueryAccountDataResponse{} + if err := r.UserAPI.QueryAccountData(ctx, &userapi.QueryAccountDataRequest{ + UserID: req.UserID, + RoomID: req.RoomID, + DataType: "m.tag", + }, accData); err != nil { + return nil, fmt.Errorf("unable to query account data") + } + + if roomData, ok := accData.RoomAccountData[req.RoomID]; ok { + tagData, ok := roomData["m.tag"] + if ok { + tags := gomatrix.TagContent{} + if err = json.Unmarshal(tagData, &tags); err != nil { + return nil, fmt.Errorf("unable to unmarshal tag content") + } + if _, ok = tags.Tags["m.server_notice"]; ok { + // mimic the returned values from Synapse + res.Message = "You cannot reject this invite" + res.Code = 403 + return nil, fmt.Errorf("You cannot reject this invite") + } + } + } } // There's no invite pending, so first of all we want to find out diff --git a/roomserver/inthttp/client.go b/roomserver/inthttp/client.go index a61404efe..99c596606 100644 --- a/roomserver/inthttp/client.go +++ b/roomserver/inthttp/client.go @@ -11,6 +11,8 @@ import ( "github.com/matrix-org/dendrite/internal/caching" "github.com/matrix-org/dendrite/internal/httputil" "github.com/matrix-org/dendrite/roomserver/api" + userapi "github.com/matrix-org/dendrite/userapi/api" + "github.com/matrix-org/gomatrixserverlib" "github.com/opentracing/opentracing-go" ) @@ -90,6 +92,10 @@ func (h *httpRoomserverInternalAPI) SetFederationAPI(fsAPI fsInputAPI.Federation func (h *httpRoomserverInternalAPI) SetAppserviceAPI(asAPI asAPI.AppServiceQueryAPI) { } +// SetUserAPI no-ops in HTTP client mode as there is no chicken/egg scenario +func (h *httpRoomserverInternalAPI) SetUserAPI(userAPI userapi.UserInternalAPI) { +} + // SetRoomAlias implements RoomserverAliasAPI func (h *httpRoomserverInternalAPI) SetRoomAlias( ctx context.Context, diff --git a/setup/config/config_global.go b/setup/config/config_global.go index 6f2306a6d..b947f2076 100644 --- a/setup/config/config_global.go +++ b/setup/config/config_global.go @@ -57,6 +57,9 @@ type Global struct { // DNS caching options for all outbound HTTP requests DNSCache DNSCacheOptions `yaml:"dns_cache"` + + // ServerNotices configuration used for sending server notices + ServerNotices ServerNotices `yaml:"server_notices"` } func (c *Global) Defaults(generate bool) { @@ -72,6 +75,7 @@ func (c *Global) Defaults(generate bool) { c.Metrics.Defaults(generate) c.DNSCache.Defaults() c.Sentry.Defaults() + c.ServerNotices.Defaults(generate) } func (c *Global) Verify(configErrs *ConfigErrors, isMonolith bool) { @@ -82,6 +86,7 @@ func (c *Global) Verify(configErrs *ConfigErrors, isMonolith bool) { c.Metrics.Verify(configErrs, isMonolith) c.Sentry.Verify(configErrs, isMonolith) c.DNSCache.Verify(configErrs, isMonolith) + c.ServerNotices.Verify(configErrs, isMonolith) } type OldVerifyKeys struct { @@ -123,6 +128,31 @@ func (c *Metrics) Defaults(generate bool) { func (c *Metrics) Verify(configErrs *ConfigErrors, isMonolith bool) { } +// ServerNotices defines the configuration used for sending server notices +type ServerNotices struct { + Enabled bool `yaml:"enabled"` + // The localpart to be used when sending notices + LocalPart string `yaml:"local_part"` + // The displayname to be used when sending notices + DisplayName string `yaml:"display_name"` + // The avatar of this user + AvatarURL string `yaml:"avatar"` + // The roomname to be used when creating messages + RoomName string `yaml:"room_name"` +} + +func (c *ServerNotices) Defaults(generate bool) { + if generate { + c.Enabled = true + c.LocalPart = "_server" + c.DisplayName = "Server Alert" + c.RoomName = "Server Alert" + c.AvatarURL = "" + } +} + +func (c *ServerNotices) Verify(errors *ConfigErrors, isMonolith bool) {} + // The configuration to use for Sentry error reporting type Sentry struct { Enabled bool `yaml:"enabled"` diff --git a/setup/config/config_test.go b/setup/config/config_test.go index 97c98e57f..8f7611f0a 100644 --- a/setup/config/config_test.go +++ b/setup/config/config_test.go @@ -58,6 +58,11 @@ global: basic_auth: username: metrics password: metrics + server_notices: + local_part: "_server" + display_name: "Server alerts" + avatar: "" + room_name: "Server Alerts" app_service_api: internal_api: listen: http://localhost:7777