From 5c4dbc0b94efd843e0b4a726aa50f313174a5ea7 Mon Sep 17 00:00:00 2001 From: Till Faelligen <2353100+S7evinK@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:39:46 +0100 Subject: [PATCH 1/4] Add a way to query empty rooms --- roomserver/api/api.go | 4 +++- roomserver/roomserver_test.go | 32 +++++++++++++++++++++++++ roomserver/storage/interface.go | 3 +++ roomserver/storage/shared/storage.go | 35 ++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/roomserver/api/api.go b/roomserver/api/api.go index 35f1d0b62..3d713a02d 100644 --- a/roomserver/api/api.go +++ b/roomserver/api/api.go @@ -89,6 +89,8 @@ type RoomserverInternalAPI interface { // RoomsWithACLs returns all room IDs for rooms with ACLs RoomsWithACLs(ctx context.Context) ([]string, error) + // EmptyRooms returns all rooms that the local server has left. + EmptyRooms(ctx context.Context) ([]string, error) } type UserRoomPrivateKeyCreator interface { @@ -263,7 +265,7 @@ type ClientRoomserverAPI interface { // If true, then the alias has not been set to the provided room, as it already in use. SetRoomAlias(ctx context.Context, senderID spec.SenderID, roomID spec.RoomID, alias string) (aliasAlreadyExists bool, err error) - //RemoveRoomAlias(ctx context.Context, req *RemoveRoomAliasRequest, res *RemoveRoomAliasResponse) error + // RemoveRoomAlias(ctx context.Context, req *RemoveRoomAliasRequest, res *RemoveRoomAliasResponse) error // Removes a room alias, as provided sender. // // Returns whether the alias was found, whether it was removed, and an error (if any occurred) diff --git a/roomserver/roomserver_test.go b/roomserver/roomserver_test.go index 659ad7141..96ab279fc 100644 --- a/roomserver/roomserver_test.go +++ b/roomserver/roomserver_test.go @@ -1319,3 +1319,35 @@ func TestRoomsWithACLs(t *testing.T) { assert.Equal(t, []string{aclRoom.ID}, roomsWithACLs) }) } + +func TestEmptyRooms(t *testing.T) { + ctx := context.Background() + alice := test.NewUser(t) + r1 := test.NewRoom(t, alice) + r2 := test.NewRoom(t, alice) + + r2.CreateAndInsert(t, alice, spec.MRoomMember, map[string]interface{}{"membership": spec.Leave}, test.WithStateKey(alice.ID)) + + test.WithAllDatabases(t, func(t *testing.T, dbType test.DBType) { + cfg, processCtx, closeDB := testrig.CreateConfig(t, dbType) + defer closeDB() + + cm := sqlutil.NewConnectionManager(processCtx, cfg.Global.DatabaseOptions) + natsInstance := &jetstream.NATSInstance{} + caches := caching.NewRistrettoCache(128*1024*1024, time.Hour, caching.DisableMetrics) + // start JetStream listeners + rsAPI := roomserver.NewInternalAPI(processCtx, cfg, cm, natsInstance, caches, caching.DisableMetrics) + rsAPI.SetFederationAPI(nil, nil) + + for _, room := range []*test.Room{r1, r2} { + // Create the rooms + err := api.SendEvents(ctx, rsAPI, api.KindNew, room.Events(), "test", "test", "test", nil, false) + assert.NoError(t, err) + } + + // We should only have r2 as an empty room + emptyRooms, err := rsAPI.EmptyRooms(ctx) + assert.NoError(t, err) + assert.Equal(t, []string{r2.ID}, emptyRooms) + }) +} diff --git a/roomserver/storage/interface.go b/roomserver/storage/interface.go index 3bdeeef8b..e81d34e30 100644 --- a/roomserver/storage/interface.go +++ b/roomserver/storage/interface.go @@ -187,6 +187,9 @@ type Database interface { // RoomsWithACLs returns all room IDs for rooms with ACLs RoomsWithACLs(ctx context.Context) ([]string, error) + + // EmptyRooms returns all rooms that the local server has left. + EmptyRooms(ctx context.Context) ([]string, error) // GetBulkStateACLs returns all server ACLs for the given rooms. GetBulkStateACLs(ctx context.Context, roomIDs []string) ([]tables.StrippedEvent, error) QueryAdminEventReports(ctx context.Context, from uint64, limit uint64, backwards bool, userID string, roomID string) ([]api.QueryAdminEventReportsResponse, int64, error) diff --git a/roomserver/storage/shared/storage.go b/roomserver/storage/shared/storage.go index 31c16c34b..cfa9e37e1 100644 --- a/roomserver/storage/shared/storage.go +++ b/roomserver/storage/shared/storage.go @@ -1707,6 +1707,41 @@ func (d *Database) RoomsWithACLs(ctx context.Context) ([]string, error) { return roomIDs, nil } +// EmptyRooms returns all rooms that the local server has left. +func (d *Database) EmptyRooms(ctx context.Context) ([]string, error) { + // Get all rooms with m.room.member events, which should be all rooms we know about + eventTypeNID, err := d.GetOrCreateEventTypeNID(ctx, spec.MRoomMember) + if err != nil { + return nil, err + } + + roomNIDs, err := d.EventsTable.SelectRoomsWithEventTypeNID(ctx, nil, eventTypeNID) + if err != nil { + return nil, err + } + + // Figure out if we are joined to the rooms + leftRoomsNIDs := make([]types.RoomNID, 0, len(roomNIDs)) + for i := 0; i < len(roomNIDs); i++ { + inRoom, err := d.GetLocalServerInRoom(ctx, roomNIDs[i]) + if err != nil { + return nil, err + } + if inRoom { + continue + } + // Server is not in the room anymore + leftRoomsNIDs = append(leftRoomsNIDs, roomNIDs[i]) + } + + roomIDs, err := d.RoomsTable.BulkSelectRoomIDs(ctx, nil, leftRoomsNIDs) + if err != nil { + return nil, err + } + + return roomIDs, nil +} + // ForgetRoom sets a users room to forgotten func (d *Database) ForgetRoom(ctx context.Context, userID, roomID string, forget bool) error { roomNIDs, err := d.RoomsTable.BulkSelectRoomNIDs(ctx, nil, []string{roomID}) From 82bd52532dae1e55075b12786c496994acde9585 Mon Sep 17 00:00:00 2001 From: Till Faelligen <2353100+S7evinK@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:15:32 +0100 Subject: [PATCH 2/4] Wire everything up to the admin API Signed-off-by: Till Faelligen <2353100+S7evinK@users.noreply.github.com> --- clientapi/routing/admin.go | 19 ++++++++++++++++++- clientapi/routing/routing.go | 6 ++++++ roomserver/api/api.go | 1 + roomserver/internal/perform/perform_admin.go | 4 ++++ roomserver/internal/query/query.go | 5 +++++ 5 files changed, 34 insertions(+), 1 deletion(-) diff --git a/clientapi/routing/admin.go b/clientapi/routing/admin.go index 48e58209c..0fbeefb67 100644 --- a/clientapi/routing/admin.go +++ b/clientapi/routing/admin.go @@ -73,7 +73,7 @@ func AdminCreateNewRegistrationToken(req *http.Request, cfg *config.ClientAPI, u } if len(token) > 64 { - //Token present in request body, but is too long. + // Token present in request body, but is too long. return util.JSONResponse{ Code: http.StatusBadRequest, JSON: spec.BadJSON("token must not be longer than 64"), @@ -578,6 +578,23 @@ func DeleteEventReport(req *http.Request, rsAPI roomserverAPI.ClientRoomserverAP } } +func QueryEmptyRooms(req *http.Request, rsAPI roomserverAPI.ClientRoomserverAPI) util.JSONResponse { + emptyRooms, err := rsAPI.AdminQueryEmptyRooms(req.Context()) + if err != nil { + logrus.WithError(err).Error("failed to query empty rooms") + return util.JSONResponse{ + Code: http.StatusInternalServerError, + JSON: spec.InternalServerError{}, + } + } + return util.JSONResponse{ + Code: http.StatusOK, + JSON: map[string]any{ + "empty_rooms": emptyRooms, + }, + } +} + func parseUint64OrDefault(input string, defaultValue uint64) uint64 { v, err := strconv.ParseUint(input, 10, 64) if err != nil { diff --git a/clientapi/routing/routing.go b/clientapi/routing/routing.go index f0aa087db..61d84e792 100644 --- a/clientapi/routing/routing.go +++ b/clientapi/routing/routing.go @@ -243,6 +243,12 @@ func Setup( }), ).Methods(http.MethodPost, http.MethodOptions) + dendriteAdminRouter.Handle("/admin/emptyRooms", + httputil.MakeAdminAPI("admin_empty_rooms", userAPI, func(req *http.Request, device *userapi.Device) util.JSONResponse { + return QueryEmptyRooms(req, rsAPI) + }), + ).Methods(http.MethodGet, http.MethodOptions) + // server notifications if cfg.Matrix.ServerNotices.Enabled { logrus.Info("Enabling server notices at /_synapse/admin/v1/send_server_notice") diff --git a/roomserver/api/api.go b/roomserver/api/api.go index 3d713a02d..1c6419b3f 100644 --- a/roomserver/api/api.go +++ b/roomserver/api/api.go @@ -250,6 +250,7 @@ type ClientRoomserverAPI interface { PerformAdminEvacuateUser(ctx context.Context, userID string) (affected []string, err error) PerformAdminPurgeRoom(ctx context.Context, roomID string) error PerformAdminDownloadState(ctx context.Context, roomID, userID string, serverName spec.ServerName) error + AdminQueryEmptyRooms(ctx context.Context) ([]string, error) PerformPeek(ctx context.Context, req *PerformPeekRequest) (roomID string, err error) PerformUnpeek(ctx context.Context, roomID, userID, deviceID string) error PerformInvite(ctx context.Context, req *PerformInviteRequest) error diff --git a/roomserver/internal/perform/perform_admin.go b/roomserver/internal/perform/perform_admin.go index da8c92a6b..f48baa5b2 100644 --- a/roomserver/internal/perform/perform_admin.go +++ b/roomserver/internal/perform/perform_admin.go @@ -350,3 +350,7 @@ func (r *Admin) PerformAdminDownloadState( func (r *Admin) PerformAdminDeleteEventReport(ctx context.Context, reportID uint64) error { return r.DB.AdminDeleteEventReport(ctx, reportID) } + +func (r *Admin) AdminQueryEmptyRooms(ctx context.Context) ([]string, error) { + return r.DB.EmptyRooms(ctx) +} diff --git a/roomserver/internal/query/query.go b/roomserver/internal/query/query.go index 37de303b0..55b5f6e82 100644 --- a/roomserver/internal/query/query.go +++ b/roomserver/internal/query/query.go @@ -1097,6 +1097,11 @@ func (r *Queryer) RoomsWithACLs(ctx context.Context) ([]string, error) { return r.DB.RoomsWithACLs(ctx) } +// EmptyRooms returns all rooms that the local server has left. +func (r *Queryer) EmptyRooms(ctx context.Context) ([]string, error) { + return r.DB.EmptyRooms(ctx) +} + // QueryAdminEventReports returns event reports given a filter. func (r *Queryer) QueryAdminEventReports(ctx context.Context, from uint64, limit uint64, backwards bool, userID, roomID string) ([]api.QueryAdminEventReportsResponse, int64, error) { return r.DB.QueryAdminEventReports(ctx, from, limit, backwards, userID, roomID) From cf4076a6a7ba9ddd53a317613ef18e48e80919fd Mon Sep 17 00:00:00 2001 From: Till Faelligen <2353100+S7evinK@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:18:54 +0100 Subject: [PATCH 3/4] Simplify return statement --- roomserver/storage/shared/storage.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/roomserver/storage/shared/storage.go b/roomserver/storage/shared/storage.go index cfa9e37e1..587f3becd 100644 --- a/roomserver/storage/shared/storage.go +++ b/roomserver/storage/shared/storage.go @@ -1734,12 +1734,7 @@ func (d *Database) EmptyRooms(ctx context.Context) ([]string, error) { leftRoomsNIDs = append(leftRoomsNIDs, roomNIDs[i]) } - roomIDs, err := d.RoomsTable.BulkSelectRoomIDs(ctx, nil, leftRoomsNIDs) - if err != nil { - return nil, err - } - - return roomIDs, nil + return d.RoomsTable.BulkSelectRoomIDs(ctx, nil, leftRoomsNIDs) } // ForgetRoom sets a users room to forgotten From 3df3278ad7f8535fb332d0bac11e5a947c10a538 Mon Sep 17 00:00:00 2001 From: Till Faelligen <2353100+S7evinK@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:23:22 +0100 Subject: [PATCH 4/4] Add short entpoint docs --- docs/administration/4_adminapi.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/administration/4_adminapi.md b/docs/administration/4_adminapi.md index 56b4fad50..25667b810 100644 --- a/docs/administration/4_adminapi.md +++ b/docs/administration/4_adminapi.md @@ -77,6 +77,19 @@ This endpoint instructs Dendrite to immediately query `/devices/{userID}` on a f This endpoint instructs Dendrite to remove the given room from its database. It does **NOT** remove media files. Depending on the size of the room, this may take a while. Will return an empty JSON once other components were instructed to delete the room. +## GET `/_dendrite/admin/emptyRooms` + +Returns a list of all rooms which have zero (locally) joined members. Response format: + +```json +{ + "empty_rooms": [ + "!roomid1:server_name", + "!roomid2:server_name" + ] +} +``` + ## POST `/_synapse/admin/v1/send_server_notice` Request body format: