aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--dendrite-config.yaml3
-rw-r--r--setup/config/config_mscs.go2
-rw-r--r--setup/mscs/msc2946/msc2946.go366
-rw-r--r--setup/mscs/msc2946/msc2946_test.go486
-rw-r--r--setup/mscs/msc2946/storage.go183
-rw-r--r--setup/mscs/mscs.go3
6 files changed, 1041 insertions, 2 deletions
diff --git a/dendrite-config.yaml b/dendrite-config.yaml
index 585d466b..978b1800 100644
--- a/dendrite-config.yaml
+++ b/dendrite-config.yaml
@@ -257,7 +257,8 @@ media_api:
mscs:
# A list of enabled MSC's
# Currently valid values are:
- # - msc2836 (Threading, see https://github.com/matrix-org/matrix-doc/pull/2836)
+ # - msc2836 (Threading, see https://github.com/matrix-org/matrix-doc/pull/2836)
+ # - msc2946 (Spaces Summary, see https://github.com/matrix-org/matrix-doc/pull/2946)
mscs: []
database:
connection_string: file:mscs.db
diff --git a/setup/config/config_mscs.go b/setup/config/config_mscs.go
index 776d0b64..4b53495f 100644
--- a/setup/config/config_mscs.go
+++ b/setup/config/config_mscs.go
@@ -3,7 +3,7 @@ package config
type MSCs struct {
Matrix *Global `yaml:"-"`
- // The MSCs to enable, currently only `msc2836` is supported.
+ // The MSCs to enable
MSCs []string `yaml:"mscs"`
Database DatabaseOptions `yaml:"database"`
diff --git a/setup/mscs/msc2946/msc2946.go b/setup/mscs/msc2946/msc2946.go
new file mode 100644
index 00000000..244a54bc
--- /dev/null
+++ b/setup/mscs/msc2946/msc2946.go
@@ -0,0 +1,366 @@
+// Copyright 2021 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 msc2946 'Spaces Summary' implements https://github.com/matrix-org/matrix-doc/pull/2946
+package msc2946
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "sync"
+
+ "github.com/gorilla/mux"
+ chttputil "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/internal/hooks"
+ "github.com/matrix-org/dendrite/internal/httputil"
+ roomserver "github.com/matrix-org/dendrite/roomserver/api"
+ "github.com/matrix-org/dendrite/setup"
+ userapi "github.com/matrix-org/dendrite/userapi/api"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/matrix-org/util"
+ "github.com/tidwall/gjson"
+)
+
+const (
+ ConstCreateEventContentKey = "org.matrix.msc1772.type"
+ ConstSpaceChildEventType = "org.matrix.msc1772.space.child"
+ ConstSpaceParentEventType = "org.matrix.msc1772.room.parent"
+)
+
+// SpacesRequest is the request body to POST /_matrix/client/r0/rooms/{roomID}/spaces
+type SpacesRequest struct {
+ MaxRoomsPerSpace int `json:"max_rooms_per_space"`
+ Limit int `json:"limit"`
+ Batch string `json:"batch"`
+}
+
+// Defaults sets the request defaults
+func (r *SpacesRequest) Defaults() {
+ r.Limit = 100
+ r.MaxRoomsPerSpace = -1
+}
+
+// SpacesResponse is the response body to POST /_matrix/client/r0/rooms/{roomID}/spaces
+type SpacesResponse struct {
+ NextBatch string `json:"next_batch"`
+ // Rooms are nodes on the space graph.
+ Rooms []Room `json:"rooms"`
+ // Events are edges on the space graph, exclusively m.space.child or m.room.parent events
+ Events []gomatrixserverlib.ClientEvent `json:"events"`
+}
+
+// Room is a node on the space graph
+type Room struct {
+ gomatrixserverlib.PublicRoom
+ NumRefs int `json:"num_refs"`
+ RoomType string `json:"room_type"`
+}
+
+// Enable this MSC
+func Enable(
+ base *setup.BaseDendrite, rsAPI roomserver.RoomserverInternalAPI, userAPI userapi.UserInternalAPI,
+) error {
+ db, err := NewDatabase(&base.Cfg.MSCs.Database)
+ if err != nil {
+ return fmt.Errorf("Cannot enable MSC2946: %w", err)
+ }
+ hooks.Enable()
+ hooks.Attach(hooks.KindNewEventPersisted, func(headeredEvent interface{}) {
+ he := headeredEvent.(*gomatrixserverlib.HeaderedEvent)
+ hookErr := db.StoreReference(context.Background(), he)
+ if hookErr != nil {
+ util.GetLogger(context.Background()).WithError(hookErr).WithField("event_id", he.EventID()).Error(
+ "failed to StoreReference",
+ )
+ }
+ })
+
+ base.PublicClientAPIMux.Handle("/unstable/rooms/{roomID}/spaces",
+ httputil.MakeAuthAPI("spaces", userAPI, spacesHandler(db, rsAPI)),
+ ).Methods(http.MethodPost, http.MethodOptions)
+ return nil
+}
+
+func spacesHandler(db Database, rsAPI roomserver.RoomserverInternalAPI) func(*http.Request, *userapi.Device) util.JSONResponse {
+ inMemoryBatchCache := make(map[string]set)
+ return func(req *http.Request, device *userapi.Device) util.JSONResponse {
+ // Extract the room ID from the request. Sanity check request data.
+ params := mux.Vars(req)
+ roomID := params["roomID"]
+ var r SpacesRequest
+ r.Defaults()
+ if resErr := chttputil.UnmarshalJSONRequest(req, &r); resErr != nil {
+ return *resErr
+ }
+ if r.Limit > 100 {
+ r.Limit = 100
+ }
+ w := walker{
+ req: &r,
+ rootRoomID: roomID,
+ caller: device,
+ ctx: req.Context(),
+
+ db: db,
+ rsAPI: rsAPI,
+ inMemoryBatchCache: inMemoryBatchCache,
+ }
+ res := w.walk()
+ return util.JSONResponse{
+ Code: 200,
+ JSON: res,
+ }
+ }
+}
+
+type walker struct {
+ req *SpacesRequest
+ rootRoomID string
+ caller *userapi.Device
+ db Database
+ rsAPI roomserver.RoomserverInternalAPI
+ ctx context.Context
+
+ // user ID|device ID|batch_num => event/room IDs sent to client
+ inMemoryBatchCache map[string]set
+ mu sync.Mutex
+}
+
+func (w *walker) alreadySent(id string) bool {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ m, ok := w.inMemoryBatchCache[w.caller.UserID+"|"+w.caller.ID]
+ if !ok {
+ return false
+ }
+ return m[id]
+}
+
+func (w *walker) markSent(id string) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ m := w.inMemoryBatchCache[w.caller.UserID+"|"+w.caller.ID]
+ if m == nil {
+ m = make(set)
+ }
+ m[id] = true
+ w.inMemoryBatchCache[w.caller.UserID+"|"+w.caller.ID] = m
+}
+
+// nolint:gocyclo
+func (w *walker) walk() *SpacesResponse {
+ var res SpacesResponse
+ // Begin walking the graph starting with the room ID in the request in a queue of unvisited rooms
+ unvisited := []string{w.rootRoomID}
+ processed := make(set)
+ for len(unvisited) > 0 {
+ roomID := unvisited[0]
+ unvisited = unvisited[1:]
+ // If this room has already been processed, skip. NB: do not remember this between calls
+ if processed[roomID] || roomID == "" {
+ continue
+ }
+ // Mark this room as processed.
+ processed[roomID] = true
+ // Is the caller currently joined to the room or is the room `world_readable`
+ // If no, skip this room. If yes, continue.
+ if !w.authorised(roomID) {
+ continue
+ }
+ // Get all `m.space.child` and `m.room.parent` state events for the room. *In addition*, get
+ // all `m.space.child` and `m.room.parent` state events which *point to* (via `state_key` or `content.room_id`)
+ // this room. This requires servers to store reverse lookups.
+ refs, err := w.references(roomID)
+ if err != nil {
+ util.GetLogger(w.ctx).WithError(err).WithField("room_id", roomID).Error("failed to extract references for room")
+ continue
+ }
+
+ // If this room has not ever been in `rooms` (across multiple requests), extract the
+ // `PublicRoomsChunk` for this room.
+ if !w.alreadySent(roomID) {
+ pubRoom := w.publicRoomsChunk(roomID)
+ roomType := ""
+ create := w.stateEvent(roomID, "m.room.create", "")
+ if create != nil {
+ roomType = gjson.GetBytes(create.Content(), ConstCreateEventContentKey).Str
+ }
+
+ // Add the total number of events to `PublicRoomsChunk` under `num_refs`. Add `PublicRoomsChunk` to `rooms`.
+ res.Rooms = append(res.Rooms, Room{
+ PublicRoom: *pubRoom,
+ NumRefs: refs.len(),
+ RoomType: roomType,
+ })
+ }
+
+ uniqueRooms := make(set)
+
+ // If this is the root room from the original request, insert all these events into `events` if
+ // they haven't been added before (across multiple requests).
+ if w.rootRoomID == roomID {
+ for _, ev := range refs.events() {
+ if !w.alreadySent(ev.EventID()) {
+ res.Events = append(res.Events, gomatrixserverlib.HeaderedToClientEvent(
+ ev, gomatrixserverlib.FormatAll,
+ ))
+ uniqueRooms[ev.RoomID()] = true
+ uniqueRooms[SpaceTarget(ev)] = true
+ w.markSent(ev.EventID())
+ }
+ }
+ } else {
+ // Else add them to `events` honouring the `limit` and `max_rooms_per_space` values. If either
+ // are exceeded, stop adding events. If the event has already been added, do not add it again.
+ numAdded := 0
+ for _, ev := range refs.events() {
+ if w.req.Limit > 0 && len(res.Events) >= w.req.Limit {
+ break
+ }
+ if w.req.MaxRoomsPerSpace > 0 && numAdded >= w.req.MaxRoomsPerSpace {
+ break
+ }
+ if w.alreadySent(ev.EventID()) {
+ continue
+ }
+ res.Events = append(res.Events, gomatrixserverlib.HeaderedToClientEvent(
+ ev, gomatrixserverlib.FormatAll,
+ ))
+ uniqueRooms[ev.RoomID()] = true
+ uniqueRooms[SpaceTarget(ev)] = true
+ w.markSent(ev.EventID())
+ // we don't distinguish between child state events and parent state events for the purposes of
+ // max_rooms_per_space, maybe we should?
+ numAdded++
+ }
+ }
+
+ // For each referenced room ID in the events being returned to the caller (both parent and child)
+ // add the room ID to the queue of unvisited rooms. Loop from the beginning.
+ for roomID := range uniqueRooms {
+ unvisited = append(unvisited, roomID)
+ }
+ }
+ return &res
+}
+
+func (w *walker) stateEvent(roomID, evType, stateKey string) *gomatrixserverlib.HeaderedEvent {
+ var queryRes roomserver.QueryCurrentStateResponse
+ tuple := gomatrixserverlib.StateKeyTuple{
+ EventType: evType,
+ StateKey: stateKey,
+ }
+ err := w.rsAPI.QueryCurrentState(w.ctx, &roomserver.QueryCurrentStateRequest{
+ RoomID: roomID,
+ StateTuples: []gomatrixserverlib.StateKeyTuple{tuple},
+ }, &queryRes)
+ if err != nil {
+ return nil
+ }
+ return queryRes.StateEvents[tuple]
+}
+
+func (w *walker) publicRoomsChunk(roomID string) *gomatrixserverlib.PublicRoom {
+ pubRooms, err := roomserver.PopulatePublicRooms(w.ctx, []string{roomID}, w.rsAPI)
+ if err != nil {
+ util.GetLogger(w.ctx).WithError(err).Error("failed to PopulatePublicRooms")
+ return nil
+ }
+ if len(pubRooms) == 0 {
+ return nil
+ }
+ return &pubRooms[0]
+}
+
+// authorised returns true iff the user is joined this room or the room is world_readable
+func (w *walker) authorised(roomID string) bool {
+ hisVisTuple := gomatrixserverlib.StateKeyTuple{
+ EventType: gomatrixserverlib.MRoomHistoryVisibility,
+ StateKey: "",
+ }
+ roomMemberTuple := gomatrixserverlib.StateKeyTuple{
+ EventType: gomatrixserverlib.MRoomMember,
+ StateKey: w.caller.UserID,
+ }
+ var queryRes roomserver.QueryCurrentStateResponse
+ err := w.rsAPI.QueryCurrentState(w.ctx, &roomserver.QueryCurrentStateRequest{
+ RoomID: roomID,
+ StateTuples: []gomatrixserverlib.StateKeyTuple{
+ hisVisTuple, roomMemberTuple,
+ },
+ }, &queryRes)
+ if err != nil {
+ util.GetLogger(w.ctx).WithError(err).Error("failed to QueryCurrentState")
+ return false
+ }
+ memberEv := queryRes.StateEvents[roomMemberTuple]
+ hisVisEv := queryRes.StateEvents[hisVisTuple]
+ if memberEv != nil {
+ membership, _ := memberEv.Membership()
+ if membership == gomatrixserverlib.Join {
+ return true
+ }
+ }
+ if hisVisEv != nil {
+ hisVis, _ := hisVisEv.HistoryVisibility()
+ if hisVis == "world_readable" {
+ return true
+ }
+ }
+ return false
+}
+
+// references returns all references pointing to or from this room.
+func (w *walker) references(roomID string) (eventLookup, error) {
+ events, err := w.db.References(w.ctx, roomID)
+ if err != nil {
+ return nil, err
+ }
+ el := make(eventLookup)
+ for _, ev := range events {
+ el.set(ev)
+ }
+ return el, nil
+}
+
+// state event lookup across multiple rooms keyed on event type
+// NOT THREAD SAFE
+type eventLookup map[string][]*gomatrixserverlib.HeaderedEvent
+
+func (el eventLookup) set(ev *gomatrixserverlib.HeaderedEvent) {
+ evs := el[ev.Type()]
+ if evs == nil {
+ evs = make([]*gomatrixserverlib.HeaderedEvent, 0)
+ }
+ evs = append(evs, ev)
+ el[ev.Type()] = evs
+}
+
+func (el eventLookup) len() int {
+ sum := 0
+ for _, evs := range el {
+ sum += len(evs)
+ }
+ return sum
+}
+
+func (el eventLookup) events() (events []*gomatrixserverlib.HeaderedEvent) {
+ for _, evs := range el {
+ events = append(events, evs...)
+ }
+ return
+}
+
+type set map[string]bool
diff --git a/setup/mscs/msc2946/msc2946_test.go b/setup/mscs/msc2946/msc2946_test.go
new file mode 100644
index 00000000..017319dc
--- /dev/null
+++ b/setup/mscs/msc2946/msc2946_test.go
@@ -0,0 +1,486 @@
+// Copyright 2021 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 msc2946_test
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/gorilla/mux"
+ "github.com/matrix-org/dendrite/internal/hooks"
+ "github.com/matrix-org/dendrite/internal/httputil"
+ roomserver "github.com/matrix-org/dendrite/roomserver/api"
+ "github.com/matrix-org/dendrite/setup"
+ "github.com/matrix-org/dendrite/setup/config"
+ "github.com/matrix-org/dendrite/setup/mscs/msc2946"
+ userapi "github.com/matrix-org/dendrite/userapi/api"
+ "github.com/matrix-org/gomatrixserverlib"
+)
+
+var (
+ client = &http.Client{
+ Timeout: 10 * time.Second,
+ }
+)
+
+// Basic sanity check of MSC2946 logic. Tests a single room with a few state events
+// and a bit of recursion to subspaces. Makes a graph like:
+// Root
+// ____|_____
+// | | |
+// R1 R2 S1
+// |_________
+// | | |
+// R3 R4 S2
+// | <-- this link is just a parent, not a child
+// R5
+//
+// Alice is not joined to R4, but R4 is "world_readable".
+func TestMSC2946(t *testing.T) {
+ alice := "@alice:localhost"
+ // give access token to alice
+ nopUserAPI := &testUserAPI{
+ accessTokens: make(map[string]userapi.Device),
+ }
+ nopUserAPI.accessTokens["alice"] = userapi.Device{
+ AccessToken: "alice",
+ DisplayName: "Alice",
+ UserID: alice,
+ }
+ rootSpace := "!rootspace:localhost"
+ subSpaceS1 := "!subspaceS1:localhost"
+ subSpaceS2 := "!subspaceS2:localhost"
+ room1 := "!room1:localhost"
+ room2 := "!room2:localhost"
+ room3 := "!room3:localhost"
+ room4 := "!room4:localhost"
+ empty := ""
+ room5 := "!room5:localhost"
+ allRooms := []string{
+ rootSpace, subSpaceS1, subSpaceS2,
+ room1, room2, room3, room4, room5,
+ }
+ rootToR1 := mustCreateEvent(t, fledglingEvent{
+ RoomID: rootSpace,
+ Sender: alice,
+ Type: msc2946.ConstSpaceChildEventType,
+ StateKey: &room1,
+ Content: map[string]interface{}{
+ "via": []string{"localhost"},
+ "present": true,
+ },
+ })
+ rootToR2 := mustCreateEvent(t, fledglingEvent{
+ RoomID: rootSpace,
+ Sender: alice,
+ Type: msc2946.ConstSpaceChildEventType,
+ StateKey: &room2,
+ Content: map[string]interface{}{
+ "via": []string{"localhost"},
+ "present": true,
+ },
+ })
+ rootToS1 := mustCreateEvent(t, fledglingEvent{
+ RoomID: rootSpace,
+ Sender: alice,
+ Type: msc2946.ConstSpaceChildEventType,
+ StateKey: &subSpaceS1,
+ Content: map[string]interface{}{
+ "via": []string{"localhost"},
+ "present": true,
+ },
+ })
+ s1ToR3 := mustCreateEvent(t, fledglingEvent{
+ RoomID: subSpaceS1,
+ Sender: alice,
+ Type: msc2946.ConstSpaceChildEventType,
+ StateKey: &room3,
+ Content: map[string]interface{}{
+ "via": []string{"localhost"},
+ "present": true,
+ },
+ })
+ s1ToR4 := mustCreateEvent(t, fledglingEvent{
+ RoomID: subSpaceS1,
+ Sender: alice,
+ Type: msc2946.ConstSpaceChildEventType,
+ StateKey: &room4,
+ Content: map[string]interface{}{
+ "via": []string{"localhost"},
+ "present": true,
+ },
+ })
+ s1ToS2 := mustCreateEvent(t, fledglingEvent{
+ RoomID: subSpaceS1,
+ Sender: alice,
+ Type: msc2946.ConstSpaceChildEventType,
+ StateKey: &subSpaceS2,
+ Content: map[string]interface{}{
+ "via": []string{"localhost"},
+ "present": true,
+ },
+ })
+ // This is a parent link only
+ s2ToR5 := mustCreateEvent(t, fledglingEvent{
+ RoomID: room5,
+ Sender: alice,
+ Type: msc2946.ConstSpaceParentEventType,
+ StateKey: &empty,
+ Content: map[string]interface{}{
+ "room_id": subSpaceS2,
+ "via": []string{"localhost"},
+ "present": true,
+ },
+ })
+ // history visibility for R4
+ r4HisVis := mustCreateEvent(t, fledglingEvent{
+ RoomID: room4,
+ Sender: "@someone:localhost",
+ Type: gomatrixserverlib.MRoomHistoryVisibility,
+ StateKey: &empty,
+ Content: map[string]interface{}{
+ "history_visibility": "world_readable",
+ },
+ })
+ var joinEvents []*gomatrixserverlib.HeaderedEvent
+ for _, roomID := range allRooms {
+ if roomID == room4 {
+ continue // not joined to that room
+ }
+ joinEvents = append(joinEvents, mustCreateEvent(t, fledglingEvent{
+ RoomID: roomID,
+ Sender: alice,
+ StateKey: &alice,
+ Type: gomatrixserverlib.MRoomMember,
+ Content: map[string]interface{}{
+ "membership": "join",
+ },
+ }))
+ }
+ roomNameTuple := gomatrixserverlib.StateKeyTuple{
+ EventType: "m.room.name",
+ StateKey: "",
+ }
+ hisVisTuple := gomatrixserverlib.StateKeyTuple{
+ EventType: "m.room.history_visibility",
+ StateKey: "",
+ }
+ nopRsAPI := &testRoomserverAPI{
+ joinEvents: joinEvents,
+ events: map[string]*gomatrixserverlib.HeaderedEvent{
+ rootToR1.EventID(): rootToR1,
+ rootToR2.EventID(): rootToR2,
+ rootToS1.EventID(): rootToS1,
+ s1ToR3.EventID(): s1ToR3,
+ s1ToR4.EventID(): s1ToR4,
+ s1ToS2.EventID(): s1ToS2,
+ s2ToR5.EventID(): s2ToR5,
+ r4HisVis.EventID(): r4HisVis,
+ },
+ pubRoomState: map[string]map[gomatrixserverlib.StateKeyTuple]string{
+ rootSpace: {
+ roomNameTuple: "Root",
+ hisVisTuple: "shared",
+ },
+ subSpaceS1: {
+ roomNameTuple: "Sub-Space 1",
+ hisVisTuple: "joined",
+ },
+ subSpaceS2: {
+ roomNameTuple: "Sub-Space 2",
+ hisVisTuple: "shared",
+ },
+ room1: {
+ hisVisTuple: "joined",
+ },
+ room2: {
+ hisVisTuple: "joined",
+ },
+ room3: {
+ hisVisTuple: "joined",
+ },
+ room4: {
+ hisVisTuple: "world_readable",
+ },
+ room5: {
+ hisVisTuple: "joined",
+ },
+ },
+ }
+ allEvents := []*gomatrixserverlib.HeaderedEvent{
+ rootToR1, rootToR2, rootToS1,
+ s1ToR3, s1ToR4, s1ToS2,
+ s2ToR5, r4HisVis,
+ }
+ allEvents = append(allEvents, joinEvents...)
+ router := injectEvents(t, nopUserAPI, nopRsAPI, allEvents)
+ cancel := runServer(t, router)
+ defer cancel()
+
+ t.Run("returns no events for unknown rooms", func(t *testing.T) {
+ res := postSpaces(t, 200, "alice", "!unknown:localhost", newReq(t, map[string]interface{}{}))
+ if len(res.Events) > 0 {
+ t.Errorf("got %d events, want 0", len(res.Events))
+ }
+ if len(res.Rooms) > 0 {
+ t.Errorf("got %d rooms, want 0", len(res.Rooms))
+ }
+ })
+ t.Run("returns the entire graph", func(t *testing.T) {
+ res := postSpaces(t, 200, "alice", rootSpace, newReq(t, map[string]interface{}{}))
+ if len(res.Events) != 7 {
+ t.Errorf("got %d events, want 7", len(res.Events))
+ }
+ if len(res.Rooms) != len(allRooms) {
+ t.Errorf("got %d rooms, want %d", len(res.Rooms), len(allRooms))
+ }
+
+ })
+}
+
+func newReq(t *testing.T, jsonBody map[string]interface{}) *msc2946.SpacesRequest {
+ t.Helper()
+ b, err := json.Marshal(jsonBody)
+ if err != nil {
+ t.Fatalf("Failed to marshal request: %s", err)
+ }
+ var r msc2946.SpacesRequest
+ if err := json.Unmarshal(b, &r); err != nil {
+ t.Fatalf("Failed to unmarshal request: %s", err)
+ }
+ return &r
+}
+
+func runServer(t *testing.T, router *mux.Router) func() {
+ t.Helper()
+ externalServ := &http.Server{
+ Addr: string(":8010"),
+ WriteTimeout: 60 * time.Second,
+ Handler: router,
+ }
+ go func() {
+ externalServ.ListenAndServe()
+ }()
+ // wait to listen on the port
+ time.Sleep(500 * time.Millisecond)
+ return func() {
+ externalServ.Shutdown(context.TODO())
+ }
+}
+
+func postSpaces(t *testing.T, expectCode int, accessToken, roomID string, req *msc2946.SpacesRequest) *msc2946.SpacesResponse {
+ t.Helper()
+ var r msc2946.SpacesRequest
+ r.Defaults()
+ data, err := json.Marshal(req)
+ if err != nil {
+ t.Fatalf("failed to marshal request: %s", err)
+ }
+ httpReq, err := http.NewRequest(
+ "POST", "http://localhost:8010/_matrix/client/unstable/rooms/"+url.PathEscape(roomID)+"/spaces",
+ bytes.NewBuffer(data),
+ )
+ httpReq.Header.Set("Authorization", "Bearer "+accessToken)
+ if err != nil {
+ t.Fatalf("failed to prepare request: %s", err)
+ }
+ res, err := client.Do(httpReq)
+ if err != nil {
+ t.Fatalf("failed to do request: %s", err)
+ }
+ if res.StatusCode != expectCode {
+ body, _ := ioutil.ReadAll(res.Body)
+ t.Fatalf("wrong response code, got %d want %d - body: %s", res.StatusCode, expectCode, string(body))
+ }
+ if res.StatusCode == 200 {
+ var result msc2946.SpacesResponse
+ body, err := ioutil.ReadAll(res.Body)
+ if err != nil {
+ t.Fatalf("response 200 OK but failed to read response body: %s", err)
+ }
+ t.Logf("Body: %s", string(body))
+ if err := json.Unmarshal(body, &result); err != nil {
+ t.Fatalf("response 200 OK but failed to deserialise JSON : %s\nbody: %s", err, string(body))
+ }
+ return &result
+ }
+ return nil
+}
+
+type testUserAPI struct {
+ accessTokens map[string]userapi.Device
+}
+
+func (u *testUserAPI) InputAccountData(ctx context.Context, req *userapi.InputAccountDataRequest, res *userapi.InputAccountDataResponse) error {
+ return nil
+}
+func (u *testUserAPI) PerformAccountCreation(ctx context.Context, req *userapi.PerformAccountCreationRequest, res *userapi.PerformAccountCreationResponse) error {
+ return nil
+}
+func (u *testUserAPI) PerformPasswordUpdate(ctx context.Context, req *userapi.PerformPasswordUpdateRequest, res *userapi.PerformPasswordUpdateResponse) error {
+ return nil
+}
+func (u *testUserAPI) PerformDeviceCreation(ctx context.Context, req *userapi.PerformDeviceCreationRequest, res *userapi.PerformDeviceCreationResponse) error {
+ return nil
+}
+func (u *testUserAPI) PerformDeviceDeletion(ctx context.Context, req *userapi.PerformDeviceDeletionRequest, res *userapi.PerformDeviceDeletionResponse) error {
+ return nil
+}
+func (u *testUserAPI) PerformDeviceUpdate(ctx context.Context, req *userapi.PerformDeviceUpdateRequest, res *userapi.PerformDeviceUpdateResponse) error {
+ return nil
+}
+func (u *testUserAPI) PerformLastSeenUpdate(ctx context.Context, req *userapi.PerformLastSeenUpdateRequest, res *userapi.PerformLastSeenUpdateResponse) error {
+ return nil
+}
+func (u *testUserAPI) PerformAccountDeactivation(ctx context.Context, req *userapi.PerformAccountDeactivationRequest, res *userapi.PerformAccountDeactivationResponse) error {
+ return nil
+}
+func (u *testUserAPI) QueryProfile(ctx context.Context, req *userapi.QueryProfileRequest, res *userapi.QueryProfileResponse) error {
+ return nil
+}
+func (u *testUserAPI) QueryAccessToken(ctx context.Context, req *userapi.QueryAccessTokenRequest, res *userapi.QueryAccessTokenResponse) error {
+ dev, ok := u.accessTokens[req.AccessToken]
+ if !ok {
+ res.Err = fmt.Errorf("unknown token")
+ return nil
+ }
+ res.Device = &dev
+ return nil
+}
+func (u *testUserAPI) QueryDevices(ctx context.Context, req *userapi.QueryDevicesRequest, res *userapi.QueryDevicesResponse) error {
+ return nil
+}
+func (u *testUserAPI) QueryAccountData(ctx context.Context, req *userapi.QueryAccountDataRequest, res *userapi.QueryAccountDataResponse) error {
+ return nil
+}
+func (u *testUserAPI) QueryDeviceInfos(ctx context.Context, req *userapi.QueryDeviceInfosRequest, res *userapi.QueryDeviceInfosResponse) error {
+ return nil
+}
+func (u *testUserAPI) QuerySearchProfiles(ctx context.Context, req *userapi.QuerySearchProfilesRequest, res *userapi.QuerySearchProfilesResponse) error {
+ return nil
+}
+
+type testRoomserverAPI struct {
+ // use a trace API as it implements method stubs so we don't need to have them here.
+ // We'll override the functions we care about.
+ roomserver.RoomserverInternalAPITrace
+ joinEvents []*gomatrixserverlib.HeaderedEvent
+ events map[string]*gomatrixserverlib.HeaderedEvent
+ pubRoomState map[string]map[gomatrixserverlib.StateKeyTuple]string
+}
+
+func (r *testRoomserverAPI) QueryBulkStateContent(ctx context.Context, req *roomserver.QueryBulkStateContentRequest, res *roomserver.QueryBulkStateContentResponse) error {
+ res.Rooms = make(map[string]map[gomatrixserverlib.StateKeyTuple]string)
+ for _, roomID := range req.RoomIDs {
+ pubRoomData, ok := r.pubRoomState[roomID]
+ if ok {
+ res.Rooms[roomID] = pubRoomData
+ }
+ }
+ return nil
+}
+
+func (r *testRoomserverAPI) QueryCurrentState(ctx context.Context, req *roomserver.QueryCurrentStateRequest, res *roomserver.QueryCurrentStateResponse) error {
+ res.StateEvents = make(map[gomatrixserverlib.StateKeyTuple]*gomatrixserverlib.HeaderedEvent)
+ checkEvent := func(he *gomatrixserverlib.HeaderedEvent) {
+ if he.RoomID() != req.RoomID {
+ return
+ }
+ if he.StateKey() == nil {
+ return
+ }
+ tuple := gomatrixserverlib.StateKeyTuple{
+ EventType: he.Type(),
+ StateKey: *he.StateKey(),
+ }
+ for _, t := range req.StateTuples {
+ if t == tuple {
+ res.StateEvents[t] = he
+ }
+ }
+ }
+ for _, he := range r.joinEvents {
+ checkEvent(he)
+ }
+ for _, he := range r.events {
+ checkEvent(he)
+ }
+ return nil
+}
+
+func injectEvents(t *testing.T, userAPI userapi.UserInternalAPI, rsAPI roomserver.RoomserverInternalAPI, events []*gomatrixserverlib.HeaderedEvent) *mux.Router {
+ t.Helper()
+ cfg := &config.Dendrite{}
+ cfg.Defaults()
+ cfg.Global.ServerName = "localhost"
+ cfg.MSCs.Database.ConnectionString = "file:msc2946_test.db"
+ cfg.MSCs.MSCs = []string{"msc2946"}
+ base := &setup.BaseDendrite{
+ Cfg: cfg,
+ PublicClientAPIMux: mux.NewRouter().PathPrefix(httputil.PublicClientPathPrefix).Subrouter(),
+ PublicFederationAPIMux: mux.NewRouter().PathPrefix(httputil.PublicFederationPathPrefix).Subrouter(),
+ }
+
+ err := msc2946.Enable(base, rsAPI, userAPI)
+ if err != nil {
+ t.Fatalf("failed to enable MSC2946: %s", err)
+ }
+ for _, ev := range events {
+ hooks.Run(hooks.KindNewEventPersisted, ev)
+ }
+ return base.PublicClientAPIMux
+}
+
+type fledglingEvent struct {
+ Type string
+ StateKey *string
+ Content interface{}
+ Sender string
+ RoomID string
+}
+
+func mustCreateEvent(t *testing.T, ev fledglingEvent) (result *gomatrixserverlib.HeaderedEvent) {
+ t.Helper()
+ roomVer := gomatrixserverlib.RoomVersionV6
+ seed := make([]byte, ed25519.SeedSize) // zero seed
+ key := ed25519.NewKeyFromSeed(seed)
+ eb := gomatrixserverlib.EventBuilder{
+ Sender: ev.Sender,
+ Depth: 999,
+ Type: ev.Type,
+ StateKey: ev.StateKey,
+ RoomID: ev.RoomID,
+ }
+ err := eb.SetContent(ev.Content)
+ if err != nil {
+ t.Fatalf("mustCreateEvent: failed to marshal event content %+v", ev.Content)
+ }
+ // make sure the origin_server_ts changes so we can test recency
+ time.Sleep(1 * time.Millisecond)
+ signedEvent, err := eb.Build(time.Now(), gomatrixserverlib.ServerName("localhost"), "ed25519:test", key, roomVer)
+ if err != nil {
+ t.Fatalf("mustCreateEvent: failed to sign event: %s", err)
+ }
+ h := signedEvent.Headered(roomVer)
+ return h
+}
diff --git a/setup/mscs/msc2946/storage.go b/setup/mscs/msc2946/storage.go
new file mode 100644
index 00000000..eb4a5efb
--- /dev/null
+++ b/setup/mscs/msc2946/storage.go
@@ -0,0 +1,183 @@
+// Copyright 2021 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 msc2946
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/matrix-org/dendrite/internal"
+ "github.com/matrix-org/dendrite/internal/sqlutil"
+ "github.com/matrix-org/dendrite/setup/config"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/tidwall/gjson"
+)
+
+var (
+ relTypes = map[string]int{
+ ConstSpaceChildEventType: 1,
+ ConstSpaceParentEventType: 2,
+ }
+)
+
+type Database interface {
+ // StoreReference persists a child or parent space mapping.
+ StoreReference(ctx context.Context, he *gomatrixserverlib.HeaderedEvent) error
+ // References returns all events which have the given roomID as a parent or child space.
+ References(ctx context.Context, roomID string) ([]*gomatrixserverlib.HeaderedEvent, error)
+}
+
+type DB struct {
+ db *sql.DB
+ writer sqlutil.Writer
+ insertEdgeStmt *sql.Stmt
+ selectEdgesStmt *sql.Stmt
+}
+
+// NewDatabase loads the database for msc2836
+func NewDatabase(dbOpts *config.DatabaseOptions) (Database, error) {
+ if dbOpts.ConnectionString.IsPostgres() {
+ return newPostgresDatabase(dbOpts)
+ }
+ return newSQLiteDatabase(dbOpts)
+}
+
+func newPostgresDatabase(dbOpts *config.DatabaseOptions) (Database, error) {
+ d := DB{
+ writer: sqlutil.NewDummyWriter(),
+ }
+ var err error
+ if d.db, err = sqlutil.Open(dbOpts); err != nil {
+ return nil, err
+ }
+ _, err = d.db.Exec(`
+ CREATE TABLE IF NOT EXISTS msc2946_edges (
+ room_version TEXT NOT NULL,
+ -- the room ID of the event, the source of the arrow
+ source_room_id TEXT NOT NULL,
+ -- the target room ID, the arrow destination
+ dest_room_id TEXT NOT NULL,
+ -- the kind of relation, either child or parent (1,2)
+ rel_type SMALLINT NOT NULL,
+ event_json TEXT NOT NULL,
+ CONSTRAINT msc2946_edges_uniq UNIQUE (source_room_id, dest_room_id, rel_type)
+ );
+ `)
+ if err != nil {
+ return nil, err
+ }
+ if d.insertEdgeStmt, err = d.db.Prepare(`
+ INSERT INTO msc2946_edges(room_version, source_room_id, dest_room_id, rel_type, event_json)
+ VALUES($1, $2, $3, $4, $5)
+ ON CONFLICT DO NOTHING
+ `); err != nil {
+ return nil, err
+ }
+ if d.selectEdgesStmt, err = d.db.Prepare(`
+ SELECT room_version, event_json FROM msc2946_edges
+ WHERE source_room_id = $1 OR dest_room_id = $2
+ `); err != nil {
+ return nil, err
+ }
+ return &d, err
+}
+
+func newSQLiteDatabase(dbOpts *config.DatabaseOptions) (Database, error) {
+ d := DB{
+ writer: sqlutil.NewExclusiveWriter(),
+ }
+ var err error
+ if d.db, err = sqlutil.Open(dbOpts); err != nil {
+ return nil, err
+ }
+ _, err = d.db.Exec(`
+ CREATE TABLE IF NOT EXISTS msc2946_edges (
+ room_version TEXT NOT NULL,
+ -- the room ID of the event, the source of the arrow
+ source_room_id TEXT NOT NULL,
+ -- the target room ID, the arrow destination
+ dest_room_id TEXT NOT NULL,
+ -- the kind of relation, either child or parent (1,2)
+ rel_type SMALLINT NOT NULL,
+ event_json TEXT NOT NULL,
+ UNIQUE (source_room_id, dest_room_id, rel_type)
+ );
+ `)
+ if err != nil {
+ return nil, err
+ }
+ if d.insertEdgeStmt, err = d.db.Prepare(`
+ INSERT INTO msc2946_edges(room_version, source_room_id, dest_room_id, rel_type, event_json)
+ VALUES($1, $2, $3, $4, $5)
+ ON CONFLICT DO NOTHING
+ `); err != nil {
+ return nil, err
+ }
+ if d.selectEdgesStmt, err = d.db.Prepare(`
+ SELECT room_version, event_json FROM msc2946_edges
+ WHERE source_room_id = $1 OR dest_room_id = $2
+ `); err != nil {
+ return nil, err
+ }
+ return &d, err
+}
+
+func (d *DB) StoreReference(ctx context.Context, he *gomatrixserverlib.HeaderedEvent) error {
+ target := SpaceTarget(he)
+ if target == "" {
+ return nil // malformed event
+ }
+ relType := relTypes[he.Type()]
+ _, err := d.insertEdgeStmt.ExecContext(ctx, he.RoomVersion, he.RoomID(), target, relType, he.JSON())
+ return err
+}
+
+func (d *DB) References(ctx context.Context, roomID string) ([]*gomatrixserverlib.HeaderedEvent, error) {
+ rows, err := d.selectEdgesStmt.QueryContext(ctx, roomID, roomID)
+ if err != nil {
+ return nil, err
+ }
+ defer internal.CloseAndLogIfError(ctx, rows, "failed to close References")
+ refs := make([]*gomatrixserverlib.HeaderedEvent, 0)
+ for rows.Next() {
+ var roomVer string
+ var jsonBytes []byte
+ if err := rows.Scan(&roomVer, &jsonBytes); err != nil {
+ return nil, err
+ }
+ ev, err := gomatrixserverlib.NewEventFromTrustedJSON(jsonBytes, false, gomatrixserverlib.RoomVersion(roomVer))
+ if err != nil {
+ return nil, err
+ }
+ he := ev.Headered(gomatrixserverlib.RoomVersion(roomVer))
+ refs = append(refs, he)
+ }
+ return refs, nil
+}
+
+// SpaceTarget returns the destination room ID for the space event. This is either a child or a parent
+// depending on the event type.
+func SpaceTarget(he *gomatrixserverlib.HeaderedEvent) string {
+ if he.StateKey() == nil {
+ return "" // no-op
+ }
+ switch he.Type() {
+ case ConstSpaceParentEventType:
+ return gjson.GetBytes(he.Content(), "room_id").Str
+ case ConstSpaceChildEventType:
+ return *he.StateKey()
+ }
+ return ""
+}
diff --git a/setup/mscs/mscs.go b/setup/mscs/mscs.go
index a8e5668e..bf210362 100644
--- a/setup/mscs/mscs.go
+++ b/setup/mscs/mscs.go
@@ -21,6 +21,7 @@ import (
"github.com/matrix-org/dendrite/setup"
"github.com/matrix-org/dendrite/setup/mscs/msc2836"
+ "github.com/matrix-org/dendrite/setup/mscs/msc2946"
"github.com/matrix-org/util"
)
@@ -39,6 +40,8 @@ func EnableMSC(base *setup.BaseDendrite, monolith *setup.Monolith, msc string) e
switch msc {
case "msc2836":
return msc2836.Enable(base, monolith.RoomserverAPI, monolith.FederationSenderAPI, monolith.UserAPI, monolith.KeyRing)
+ case "msc2946":
+ return msc2946.Enable(base, monolith.RoomserverAPI, monolith.UserAPI)
default:
return fmt.Errorf("EnableMSC: unknown msc '%s'", msc)
}