diff options
Diffstat (limited to 'clientapi/routing/server_notices.go')
-rw-r--r-- | clientapi/routing/server_notices.go | 343 |
1 files changed, 343 insertions, 0 deletions
diff --git a/clientapi/routing/server_notices.go b/clientapi/routing/server_notices.go new file mode 100644 index 00000000..42a303a6 --- /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 +} |