@page "/HSAdmin/Synapse/RoomQuery" @using System.Diagnostics.CodeAnalysis @using System.Text.Json @using ArcaneLibs.Blazor.Components.Services @using Microsoft.AspNetCore.WebUtilities @using ArcaneLibs.Extensions @using LibMatrix @using LibMatrix.EventTypes.Spec.State.RoomInfo @using LibMatrix.Homeservers.Extensions.NamedCaches @using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters @using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses @using MatrixUtils.Web.Pages.HSAdmin.Synapse.Components @using MatrixUtils.Web.Pages.HSAdmin.Synapse.Components.RoomQuery @inject ILogger Logger @inject BlazorSaveFileService BlazorSaveFileService

Homeserver Administration - Room Query




(Execute manually)
(Execute manually)
(Execute manually)

@if (EnableMultiPurge) { [Invert selection]
Edit purge options
} else {
}
Local filtering (slow)
Search @if (EnableMultiPurge) { Purge selected rooms }
@if (Results.Count > 0) {

Found @Results.Count rooms

} @foreach (var room in Results) {
@* *@

@if (EnableMultiPurge) { } @if (!string.IsNullOrWhiteSpace(room.CanonicalAlias)) { @room.CanonicalAlias - } @room.RoomId @if (!string.IsNullOrWhiteSpace(room.Name)) { (@room.Name) }
@if (!string.IsNullOrWhiteSpace(room.Creator)) { Created by @room.Creator
}

Delete room Resync state @(room.JoinedLocalMembers == 0 ? "Try to export state" : "Export state") Force Join

@{ List flags = []; if (room.JoinedLocalMembers > 0) { flags.Add(room.JoinRules switch { "public" => "Public", "invite" => "Invite only", "knock" => "Knock", "restricted" => "Restricted", "knock_restricted" => "Knock + restricted", // TODO: default? null => null, "" => null, _ => "unknown join rule: " + room.JoinRules }); if (!string.IsNullOrWhiteSpace(room.Encryption)) flags.Add("encrypted"); if (!room.Federatable) flags.Add("unfederated"); flags.Add(room.HistoryVisibility switch { "world_readable" => "world readable history", "shared" => "shared history", "invited" => "history since invite", "joined" => "history since join", // TODO: default? null => null, "" => null, _ => "unknown history setting: " + room.HistoryVisibility }); flags.Add(room.GuestAccess switch { "can_join" => "guests allowed", "forbidden" => null, // TODO: default? null => null, "" => null, _ => "unknown guest access: " + room.GuestAccess, }); flags = flags.Where(x => x != null).ToList(); } } @string.Join(", ", flags) @if (room.JoinedLocalMembers == 0 && flags.Count > 0) { at the time of leaving }
@room.StateEvents state events, room version @(room.Version ?? "1")
@if (room.TombstoneEvent is not null) { var tombstoneContent = room.TombstoneEvent.ContentAs()!; Room is tombstoned! Target room: @tombstoneContent.ReplacementRoom, message: @tombstoneContent.Body
} @{ var memberSummary = room.MemberSummary; if (room.LocalMembers is not null) { memberSummary += $": {string.Join(", ", room.LocalMembers)}"; } } @memberSummary
@if (!string.IsNullOrWhiteSpace(room.TopicEvent?.ContentAs()?.Topic)) {
Room topic
@(room.TopicEvent?.ContentAs()?.Topic)
} @foreach (var ex in room.Exceptions) { @ex
} @if (ShowFullResultData) {
Full result data
@room.ToJson(ignoreNull: true)
}
} @* *@ @* @if (DeleteRequest.HasValue) { *@ @* *@ @* *@ @* *@ @* } *@ @* @foreach (var (roomId, status) in DeleteStatuses) { *@ @* *@ @*
@status.ToJson()
*@ @*
*@ @* } *@ @foreach (var (roomId, deleteRequest) in DeleteRequests) { } @code { [Parameter] [SupplyParameterFromQuery(Name = "order_by")] public string? OrderBy { get; set; } [Parameter] [SupplyParameterFromQuery(Name = "name_search")] public string? SearchTerm { get; set; } [Parameter] [SupplyParameterFromQuery(Name = "ascending")] public bool Ascending { get; set; } = true; [Parameter] [SupplyParameterFromQuery(Name = "FetchV12PlusCreatorServer")] public bool FetchV12PlusCreatorServer { get; set; } = true; [Parameter] [SupplyParameterFromQuery(Name = "SummarizeLocalMembers")] public bool SummarizeLocalMembers { get; set; } = true; [Parameter] [SupplyParameterFromQuery(Name = "FetchTombstones")] public bool FetchTombstones { get; set; } = true; private List Results { get; set; } = new(); private AuthenticatedHomeserverSynapse Homeserver { get; set; } = null!; private SynapseAdminLocalRoomQueryFilter Filter { get; set; } = new(); private Dictionary DeleteRequests { get; set; } = []; // private Dictionary DeleteStatuses { get; set; } = new(); private NamedCache TaskMap { get; set; } = null!; private SynapseRoomShutdownWindowContent.RoomShutdownContext DefaultShutdownContext { get; set; } = new() { RoomId = "", DeleteRequest = new() { Block = true, Purge = true, ForcePurge = false } }; public bool ShowFullResultData { get; set { field = value; StateHasChanged(); } } public bool EnableMultiPurge { get; set; } protected override async Task OnInitializedAsync() { var hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true); if (hs is not AuthenticatedHomeserverSynapse synapse) { NavigationManager.NavigateTo("/"); return; } Homeserver = synapse; TaskMap = new NamedCache(Homeserver, "gay.rory.matrixutils.synapse_room_shutdown_tasks"); DeleteRequests = (await TaskMap.ReadCacheMapAsync()).Where(x => x.Value.DeleteId is not null).ToDictionary(); StateHasChanged(); } protected override Task OnParametersSetAsync() { OrderBy ??= "name"; var execute = false; foreach (var (key, value) in QueryHelpers.ParseQuery(new Uri(NavigationManager.Uri).Query)) { switch (key) { case "RoomIdContains": Filter.RoomId.Enabled = Filter.RoomId.CheckValueContains = true; Filter.RoomId.ValueContains = value[0]!; break; case "NameContains": Filter.Name.Enabled = Filter.Name.CheckValueContains = true; Filter.Name.ValueContains = value[0]!; break; case "CanonicalAliasContains": Filter.CanonicalAlias.Enabled = Filter.CanonicalAlias.CheckValueContains = true; Filter.CanonicalAlias.ValueContains = value[0]!; break; case "VersionContains": Filter.Version.Enabled = Filter.Version.CheckValueContains = true; Filter.Version.ValueContains = value[0]!; break; case "CreatorContains": Filter.Creator.Enabled = Filter.Creator.CheckValueContains = true; Filter.Creator.ValueContains = value[0]!; break; case "EncryptionContains": Filter.Encryption.Enabled = Filter.Encryption.CheckValueContains = true; Filter.Encryption.ValueContains = value[0]!; break; case "JoinRulesContains": Filter.JoinRules.Enabled = Filter.JoinRules.CheckValueContains = true; Filter.JoinRules.ValueContains = value[0]!; break; case "GuestAccessContains": Filter.GuestAccess.Enabled = Filter.GuestAccess.CheckValueContains = true; Filter.GuestAccess.ValueContains = value[0]!; break; case "HistoryVisibilityContains": Filter.HistoryVisibility.Enabled = Filter.HistoryVisibility.CheckValueContains = true; Filter.HistoryVisibility.ValueContains = value[0]!; break; case "Federatable": Filter.Federation = new() { Enabled = true, Value = bool.Parse(value[0]!) }; break; case "Public": Filter.Public = new() { Enabled = true, Value = bool.Parse(value[0]!) }; break; case "JoinedMembersGreaterThan": Filter.JoinedMembers.Enabled = Filter.JoinedLocalMembers.CheckGreaterThan = true; Filter.JoinedMembers.GreaterThan = int.Parse(value[0]!); break; case "JoinedMembersLessThan": Filter.JoinedMembers.Enabled = Filter.JoinedLocalMembers.CheckLessThan = true; Filter.JoinedMembers.LessThan = int.Parse(value[0]!); break; case "JoinedLocalMembersGreaterThan": Filter.JoinedLocalMembers.Enabled = Filter.JoinedLocalMembers.CheckGreaterThan = true; Filter.JoinedLocalMembers.GreaterThan = int.Parse(value[0]!); break; case "JoinedLocalMembersLessThan": Filter.JoinedLocalMembers.Enabled = Filter.JoinedLocalMembers.CheckLessThan = true; Filter.JoinedLocalMembers.LessThan = int.Parse(value[0]!); break; case "StateEventsGreaterThan": Filter.StateEvents.Enabled = Filter.StateEvents.CheckGreaterThan = true; Filter.StateEvents.GreaterThan = int.Parse(value[0]!); break; case "StateEventsLessThan": Filter.StateEvents.Enabled = Filter.StateEvents.CheckLessThan = true; Filter.StateEvents.LessThan = int.Parse(value[0]!); break; case "Execute": execute = true; break; case "order_by": case "name_search": case "ascending": case "FetchV12PlusCreatorServer": case "SummarizeLocalMembers": case "FetchTombstones": break; default: Console.WriteLine($"Unknown query parameter: {key}"); break; } } StateHasChanged(); if (execute) _ = Search(); return Task.CompletedTask; } private async Task Search() { Results.Clear(); Console.WriteLine("Starting search... Parameters: " + new { orderBy = OrderBy!, dir = Ascending ? "f" : "b", searchTerm = SearchTerm, localFilter = Filter, chunkLimit = 1000, fetchTombstones = FetchTombstones, fetchTopics = true, fetchCreateEvents = true }.ToJson()); var searchRooms = Homeserver.Admin.SearchRoomsAsync( orderBy: OrderBy!, dir: Ascending ? "f" : "b", searchTerm: SearchTerm, localFilter: Filter, chunkLimit: 1000, fetchTombstones: FetchTombstones, fetchTopics: true, fetchCreateEvents: true ).GetAsyncEnumerator(); var joinedRooms = await Homeserver.GetJoinedRooms(); while (await searchRooms.MoveNextAsync()) { var room = searchRooms.Current; var roomInfo = new RoomInfo { RoomId = room.RoomId, Name = room.Name, CanonicalAlias = room.CanonicalAlias, Creator = room.Creator, Version = room.Version, Encryption = room.Encryption, Federatable = room.Federatable, Public = room.Public, JoinRules = room.JoinRules, GuestAccess = room.GuestAccess, HistoryVisibility = room.HistoryVisibility, StateEvents = room.StateEvents, JoinedMembers = room.JoinedMembers, JoinedLocalMembers = room.JoinedLocalMembers, OriginHomeserver = Homeserver.GetRoom(room.RoomId).IsV12PlusRoomId ? room.RoomId.Split(':', 2).Skip(1).FirstOrDefault(string.Empty) : string.Empty }; if (string.IsNullOrWhiteSpace(roomInfo.OriginHomeserver) && FetchV12PlusCreatorServer) { try { if (joinedRooms.Any(x => x.RoomId == room.RoomId)) roomInfo.OriginHomeserver = await Homeserver.GetRoom(room.RoomId).GetOriginHomeserverAsync(); else roomInfo.OriginHomeserver = (await Homeserver.Admin.GetRoomStateAsync(room.RoomId, RoomCreateEventContent.EventId)).Events.FirstOrDefault()?.Sender?.Split(':', 2)[1]; } catch (MatrixException e) { roomInfo.Exceptions.Add($"While getting origin homeserver: {e.GetAsObject().ToJson(indent: false, ignoreNull: true)}"); } } Results.Add(roomInfo); if ((Results.Count <= 200 && Results.Count % 10 == 0 && FetchV12PlusCreatorServer) || Results.Count % 1000 == 0) { StateHasChanged(); await Task.Yield(); await Task.Delay(1); } } StateHasChanged(); if (FetchV12PlusCreatorServer) await FetchV12PlusCreatorServersAsync(false); if (SummarizeLocalMembers) await FetchLocalMemberEventsAsync(false); // if (CheckTombstone) await FetchTombstoneEventsAsync(false); StateHasChanged(); } private Task DeleteRoom(RoomInfo room, bool executeWithoutConfirmation = false) { var dc = JsonSerializer.Deserialize(DefaultShutdownContext.ToJson())!; dc.RoomId = room.RoomId; dc.RoomDetails = room; dc.ExecuteImmediately = executeWithoutConfirmation; DeleteRequests.TryAdd(room.RoomId, dc); StateHasChanged(); return Task.CompletedTask; } private void PurgeSelection() { foreach (var room in Results.Where(x => x.MultiPurgeSelected)) { DeleteRoom(room, true); } } private readonly Dictionary validOrderBy = new() { { "name", "Room name" }, { "canonical_alias", "Main alias address" }, { "joined_members", "Number of members (reversed)" }, { "joined_local_members", "Number of local members (reversed)" }, { "version", "Room version" }, { "creator", "Creator of the room" }, { "encryption", "End-to-end encryption algorithm" }, { "federatable", "Is room federated" }, { "public", "Visibility in room list" }, { "join_rules", "Join rules" }, { "guest_access", "Guest access" }, { "history_visibility", "Visibility of history" }, { "state_events", "Number of state events" } }; private class RoomInfo : SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom { public List? LocalMembers { get; set; } public required string OriginHomeserver { get; set; } [field: AllowNull, MaybeNull] public string MemberSummary => field ??= $"{JoinedMembers} members, of which {JoinedLocalMembers} are on this server"; public List Exceptions { get; set; } = []; public bool MultiPurgeSelected { get; set; } } private async Task ExportState(RoomInfo room) { try { var state = await Homeserver.Admin.GetRoomStateAsync(room.RoomId); var json = state.ToJson(); await BlazorSaveFileService.SaveFileAsync($"{room.RoomId.Replace(":", "_")}_state.json", System.Text.Encoding.UTF8.GetBytes(json), "application/json"); } catch (Exception e) { Logger.LogError(e, "Failed to export room state for {RoomId}", room.RoomId); } } private async Task ForceJoin(RoomInfo room) { try { await Homeserver.GetRoom(room.RoomId).JoinAsync([Homeserver.ServerName]); } catch (Exception e) { Logger.LogError(e, "Failed to force-join room {RoomId}", room.RoomId); // await Homeserver.Admin.room } } private SemaphoreSlim _concurrencyLimiter = new SemaphoreSlim(16, 16); private async Task FetchV12PlusCreatorServersAsync() => await FetchV12PlusCreatorServersAsync(true); private async Task FetchV12PlusCreatorServersAsync(bool rerender) { var joinedRooms = await Homeserver.GetJoinedRooms(); var tasks = Results .Where(x => string.IsNullOrWhiteSpace(x.OriginHomeserver)) .Select(async r => { if (!string.IsNullOrWhiteSpace(r.Creator) && r.Creator.Contains(':')) { r.OriginHomeserver = r.Creator.Split(':', 2)[1]; return; } if (r.CreateEvent != null && !string.IsNullOrWhiteSpace(r.CreateEvent.Sender) && r.CreateEvent.Sender.Contains(':')) { r.OriginHomeserver = r.CreateEvent.Sender.Split(':', 2)[1]; return; } await _concurrencyLimiter.WaitAsync(); try { if (joinedRooms.Any(x => x.RoomId == r.RoomId)) r.OriginHomeserver = await Homeserver.GetRoom(r.RoomId).GetOriginHomeserverAsync(); else r.OriginHomeserver = (await Homeserver.Admin.GetRoomStateAsync(r.RoomId, RoomCreateEventContent.EventId)).Events.FirstOrDefault()?.Sender?.Split(':', 2)[1]; } catch (MatrixException e) { r.Exceptions.Add($"While getting origin homeserver: {e.GetAsObject().ToJson(indent: false, ignoreNull: true)}"); } catch (Exception e) { Console.WriteLine($"Failed to get origin homeserver for {r.RoomId}, unhandled exception: " + e); } finally { _concurrencyLimiter.Release(); } }); await Task.WhenAll(tasks); if (rerender) StateHasChanged(); } private async Task FetchTombstoneEventsAsync() => await FetchTombstoneEventsAsync(true); private async Task FetchTombstoneEventsAsync(bool rerender) { var getTombstoneTasks = Results .Where(x => x.TombstoneEvent is null) .Select(async r => { await _concurrencyLimiter.WaitAsync(); try { var state = await Homeserver.Admin.GetRoomStateAsync(r.RoomId, type: "m.room.tombstone"); var tombstone = state.Events.FirstOrDefault(x => x is { StateKey: "", Type: "m.room.tombstone" }); if (tombstone is { } tombstoneEvent) { r.TombstoneEvent = tombstoneEvent; } } catch (MatrixException e) { r.Exceptions.Add($"While checking for tombstone: {e.GetAsObject().ToJson(indent: false, ignoreNull: true)}"); } catch (Exception e) { Console.WriteLine($"Failed to check tombstone for {r.RoomId}, unhandled exception: " + e); } finally { _concurrencyLimiter.Release(); } }); await Task.WhenAll(getTombstoneTasks); if (rerender) StateHasChanged(); } private async Task FetchLocalMemberEventsAsync() => await FetchLocalMemberEventsAsync(true); private async Task FetchLocalMemberEventsAsync(bool rerender) { var getLocalMembersTasks = Results .Where(x => x.LocalMembers is null && x.JoinedLocalMembers is > 0 and < 100) .Select(async r => { await _concurrencyLimiter.WaitAsync(); try { var members = (await Homeserver.Admin.GetRoomMembersAsync(r.RoomId)).Members.Where(x => x.EndsWith(":" + Homeserver.ServerName)).ToList(); r.LocalMembers = members; } catch (MatrixException e) { r.Exceptions.Add($"While fetching local members: {e.GetAsObject().ToJson(ignoreNull: true, indent: false)}"); } catch (Exception e) { Console.WriteLine($"Failed to fetch local members for {r.RoomId}, unhandled exception: " + e); } finally { _concurrencyLimiter.Release(); } }); await Task.WhenAll(getLocalMembersTasks); if (rerender) StateHasChanged(); } private void MultiPurgeInvertSelection() { foreach (var room in Results) { room.MultiPurgeSelected ^= true; } StateHasChanged(); } }