diff --git a/clientapi/routing/room_tagging.go b/clientapi/routing/room_tagging.go new file mode 100644 index 00000000..6e7324cd --- /dev/null +++ b/clientapi/routing/room_tagging.go @@ -0,0 +1,234 @@ +// Copyright 2019 Sumukha PK +// +// 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 ( + "encoding/json" + "net/http" + + "github.com/sirupsen/logrus" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/clientapi/producers" + "github.com/matrix-org/gomatrix" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" +) + +// newTag creates and returns a new gomatrix.TagContent +func newTag() gomatrix.TagContent { + return gomatrix.TagContent{ + Tags: make(map[string]gomatrix.TagProperties), + } +} + +// GetTags implements GET /_matrix/client/r0/user/{userID}/rooms/{roomID}/tags +func GetTags( + req *http.Request, + accountDB *accounts.Database, + device *authtypes.Device, + userID string, + roomID string, + syncProducer *producers.SyncAPIProducer, +) util.JSONResponse { + + if device.UserID != userID { + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("Cannot retrieve another user's tags"), + } + } + + _, data, err := obtainSavedTags(req, userID, roomID, accountDB) + if err != nil { + return httputil.LogThenError(req, err) + } + + if len(data) == 0 { + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: data[0].Content, + } +} + +// PutTag implements PUT /_matrix/client/r0/user/{userID}/rooms/{roomID}/tags/{tag} +// Put functionality works by getting existing data from the DB (if any), adding +// the tag to the "map" and saving the new "map" to the DB +func PutTag( + req *http.Request, + accountDB *accounts.Database, + device *authtypes.Device, + userID string, + roomID string, + tag string, + syncProducer *producers.SyncAPIProducer, +) util.JSONResponse { + + if device.UserID != userID { + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("Cannot modify another user's tags"), + } + } + + var properties gomatrix.TagProperties + if reqErr := httputil.UnmarshalJSONRequest(req, &properties); reqErr != nil { + return *reqErr + } + + localpart, data, err := obtainSavedTags(req, userID, roomID, accountDB) + if err != nil { + return httputil.LogThenError(req, err) + } + + var tagContent gomatrix.TagContent + if len(data) > 0 { + if err = json.Unmarshal(data[0].Content, &tagContent); err != nil { + return httputil.LogThenError(req, err) + } + } else { + tagContent = newTag() + } + tagContent.Tags[tag] = properties + if err = saveTagData(req, localpart, roomID, accountDB, tagContent); err != nil { + return httputil.LogThenError(req, err) + } + + // Send data to syncProducer in order to inform clients of changes + // Run in a goroutine in order to prevent blocking the tag request response + go func() { + if err := syncProducer.SendData(userID, roomID, "m.tag"); err != nil { + logrus.WithError(err).Error("Failed to send m.tag account data update to syncapi") + } + }() + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} + +// DeleteTag implements DELETE /_matrix/client/r0/user/{userID}/rooms/{roomID}/tags/{tag} +// Delete functionality works by obtaining the saved tags, removing the intended tag from +// the "map" and then saving the new "map" in the DB +func DeleteTag( + req *http.Request, + accountDB *accounts.Database, + device *authtypes.Device, + userID string, + roomID string, + tag string, + syncProducer *producers.SyncAPIProducer, +) util.JSONResponse { + + if device.UserID != userID { + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("Cannot modify another user's tags"), + } + } + + localpart, data, err := obtainSavedTags(req, userID, roomID, accountDB) + if err != nil { + return httputil.LogThenError(req, err) + } + + // If there are no tags in the database, exit + if len(data) == 0 { + // Spec only defines 200 responses for this endpoint so we don't return anything else. + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } + } + + var tagContent gomatrix.TagContent + err = json.Unmarshal(data[0].Content, &tagContent) + if err != nil { + return httputil.LogThenError(req, err) + } + + // Check whether the tag to be deleted exists + if _, ok := tagContent.Tags[tag]; ok { + delete(tagContent.Tags, tag) + } else { + // Spec only defines 200 responses for this endpoint so we don't return anything else. + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } + } + if err = saveTagData(req, localpart, roomID, accountDB, tagContent); err != nil { + return httputil.LogThenError(req, err) + } + + // Send data to syncProducer in order to inform clients of changes + // Run in a goroutine in order to prevent blocking the tag request response + go func() { + if err := syncProducer.SendData(userID, roomID, "m.tag"); err != nil { + logrus.WithError(err).Error("Failed to send m.tag account data update to syncapi") + } + }() + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} + +// obtainSavedTags gets all tags scoped to a userID and roomID +// from the database +func obtainSavedTags( + req *http.Request, + userID string, + roomID string, + accountDB *accounts.Database, +) (string, []gomatrixserverlib.ClientEvent, error) { + localpart, _, err := gomatrixserverlib.SplitID('@', userID) + if err != nil { + return "", nil, err + } + + data, err := accountDB.GetAccountDataByType( + req.Context(), localpart, roomID, "m.tag", + ) + + return localpart, data, err +} + +// saveTagData saves the provided tag data into the database +func saveTagData( + req *http.Request, + localpart string, + roomID string, + accountDB *accounts.Database, + Tag gomatrix.TagContent, +) error { + newTagData, err := json.Marshal(Tag) + if err != nil { + return err + } + + return accountDB.SaveAccountData(req.Context(), localpart, roomID, "m.tag", string(newTagData)) +} diff --git a/clientapi/routing/routing.go b/clientapi/routing/routing.go index 8135e49a..ab8f8973 100644 --- a/clientapi/routing/routing.go +++ b/clientapi/routing/routing.go @@ -483,4 +483,34 @@ func Setup( }} }), ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/user/{userId}/rooms/{roomId}/tags", + common.MakeAuthAPI("get_tags", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars, err := common.URLDecodeMapValues(mux.Vars(req)) + if err != nil { + return util.ErrorResponse(err) + } + return GetTags(req, accountDB, device, vars["userId"], vars["roomId"], syncProducer) + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/user/{userId}/rooms/{roomId}/tags/{tag}", + common.MakeAuthAPI("put_tag", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars, err := common.URLDecodeMapValues(mux.Vars(req)) + if err != nil { + return util.ErrorResponse(err) + } + return PutTag(req, accountDB, device, vars["userId"], vars["roomId"], vars["tag"], syncProducer) + }), + ).Methods(http.MethodPut, http.MethodOptions) + + r0mux.Handle("/user/{userId}/rooms/{roomId}/tags/{tag}", + common.MakeAuthAPI("delete_tag", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars, err := common.URLDecodeMapValues(mux.Vars(req)) + if err != nil { + return util.ErrorResponse(err) + } + return DeleteTag(req, accountDB, device, vars["userId"], vars["roomId"], vars["tag"], syncProducer) + }), + ).Methods(http.MethodDelete, http.MethodOptions) } diff --git a/testfile b/testfile index 4c5163e5..1d97eb37 100644 --- a/testfile +++ b/testfile @@ -159,3 +159,9 @@ Inbound federation rejects remote attempts to kick local users to rooms An event which redacts itself should be ignored A pair of events which redact each other should be ignored Full state sync includes joined rooms +Can add tag +Can remove tag +Can list tags for a room +Tags appear in an initial v2 /sync +Newly updated tags appear in an incremental v2 /sync +Deleted tags appear in an incremental v2 /sync