aboutsummaryrefslogtreecommitdiff
path: root/clientapi
diff options
context:
space:
mode:
authorS7evinK <2353100+S7evinK@users.noreply.github.com>2022-02-18 16:05:03 +0100
committerGitHub <noreply@github.com>2022-02-18 16:05:03 +0100
commit002429c9e24cc746e0929b41eccbe429f89a6e1f (patch)
tree217eaea280343f46c61868a694d75fccd8fe66af /clientapi
parentdbded875257703eb63c8eb8af8d47d74c811642f (diff)
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 <kegan@matrix.org>
Diffstat (limited to 'clientapi')
-rw-r--r--clientapi/jsonerror/jsonerror.go9
-rw-r--r--clientapi/routing/createroom.go79
-rw-r--r--clientapi/routing/leaveroom.go6
-rw-r--r--clientapi/routing/membership.go35
-rw-r--r--clientapi/routing/routing.go45
-rw-r--r--clientapi/routing/sendevent.go52
-rw-r--r--clientapi/routing/server_notices.go343
-rw-r--r--clientapi/routing/server_notices_test.go83
8 files changed, 580 insertions, 72 deletions
diff --git a/clientapi/jsonerror/jsonerror.go b/clientapi/jsonerror/jsonerror.go
index caa216e6..97c59703 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 80ac2293..fcacc76c 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 38cef118..a34dd02d 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 11223924..ffe8da13 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 63dcaa41..d75f58b8 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 606107b9..23935b5d 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 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
+}
diff --git a/clientapi/routing/server_notices_test.go b/clientapi/routing/server_notices_test.go
new file mode 100644
index 00000000..2fac072c
--- /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)
+ }
+ })
+ }
+}