about summary refs log tree commit diff
path: root/MatrixUtils.Web/Pages/HSAdmin
diff options
context:
space:
mode:
Diffstat (limited to 'MatrixUtils.Web/Pages/HSAdmin')
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/HSAdmin.razor13
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/HSE/ManageExternalProfiles.razor43
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor201
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/Synapse/BackgroundJobs.razor29
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/Synapse/BlockMedia.razor192
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/Synapse/BlockMedia.razor.css (renamed from MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor.css)0
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor74
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor.css35
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryResult.razor5
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindow.razor5
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor266
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor618
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor.css7
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/Synapse/SubTools/SynapseRoomStateResync.razor211
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/Synapse/UserList.razor243
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/Synapse/UserList.razor.css7
16 files changed, 1746 insertions, 203 deletions
diff --git a/MatrixUtils.Web/Pages/HSAdmin/HSAdmin.razor b/MatrixUtils.Web/Pages/HSAdmin/HSAdmin.razor

index 9c61431..21b0972 100644 --- a/MatrixUtils.Web/Pages/HSAdmin/HSAdmin.razor +++ b/MatrixUtils.Web/Pages/HSAdmin/HSAdmin.razor
@@ -1,5 +1,6 @@ @page "/HSAdmin" @using ArcaneLibs.Extensions +@using LibMatrix.Responses.Federation <h3>Homeserver Admininistration</h3> <hr/> @@ -10,7 +11,15 @@ else { @if (Homeserver is AuthenticatedHomeserverSynapse) { <h4>Synapse tools</h4> <hr/> - <a href="/HSAdmin/RoomQuery">Query rooms</a> + <a href="/HSAdmin/Synapse/RoomQuery">Query rooms</a><br/> + <a href="/HSAdmin/Synapse/UserQuery">Query users</a><br/> + <a href="/HSAdmin/Synapse/BlockMedia">Block media</a><br/> + <a href="/HSAdmin/Synapse/BackgroundJobs">View running background jobs</a><br/> + } + else if (Homeserver is AuthenticatedHomeserverHSE) { + <h4>Rory&amp;::LibMatrix.HomeserverEmulator tools</h4> + <hr/> + <a href="/HSAdmin/HSE/ManageExternalProfiles">Manage external profiles</a> } else { <p>Homeserver type @Homeserver.GetType().Name does not have any administration tools in RMU.</p> @@ -24,7 +33,7 @@ else { public ServerVersionResponse? ServerVersionResponse { get; set; } protected override async Task OnInitializedAsync() { - Homeserver = await RMUStorage.GetCurrentSessionOrNavigate(); + Homeserver = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true); if (Homeserver is null) return; ServerVersionResponse = await (Homeserver.FederationClient?.GetServerVersionAsync() ?? Task.FromResult<ServerVersionResponse?>(null)); await base.OnInitializedAsync(); diff --git a/MatrixUtils.Web/Pages/HSAdmin/HSE/ManageExternalProfiles.razor b/MatrixUtils.Web/Pages/HSAdmin/HSE/ManageExternalProfiles.razor new file mode 100644
index 0000000..ec2ec54 --- /dev/null +++ b/MatrixUtils.Web/Pages/HSAdmin/HSE/ManageExternalProfiles.razor
@@ -0,0 +1,43 @@ +@page "/HSAdmin/HSE/ManageExternalProfiles" +@using ArcaneLibs.Extensions +@using LibMatrix.Responses +<h3>Manage external profiles</h3> + +<LinkButton OnClickAsync="AddAllLocalProfiles">Add local sessions</LinkButton> + +@foreach(var p in ExternalProfiles) +{ + <h4>@p.Key</h4> + <pre>@p.Value.ToJson(indent: true)</pre> +} + +@code { + public AuthenticatedHomeserverGeneric? Homeserver { get; set; } + private Dictionary<string, LoginResponse> ExternalProfiles = new(); + + protected override async Task OnInitializedAsync() + { + Homeserver = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true); + if (Homeserver is null) return; + await LoadProfiles(); + await base.OnInitializedAsync(); + } + + private async Task LoadProfiles() { + if(Homeserver is AuthenticatedHomeserverHSE hse) + { + ExternalProfiles = await hse.GetExternalProfilesAsync(); + } + StateHasChanged(); + } + + private async Task AddAllLocalProfiles() { + if(Homeserver is AuthenticatedHomeserverHSE hse) { + var sessions = await sessionStore.GetAllSessions(); + foreach(var session in sessions) { + await hse.SetExternalProfile(session.Value.Auth.UserId, session.Value.Auth); + } + await LoadProfiles(); + } + } +} \ No newline at end of file diff --git a/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor b/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor deleted file mode 100644
index 11df261..0000000 --- a/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor +++ /dev/null
@@ -1,201 +0,0 @@ -@page "/HSAdmin/RoomQuery" -@using LibMatrix.Responses.Admin -@using LibMatrix.Filters -@using ArcaneLibs.Extensions - -<h3>Homeserver Administration - Room Query</h3> - -<label>Search name: </label> -<InputText @bind-Value="SearchTerm"/><br/> -<label>Order by: </label> -<select @bind="OrderBy"> - @foreach (var item in validOrderBy) { - <option value="@item.Key">@item.Value</option> - } -</select><br/> -<label>Ascending: </label> -<InputCheckbox @bind-Value="Ascending"/><br/> -<details> - <summary> - <span>Local filtering (slow)</span> - - </summary> - <div style="margin-left: 8px; margin-bottom: 8px;"> - <u style="display: block;">String contains</u> - <span class="tile tile280">Room ID: <FancyTextBox @bind-Value="@Filter.RoomIdContains"></FancyTextBox></span> - <span class="tile tile280">Room name: <FancyTextBox @bind-Value="@Filter.NameContains"></FancyTextBox></span> - <span class="tile tile280">Canonical alias: <FancyTextBox @bind-Value="@Filter.CanonicalAliasContains"></FancyTextBox></span> - <span class="tile tile280">Creator: <FancyTextBox @bind-Value="@Filter.CreatorContains"></FancyTextBox></span> - <span class="tile tile280">Room version: <FancyTextBox @bind-Value="@Filter.VersionContains"></FancyTextBox></span> - <span class="tile tile280">Encryption algorithm: <FancyTextBox @bind-Value="@Filter.EncryptionContains"></FancyTextBox></span> - <span class="tile tile280">Join rules: <FancyTextBox @bind-Value="@Filter.JoinRulesContains"></FancyTextBox></span> - <span class="tile tile280">Guest access: <FancyTextBox @bind-Value="@Filter.GuestAccessContains"></FancyTextBox></span> - <span class="tile tile280">History visibility: <FancyTextBox @bind-Value="@Filter.HistoryVisibilityContains"></FancyTextBox></span> - - <u style="display: block;">Optional checks</u> - <span class="tile tile150"> - <InputCheckbox @bind-Value="@Filter.CheckFederation"></InputCheckbox> Is federated: - @if (Filter.CheckFederation) { - <InputCheckbox @bind-Value="@Filter.Federatable"></InputCheckbox> - } - </span> - <span class="tile tile150"> - <InputCheckbox @bind-Value="@Filter.CheckPublic"></InputCheckbox> Is public: - @if (Filter.CheckPublic) { - <InputCheckbox @bind-Value="@Filter.Public"></InputCheckbox> - } - </span> - - <u style="display: block;">Ranges</u> - <span class="tile center-children"> - <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.StateEventsGreaterThan"></InputNumber><span class="range-sep">state events</span><InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.StateEventsLessThan"></InputNumber> - </span> - <span class="tile center-children"> - <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedMembersGreaterThan"></InputNumber><span class="range-sep">members</span><InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedMembersLessThan"></InputNumber> - </span> - <span class="tile center-children"> - <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedLocalMembersGreaterThan"></InputNumber><span class="range-sep">local members</span><InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedLocalMembersLessThan"></InputNumber> - </span> - </div> -</details> -<button class="btn btn-primary" @onclick="Search">Search</button> -<br/> - -@if (Results.Count > 0) { - <p>Found @Results.Count rooms</p> - <details> - <summary>TSV data (copy/paste)</summary> - <pre style="font-size: 0.6em;"> - <table> - @foreach (var res in Results) { - <tr> - <td style="padding: 8px;">@res.RoomId@("\t")</td> - <td style="padding: 8px;">@res.CanonicalAlias@("\t")</td> - <td style="padding: 8px;">@res.Creator@("\t")</td> - <td style="padding: 8px;">@res.Name</td> - </tr> - } - </table> - </pre> - </details> -} - -@foreach (var res in Results) { - <div style="background-color: #ffffff11; border-radius: 0.5em; display: block; margin-top: 4px; padding: 4px;"> - @* <RoomListItem RoomName="@res.Name" RoomId="@res.RoomId"></RoomListItem> *@ - <p> - @if (!string.IsNullOrWhiteSpace(res.CanonicalAlias)) { - <span>@res.CanonicalAlias - @res.RoomId (@res.Name)</span> - <br/> - } - else { - <span>@res.RoomId (@res.Name)</span> - <br/> - } - @if (!string.IsNullOrWhiteSpace(res.Creator)) { - @* <span>Created by <InlineUserItem UserId="@res.Creator"></InlineUserItem></span> *@ - <span>Created by @res.Creator</span> - <br/> - } - </p> - <span>@res.StateEvents state events</span><br/> - <span>@res.JoinedMembers members, of which @res.JoinedLocalMembers are on this server</span> - <details> - <summary>Full result data</summary> - <pre>@res.ToJson(ignoreNull: true)</pre> - </details> - </div> -} - -<style> - .int-input { - width: 128px; - } - .tile { - display: inline-block; - padding: 4px; - border: 1px solid #ffffff22; - } - .tile280 { - min-width: 280px; - } - .tile150 { - min-width: 150px; - } - .range-sep { - display: inline-block; - padding: 4px; - width: 150px; - } - .range-sep::before { - content: "@("<") "; - } - .range-sep::after { - content: " @("<")"; - } - .center-children { - text-align: center; - } -</style> - -@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; } - - public List<AdminRoomListingResult.AdminRoomListingResultRoom> Results { get; set; } = new(); - - private string Status { get; set; } - - public LocalRoomQueryFilter Filter { get; set; } = new(); - - protected override Task OnParametersSetAsync() { - if (Ascending == null) - Ascending = true; - OrderBy ??= "name"; - return Task.CompletedTask; - } - - private async Task Search() { - Results.Clear(); - var hs = await RMUStorage.GetCurrentSessionOrNavigate(); - if (hs is AuthenticatedHomeserverSynapse synapse) { - var searchRooms = synapse.Admin.SearchRoomsAsync(orderBy: OrderBy!, dir: Ascending ? "f" : "b", searchTerm: SearchTerm, localFilter: Filter).GetAsyncEnumerator(); - while (await searchRooms.MoveNextAsync()) { - var room = searchRooms.Current; - Console.WriteLine("Hit: " + room.ToJson(false)); - Results.Add(room); - if (Results.Count % 10 == 0) - StateHasChanged(); - } - } - - StateHasChanged(); - } - - private readonly Dictionary<string, string> 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" } - }; - -} diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/BackgroundJobs.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/BackgroundJobs.razor new file mode 100644
index 0000000..d855cba --- /dev/null +++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/BackgroundJobs.razor
@@ -0,0 +1,29 @@ +@page "/HSAdmin/Synapse/BackgroundJobs" +@using ArcaneLibs.Extensions +@using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses + +<h3>Homeserver Administration - Background jobs</h3> +<pre>@BackgroundJobStatus?.ToJson(ignoreNull: true)</pre> + +@code { + private AuthenticatedHomeserverSynapse? Homeserver { get; set; } + private SynapseAdminBackgroundUpdateStatusResponse? BackgroundJobStatus { get; set; } + + protected override async Task OnInitializedAsync() { + var hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true) as AuthenticatedHomeserverSynapse; + if (hs is null) return; + Homeserver = hs; + + while (true) { + try { + BackgroundJobStatus = await hs.Admin.GetBackgroundUpdatesStatusAsync(); + StateHasChanged(); + await Task.Delay(1000); + } + catch (Exception ex) { + Console.WriteLine(ex); + } + } + } + +} diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/BlockMedia.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/BlockMedia.razor new file mode 100644
index 0000000..5ccaca9 --- /dev/null +++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/BlockMedia.razor
@@ -0,0 +1,192 @@ +@page "/HSAdmin/Synapse/BlockMedia" +@using System.Text.Json +@using ArcaneLibs.Extensions +@using LibMatrix +@using LibMatrix.EventTypes.Spec +@using LibMatrix.StructuredData + +<h3>Homeserver Administration - Block media</h3> +@if (Homeserver is not null) { + <label>Event URL: </label> + <FancyTextBox @bind-Value="EventUrl"/> + <br/> + <label>Event JSON: </label> + <details> + <summary>@(string.IsNullOrEmpty(EventJson) ? "" : "{ ... }")</summary> + <FancyTextBox Multiline="true" @bind-Value="EventJson"/> + </details> + <br/> + <label>MXC URI: </label> + <FancyTextBox @bind-Value="MxcUrl"/> + <br/> + <label>Room ID: </label> + <FancyTextBox @bind-Value="RoomId"/> + <br/> + <pre>@MxcUri?.ToJson(ignoreNull: true)</pre> + + @if (Event is not null) { + <LinkButton OnClickAsync="@RedactAllEvents">Redact all messages</LinkButton> + } + + @if (Event?.Sender?.Split(':', 2)[1] == Homeserver?.ServerName) { + <p>User is a local user!</p> + <LinkButton OnClickAsync="@DeactivateUser">Deactivate User</LinkButton> + <LinkButton OnClickAsync="@QuarantineMediaByUser">Quarantine all media</LinkButton> + } +} + +<style> + .int-input { + width: 128px; + } + + .tile { + display: inline-block; + padding: 4px; + border: 1px solid #ffffff22; + } + + .tile280 { + min-width: 280px; + } + + .tile150 { + min-width: 150px; + } + + .range-sep { + display: inline-block; + padding: 4px; + width: 150px; + } + + .range-sep::before { + content: "@("<") "; + } + + .range-sep::after { + content: " @("<")"; + } + + .center-children { + text-align: center; + } +</style> + +@code { + + private AuthenticatedHomeserverSynapse? Homeserver { get; set; } + + protected override async Task OnInitializedAsync() { + var hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true) as AuthenticatedHomeserverSynapse; + if (hs is null) return; + Homeserver = hs; + + if (!string.IsNullOrWhiteSpace(EventUrl)) { + _ = ExpandEventUrl(); + } + } + + [SupplyParameterFromQuery] + public string? EventUrl { + get; + set { + field = value?.Split('?')[0]; + _ = ExpandEventUrl(); + } + } + + private MatrixEventResponse? Event { get; set; } + + private string? EventJson { + get; + set { + field = value; + _ = ExpandEventJson(); + } + } + + private string? MxcUrl { + get; + set { + field = value; + _ = ExpandMxcUri(); + } + } + + private MxcUri? MxcUri { get; set; } + + private string? RoomId { + get => Event?.RoomId ?? field; + set; + } + + private async Task ExpandEventUrl() { + Console.WriteLine("Expanding event URL..."); + if (!string.IsNullOrWhiteSpace(EventUrl)) { + Console.WriteLine("Parsing event URL..."); + var data = ParseEventUrl(EventUrl); + Console.WriteLine($"Room: {data.room}, Event: {data.eventId}"); + RoomId = data.room; + var room = Homeserver.GetRoom(data.room); + var eventResponse = await room.GetEventAsync(data.eventId); + eventResponse.RoomId ??= data.room; + EventJson = eventResponse?.ToJson() ?? "null"; + } + + StateHasChanged(); + } + + private async Task ExpandEventJson() { + Console.WriteLine("Expanding event JSON..."); + if (!string.IsNullOrWhiteSpace(EventJson)) { + Event = JsonSerializer.Deserialize<MatrixEventResponse>(EventJson); + MxcUrl = Event?.ContentAs<RoomMessageEventContent>()?.Url; + Console.WriteLine($"MXC URL: {MxcUrl}"); + + var possiblyRelated = await Homeserver.Admin.GetRoomMediaAsync(Event!.RoomId!); + } + + StateHasChanged(); + } + + private async Task ExpandMxcUri() { + Console.WriteLine("Expanding MXC URI..."); + if (!string.IsNullOrWhiteSpace(MxcUrl)) { + MxcUri = MxcUrl; + } + + StateHasChanged(); + } + + private (string room, string eventId) ParseEventUrl(string url) { + var parts = url.Split('/'); + Console.WriteLine($"Parts: {string.Join(", ", parts)}"); + return (parts[4].UrlDecode(), parts[5].Split('?')[0].UrlDecode()); + } + +#region Local user + + private async Task DeactivateUser() { + await Homeserver.Admin.DeactivateUserAsync(Event.Sender, true); + } + + private async Task QuarantineMediaByUser() { + if (Event is null) return; + var media = Homeserver.Admin.GetUserMediaEnumerableAsync(Event?.Sender!); + await foreach (var m in media) { + if (m is not null) { + // await Homeserver.Admin.QuarantineMedia(m); + // await Homeserver.Admin.DeleteMedia(m); + } + } + } + +#endregion + + private async Task RedactAllEvents() { + if (Event is null) return; + await Homeserver!.Admin.DeleteAllMessages(Event.Sender!); + } + +} diff --git a/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor.css b/MatrixUtils.Web/Pages/HSAdmin/Synapse/BlockMedia.razor.css
index e69de29..e69de29 100644 --- a/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor.css +++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/BlockMedia.razor.css
diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor new file mode 100644
index 0000000..f1c5907 --- /dev/null +++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor
@@ -0,0 +1,74 @@ +@using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters +@using MatrixUtils.Web.Shared.FilterComponents +<div style="margin-left: 8px; margin-bottom: 8px;"> + <u style="display: block;">String contains</u> + <span class="tile tile280"><StringFilterComponent Filter="@Filter.RoomId" Label="Room ID"/></span> + <span class="tile tile280"><StringFilterComponent Filter="@Filter.Name" Label="Room name"/></span> + <span class="tile tile280"><StringFilterComponent Filter="@Filter.CanonicalAlias" Label="Canonical alias"/></span> + <span class="tile tile280"><StringFilterComponent Filter="@Filter.Creator" Label="Creator"/></span> + <span class="tile tile280"><StringFilterComponent Filter="@Filter.Version" Label="Room version"/></span> + <span class="tile tile280"><StringFilterComponent Filter="@Filter.Encryption" Label="Encryption algorithm"/></span> + <span class="tile tile280"><StringFilterComponent Filter="@Filter.JoinRules" Label="Join rules"/></span> + <span class="tile tile280"><StringFilterComponent Filter="@Filter.GuestAccess" Label="Guest access"/></span> + <span class="tile tile280"><StringFilterComponent Filter="@Filter.HistoryVisibility" Label="History visibility"/></span> + <span class="tile tile280"><StringFilterComponent Filter="@Filter.Topic" Label="Topic"/></span> + + <u style="display: block;">Optional checks</u> + <span class="tile tile150"><BooleanFilterComponent Filter="@Filter.Federation" Label="Is federated"/></span> + <span class="tile tile150"><BooleanFilterComponent Filter="@Filter.Public" Label="Is public"/></span> + <span class="tile tile150"><BooleanFilterComponent Filter="@Filter.Tombstone" Label="Is tombstoned"/></span> + + <u style="display: block;">Ranges</u> + <span class="tile center-children"> + <InputCheckbox @bind-Value="@Filter.StateEvents.Enabled"/> + @if (!Filter.StateEvents.Enabled) { + <span>State events</span> + } + else { + <InputCheckbox @bind-Value="@Filter.StateEvents.CheckGreaterThan"/> + <span> </span> + <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.StateEvents.GreaterThan"/> + <span class="range-sep">state events</span> + <InputCheckbox @bind-Value="@Filter.StateEvents.CheckLessThan"/> + <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.StateEvents.LessThan"/> + } + </span> + <span class="tile center-children"> + <InputCheckbox @bind-Value="@Filter.JoinedMembers.Enabled"/> + @if (!Filter.JoinedMembers.Enabled) { + <span>Joined members</span> + } + else { + <InputCheckbox @bind-Value="@Filter.JoinedMembers.CheckGreaterThan"/> + <span> </span> + <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedMembers.GreaterThan"/> + <span class="range-sep">members</span> + <InputCheckbox @bind-Value="@Filter.JoinedMembers.CheckLessThan"/> + <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedMembers.LessThan"/> + } + </span> + <span class="tile center-children"> + <InputCheckbox @bind-Value="@Filter.JoinedLocalMembers.Enabled"/> + <span> </span> + @if (!Filter.JoinedLocalMembers.Enabled) { + <span>Joined local members</span> + } + else { + <InputCheckbox @bind-Value="@Filter.JoinedLocalMembers.CheckGreaterThan"/> + <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedLocalMembers.GreaterThan"/> + <span class="range-sep">local members</span> + <InputCheckbox @bind-Value="@Filter.JoinedLocalMembers.CheckLessThan"/> + <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedLocalMembers.LessThan"/> + } + </span> +</div> +@* @{ *@ +@* Console.WriteLine($"Rendered SynapseRoomQueryFilter with filter: {Filter.ToJson()}"); *@ +@* } *@ + +@code { + + [Parameter] + public required SynapseAdminLocalRoomQueryFilter Filter { get; set; } + +} \ No newline at end of file diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor.css b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor.css new file mode 100644
index 0000000..83ce426 --- /dev/null +++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor.css
@@ -0,0 +1,35 @@ +.int-input { + width: 128px; +} + +.tile { + display: inline-block; + padding: 4px; + border: 1px solid #ffffff22; +} + +.tile280 { + min-width: 280px; +} + +.tile150 { + min-width: 150px; +} + +.range-sep { + display: inline-block; + padding: 4px; + width: 150px; +} + +.range-sep::before { + content: "< "; +} + +.range-sep::after { + content: " <"; +} + +.center-children { + text-align: center; +} \ No newline at end of file diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryResult.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryResult.razor new file mode 100644
index 0000000..5591072 --- /dev/null +++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryResult.razor
@@ -0,0 +1,5 @@ +<h3>SynapseRoomQueryResult</h3> + +@code { + +} \ No newline at end of file diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindow.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindow.razor new file mode 100644
index 0000000..d598994 --- /dev/null +++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindow.razor
@@ -0,0 +1,5 @@ +<h3>SynapseRoomShutdownWindow</h3> + +@code { + +} \ No newline at end of file diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor new file mode 100644
index 0000000..b0e6a89 --- /dev/null +++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor
@@ -0,0 +1,266 @@ +@using System.Text.Json.Serialization +@using ArcaneLibs.Extensions +@using LibMatrix +@using LibMatrix.EventTypes.Spec.State.RoomInfo +@using LibMatrix.Homeservers.Extensions.NamedCaches +@using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests +@using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses + +@if (string.IsNullOrWhiteSpace(Context.DeleteId) || EditorOnly) { + <span>Block room: </span> + <InputCheckbox @bind-Value="@Context.DeleteRequest.Block"/> + <br/> + <span>Purge room: </span> + <InputCheckbox @bind-Value="@Context.DeleteRequest.Purge"/> + <br/> + <span>Force purge room (unsafe): </span> + <InputCheckbox @bind-Value="@Context.DeleteRequest.ForcePurge"></InputCheckbox> + <br/> + <details> + <summary>Media</summary> + <span>Quarantine local media: </span> + <InputCheckbox @bind-Value="@Context.ExtraOptions.QuarantineLocalMedia"/> + <br/> + <span>Quarantine remote media: </span> + <InputCheckbox @bind-Value="@Context.ExtraOptions.QuarantineRemoteMedia"/> + <br/> + <span>Delete remote media: </span> + <InputCheckbox @bind-Value="@Context.ExtraOptions.DeleteRemoteMedia"/> + </details> + + <details> + <summary>Local users</summary> + <span>Suspend local users: </span> + <InputCheckbox @bind-Value="@Context.ExtraOptions.SuspendLocalUsers"></InputCheckbox> + <br/> + <span>Quarantine <b>ALL</b> local user media: </span> + <InputCheckbox @bind-Value="@Context.ExtraOptions.QuarantineLocalUserMedia"></InputCheckbox> + <br/> + <span>Delete <b>ALL</b> local user media: </span> + <InputCheckbox @bind-Value="@Context.ExtraOptions.DeleteLocalUserMedia"></InputCheckbox> + <br/> + <span>Follow tombstone (if any): </span> + <InputCheckbox @bind-Value="@Context.ExtraOptions.FollowTombstone"/> + @if (!EditorOnly) { + <LinkButton InlineText="true" OnClickAsync="@FollowTombstoneAsync">Exec</LinkButton> + } + </details> + + <details> + <summary>Issue warning to local members (optional)</summary> + <b>All fields are required if used!</b><br/> + <span>Warning room User ID: </span> + <FancyTextBox @bind-Value="@Context.DeleteRequest.NewRoomUserId"/> + <br/> + <span>Warning room name: </span> + <FancyTextBox @bind-Value="@Context.DeleteRequest.RoomName"/> + <br/> + <span>Warning room message (plaintext): </span> + <FancyTextBox Multiline="true" @bind-Value="@Context.DeleteRequest.Message"/> + <br/> + </details> + + @if (!EditorOnly) { + <LinkButton OnClickAsync="@DeleteRoom">Execute</LinkButton> + } +} +else { + <pre> + @(_status?.ToJson() ?? "Loading status...") + </pre> + <br/> + <LinkButton InlineText="true" OnClickAsync="@OnComplete">[Stop tracking]</LinkButton> + if (_status?.Status == SynapseAdminRoomDeleteStatus.Failed) { + <LinkButton InlineText="true" OnClickAsync="@ForceDelete">[Force delete]</LinkButton> + } +} + +@code { + + [Parameter] + public required RoomShutdownContext Context { get; set; } + + [Parameter] + public required AuthenticatedHomeserverSynapse Homeserver { get; set; } + + [Parameter] + public bool EditorOnly { get; set; } + + private NamedCache<RoomShutdownContext> TaskMap { get; set; } = null!; + private SynapseAdminRoomDeleteStatus? _status = null; + private bool _isTracking = false; + + protected override async Task OnInitializedAsync() { + if (EditorOnly) return; + TaskMap = new NamedCache<RoomShutdownContext>(Homeserver, "gay.rory.matrixutils.synapse_room_shutdown_tasks"); + var existing = await TaskMap.GetValueAsync(Context.RoomId); + if (existing is not null) { + Context = existing; + } + + if (Context.ExecuteImmediately) + await DeleteRoom(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) { + if (EditorOnly) return; + if (!_isTracking) { + if (!string.IsNullOrWhiteSpace(Context.DeleteId)) { + _isTracking = true; + _ = Task.Run(async () => { + do { + _status = await Homeserver.Admin.GetRoomDeleteStatus(Context.DeleteId); + StateHasChanged(); + if (_status.Status == SynapseAdminRoomDeleteStatus.Complete) { + await OnComplete(); + break; + } + + await Task.Delay(1000); + } while (_status.Status != SynapseAdminRoomDeleteStatus.Failed && _status.Status != SynapseAdminRoomDeleteStatus.Complete); + }); + } + } + } + + public class RoomShutdownContext { + public required string RoomId { get; set; } + + [JsonIgnore] // do NOT persist - this triggers immediate purging + public bool ExecuteImmediately { get; set; } + + public string? DeleteId { get; set; } + public ExtraDeleteOptions ExtraOptions { get; set; } = new(); + + public SynapseAdminRoomDeleteRequest DeleteRequest { get; set; } = new() { + Block = true, + Purge = true, + ForcePurge = false + }; + + public SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom? RoomDetails { get; set; } + + public class ExtraDeleteOptions { + public bool FollowTombstone { get; set; } + + // media options + public bool QuarantineLocalMedia { get; set; } + public bool QuarantineRemoteMedia { get; set; } + public bool DeleteRemoteMedia { get; set; } + + // user options + public bool SuspendLocalUsers { get; set; } + public bool QuarantineLocalUserMedia { get; set; } + public bool DeleteLocalUserMedia { get; set; } + } + } + + public async Task OnComplete() { + if (EditorOnly) return; + Console.WriteLine($"Room shutdown task for {Context.RoomId} completed, removing from map."); + await OnCompleteLock.WaitAsync(); + try { + await TaskMap.RemoveValueAsync(Context.RoomId!); + } + catch (Exception e) { + Console.WriteLine("Failed to remove completed room shutdown task from map: " + e); + } + finally { + OnCompleteLock.Release(); + } + } + + public async Task DeleteRoom() { + if (EditorOnly) return; + if (Context.ExtraOptions.FollowTombstone) await FollowTombstoneAsync(); + + Console.WriteLine($"Deleting room {Context.RoomId} with options: " + Context.DeleteRequest.ToJson()); + + var resp = await Homeserver.Admin.DeleteRoom(Context.RoomId, Context.DeleteRequest, false); + Context.DeleteId = resp.DeleteId; + await TaskMap.SetValueAsync(Context.RoomId, Context); + } + + private static readonly SemaphoreSlim OnCompleteLock = new(1, 1); + + private async Task FollowTombstoneAsync() { + if (EditorOnly) return; + var tomb = await TryGetTombstoneAsync(); + var content = tomb?.ContentAs<RoomTombstoneEventContent>(); + if (content != null && !string.IsNullOrWhiteSpace(content.ReplacementRoom)) { + Console.WriteLine("Tombstone: " + tomb.ToJson()); + if (!content.ReplacementRoom.StartsWith('!')) { + Console.WriteLine($"Invalid replacement room ID in tombstone: {content.ReplacementRoom}, ignoring!"); + } + else { + var oldMembers = await Homeserver.Admin.GetRoomMembersAsync(Context.RoomId, localOnly: true); + var isKnownRoom = await Homeserver.Admin.CheckRoomKnownAsync(content.ReplacementRoom); + var targetMembers = isKnownRoom + ? await Homeserver.Admin.GetRoomMembersAsync(Context.RoomId, localOnly: true) + : new() { Members = [] }; + + var members = oldMembers.Members.Except(targetMembers.Members).ToList(); + Console.WriteLine("To migrate: " + members.ToJson()); + foreach (var member in members) { + var success = false; + do { + var sess = member == Homeserver.WhoAmI.UserId ? Homeserver : await Homeserver.Admin.GetHomeserverForUserAsync(member, TimeSpan.FromSeconds(15)); + var oldRoom = sess.GetRoom(Context.RoomId); + var room = sess.GetRoom(content.ReplacementRoom); + try { + var servers = (await oldRoom.GetMembersByHomeserverAsync(joinedOnly: true)) + .Select(x => new KeyValuePair<string, int>(x.Key, x.Value.Count)) + .OrderByDescending(x => x.Key == "matrix.org" ? 0 : x.Value); // try anything else first, to reduce load on matrix.org + + await room.JoinAsync(servers.Take(10).Select(x => x.Key).ToArray(), reason: "Automatically following tombstone as old room is being purged.", checkIfAlreadyMember: isKnownRoom); + Console.WriteLine($"Migrated {member} from {Context.RoomId} to {content.ReplacementRoom}"); + success = true; + } + catch (Exception e) { + if (e is MatrixException { ErrorCode: "M_FORBIDDEN" }) { + Console.WriteLine($"Cannot migrate {member} to {content.ReplacementRoom}: {(e as MatrixException)!.GetAsJson()}"); + success = true; // give up + continue; + } + + Console.WriteLine($"Failed to invite {member} to {content.ReplacementRoom}: {e}"); + success = false; + await Task.Delay(1000); + } + } while (!success); + } + } + } + } + + private async Task<MatrixEventResponse?> TryGetTombstoneAsync() { + if (EditorOnly) return null; + try { + return (await Homeserver.Admin.GetRoomStateAsync(Context.RoomId, RoomTombstoneEventContent.EventId)).Events.FirstOrDefault(x => x.StateKey == ""); + } + catch { + return null; + } + } + + private async Task ForceDelete() { + if (EditorOnly) return; + Console.WriteLine($"Forcing purge for {Context.RoomId}!"); + await OnCompleteLock.WaitAsync(); + try { + var resp = await Homeserver.Admin.DeleteRoom(Context.RoomId, new() { + ForcePurge = true + }, waitForCompletion: false); + Context.DeleteId = resp.DeleteId; + await TaskMap.SetValueAsync(Context.RoomId, Context); + StateHasChanged(); + } + catch (Exception e) { + Console.WriteLine("Failed to remove completed room shutdown task from map: " + e); + } + finally { + OnCompleteLock.Release(); + } + } + +} \ No newline at end of file diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor new file mode 100644
index 0000000..05899c8 --- /dev/null +++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor
@@ -0,0 +1,618 @@ +@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<RoomQuery> Logger +@inject BlazorSaveFileService BlazorSaveFileService + +<h3>Homeserver Administration - Room Query</h3> + +<label>Search name: </label> +<InputText @bind-Value="SearchTerm"/><br/> +<label>Order by: </label> +<select @bind="OrderBy"> + @foreach (var item in validOrderBy) { + <option value="@item.Key">@item.Value</option> + } +</select><br/> +<InputCheckbox @bind-Value="Ascending"/> +<label> Ascending</label><br/> +<InputCheckbox @bind-Value="FetchV12PlusCreatorServer"/> +<label> Fetch v12+ room creation homeserver</label> +<LinkButton InlineText="true" OnClickAsync="FetchV12PlusCreatorServersAsync"> (Execute manually)</LinkButton><br/> +<InputCheckbox @bind-Value="FetchTombstones"/> +<label> Check for tombstone events</label> +<LinkButton InlineText="true" OnClickAsync="FetchTombstoneEventsAsync"> (Execute manually)</LinkButton><br/> +<InputCheckbox @bind-Value="SummarizeLocalMembers"/> +<label> Fetch local member list for small rooms</label> +<LinkButton InlineText="true" OnClickAsync="FetchLocalMemberEventsAsync"> (Execute manually)</LinkButton><br/> +<InputCheckbox @bind-Value="ShowFullResultData"/> +<label> Show full result data (JSON)</label><br/> +<InputCheckbox @bind-Value="EnableMultiPurge"/> +<label> Enable multi-purge mode</label> +@if (EnableMultiPurge) { + <span> </span> + <LinkButton InlineText="true" OnClick="@MultiPurgeInvertSelection">[Invert selection]</LinkButton> + <span> </span> + <details style="display: inline-block;"> + <summary>Edit purge options</summary> + <SynapseRoomShutdownWindowContent Context="@DefaultShutdownContext" Homeserver="Homeserver" EditorOnly="true"/> + </details> +} +else { + <br/> +} +<details> + <summary>Local filtering (slow)</summary> + <SynapseRoomQueryFilter Filter="@Filter"/> +</details> +<LinkButton OnClickAsync="@Search">Search</LinkButton> + +@if (EnableMultiPurge) { + <LinkButton Color="#FF8800" OnClick="@PurgeSelection">Purge selected rooms</LinkButton> +} +<br/> + +@if (Results.Count > 0) { + <p>Found @Results.Count rooms</p> +} + +@foreach (var room in Results) { + <div class="room-list-item"> + @* <RoomListItem RoomName="@res.Name" RoomId="@res.RoomId"></RoomListItem> *@ + <p> + @if (EnableMultiPurge) { + <InputCheckbox @bind-Value="@room.MultiPurgeSelected"/> + <span> </span> + } + @if (!string.IsNullOrWhiteSpace(room.CanonicalAlias)) { + <span>@room.CanonicalAlias - </span> + } + <span>@room.RoomId</span> + @if (!string.IsNullOrWhiteSpace(room.Name)) { + <span> (@room.Name)</span> + } + <br/> + + @if (!string.IsNullOrWhiteSpace(room.Creator)) { + <span>Created by @room.Creator</span> + <br/> + } + </p> + <p> + <LinkButton OnClickAsync="@(() => DeleteRoom(room))">Delete room</LinkButton> + <LinkButton target="_blank" href="@($"/HSAdmin/Synapse/ResyncState?roomId={room.RoomId}&via={room.OriginHomeserver}")">Resync state</LinkButton> + <LinkButton OnClickAsync="@(() => ExportState(room))">@(room.JoinedLocalMembers == 0 ? "Try to export state" : "Export state")</LinkButton> + <LinkButton OnClickAsync="@(() => ForceJoin(room))">Force Join</LinkButton> + </p> + + @{ + List<string?> 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(); + } + } + <span>@string.Join(", ", flags)</span> + @if (room.JoinedLocalMembers == 0 && flags.Count > 0) { + <span> at the time of leaving</span> + } + <br/> + + <span>@room.StateEvents state events, room version @(room.Version ?? "1")</span><br/> + @if (room.TombstoneEvent is not null) { + var tombstoneContent = room.TombstoneEvent.ContentAs<RoomTombstoneEventContent>()!; + <span>Room is tombstoned! Target room: @tombstoneContent.ReplacementRoom, message: @tombstoneContent.Body</span> + <br/> + } + + @{ + var memberSummary = room.MemberSummary; + if (room.LocalMembers is not null) { + memberSummary += $": {string.Join(", ", room.LocalMembers)}"; + } + } + <span>@memberSummary</span><br/> + @if (!string.IsNullOrWhiteSpace(room.TopicEvent?.ContentAs<RoomTopicEventContent>()?.Topic)) { + <details> + <summary>Room topic</summary> + <pre>@(room.TopicEvent?.ContentAs<RoomTopicEventContent>()?.Topic)</pre> + </details> + } + @foreach (var ex in room.Exceptions) { + <span style="color: red;">@ex</span> + <br/> + } + @if (ShowFullResultData) { + <details> + <summary>Full result data</summary> + <pre>@room.ToJson(ignoreNull: true)</pre> + </details> + } + </div> +} +@* *@ +@* @if (DeleteRequest.HasValue) { *@ +@* <ModalWindow MinWidth="600" Title="@("Delete " + DeleteRequest.Value.RoomId)" OnCloseClicked="@(() => { DeleteRequest = null; })"> *@ +@* *@ +@* </ModalWindow> *@ +@* } *@ + +@* @foreach (var (roomId, status) in DeleteStatuses) { *@ +@* <ModalWindow Title="@("Delete status for " + roomId)" MinWidth="600"> *@ +@* <pre>@status.ToJson()</pre> *@ +@* </ModalWindow> *@ +@* } *@ + +@foreach (var (roomId, deleteRequest) in DeleteRequests) { + <ModalWindow Title="@($"Delete room {roomId}")" OnCloseClicked="@(() => { + DeleteRequests.Remove(roomId); + StateHasChanged(); + })"> + <SynapseRoomShutdownWindowContent Context="deleteRequest" Homeserver="Homeserver"/> + </ModalWindow> +} + +@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<RoomInfo> Results { get; set; } = new(); + + private AuthenticatedHomeserverSynapse Homeserver { get; set; } = null!; + + private SynapseAdminLocalRoomQueryFilter Filter { get; set; } = new(); + + private Dictionary<string, SynapseRoomShutdownWindowContent.RoomShutdownContext> DeleteRequests { get; set; } = []; + + // private Dictionary<string, SynapseAdminRoomDeleteStatus> DeleteStatuses { get; set; } = new(); + + private NamedCache<SynapseRoomShutdownWindowContent.RoomShutdownContext> 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<SynapseRoomShutdownWindowContent.RoomShutdownContext>(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<SynapseRoomShutdownWindowContent.RoomShutdownContext>(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<string, string> 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<string>? 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<string> 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(); + } + +} diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor.css b/MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor.css new file mode 100644
index 0000000..62941e5 --- /dev/null +++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor.css
@@ -0,0 +1,7 @@ +.room-list-item { + background-color: #ffffff11; + border-radius: 0.5em; + display: block; + margin-top: 4px; + padding: 4px; +} \ No newline at end of file diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/SubTools/SynapseRoomStateResync.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/SubTools/SynapseRoomStateResync.razor new file mode 100644
index 0000000..3cc5a6a --- /dev/null +++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/SubTools/SynapseRoomStateResync.razor
@@ -0,0 +1,211 @@ +@page "/HSAdmin/Synapse/ResyncState" +@using ArcaneLibs.Extensions +@using LibMatrix +@using LibMatrix.EventTypes.Spec.State.RoomInfo +@using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests + +<h3>Resync room state with other server</h3> +<hr/> + +@if (!Executing) { + <p>WARNING: Will likely not work on invite-only/knock rooms! May also mess with history visibility!</p> + <p>If the room is using mjolnir/draupnir, it's probably recommended to set the "via" to the server it's hosted on.</p> + <span>Room ID: </span> + <InputText @bind-Value="@RoomId"></InputText> + <br/> + <span>Via: </span> + <InputText @bind-Value="@Via"></InputText> + <br/> + <LinkButton OnClickAsync="@Execute">Execute</LinkButton> +} + +@if (Executing) { + <p>Execution in progress. DO NOT CLOSE THIS PAGE!</p> +} +@* stage 1 *@ +@if (Stage >= 1) { + @if (Members is null) { + <p>Loading members...</p> + } + else { + <p>Got @Members.Count local members</p> + } +} + +@* stage 2 *@ +@if (Stage == 2) { + <p>Purging room, please wait...</p> + <pre>@DeleteStatus.ToJson(ignoreNull: true)</pre> +} + +@* stage 3 *@ +@if (Stage == 3) { + <p>Rejoining room, please wait...</p> + <p>Members left to restore: </p> + string members = ""; + foreach (var member in Members) { + members += $"{member.StateKey} ({member.ContentAs<RoomMemberEventContent>()?.ToJson(indent: false, ignoreNull: true)})\n"; + } + + <pre> + @members + </pre> +} + +@if (Stage == 4) { + <p>Execution finished. You may now close the page :)</p> +} + +@if (Error is not null) { + <p style="color: red">Error: @Error.Message</p> + <pre> + @Error.ToString() + </pre> +} + +@code { + + [Parameter] + [SupplyParameterFromQuery] + public string? RoomId { get; set; } + + [Parameter] + [SupplyParameterFromQuery(Name = "via")] + public string? Via { get; set; } + + private AuthenticatedHomeserverSynapse? Homeserver { get; set; } + + // Execution flow + private int Stage { get; set; } + private bool Executing { get; set; } + private Exception? Error { get; set; } + + // Stage 1 + private List<MatrixEventResponse>? Members { get; set; } + + // Stage 2 + private SynapseAdminRoomDeleteStatus? DeleteStatus { get; set; } + + protected override async Task OnInitializedAsync() { + if (await sessionStore.GetCurrentHomeserver(navigateOnFailure: true) is not AuthenticatedHomeserverSynapse hs) return; + Homeserver = hs; + + StateHasChanged(); + } + + private Task Execute() => Execute(0); + + private async Task Execute(int startStage) { + if (string.IsNullOrWhiteSpace(RoomId)) return; + if (string.IsNullOrWhiteSpace(Via)) return; + Executing = true; + StateHasChanged(); + + await ExecuteStages(startStage); + + StateHasChanged(); + } + + private async Task ExecuteStages(int startStage) { + if (startStage <= 1) + if (!await TryGetRoomMembers()) + return; + if (startStage <= 2) + if (!await TryPurgeRoom()) + return; + if (startStage <= 3) + if (!await TryRestoreRoom()) + return; + + Stage = 4; + Executing = false; + StateHasChanged(); + } + + private async Task<bool> TryGetRoomMembers() { + Stage = 1; + try { + Members = (await Homeserver.Admin.GetRoomStateAsync(RoomId, type: RoomMemberEventContent.EventId)) + .Events.Where(m => (m.StateKey?.EndsWith(':' + Homeserver.ServerName) ?? false) && m.ContentAs<RoomMemberEventContent>()!.Membership == "join") + .ToList(); + Console.WriteLine(Members.ToJson(ignoreNull: true)); + StateHasChanged(); + return true; + } + catch (Exception e) { + Error = e; + return Executing = false; + } + } + + private async Task<bool> TryPurgeRoom() { + Stage = 2; + + try { + var resp = await Homeserver.Admin.DeleteRoom(RoomId, new SynapseAdminRoomDeleteRequest { + Block = true, + Purge = true, + // ForcePurge = true // This causes synapse to early return and not actually purge stuff... + }, waitForCompletion: false); + + while (true) { + // we dont want API failure to break this step + try { + DeleteStatus = await Homeserver.Admin.GetRoomDeleteStatus(resp.DeleteId); + StateHasChanged(); + if (DeleteStatus.Status == SynapseAdminRoomDeleteStatus.Complete) { + return true; + } + + if (DeleteStatus.Status == SynapseAdminRoomDeleteStatus.Failed) { + Error = new Exception("Failed to delete room: " + DeleteStatus.ToJson()); + return Executing = false; + } + + await Task.Delay(1000); + } + catch { } + } + + StateHasChanged(); + return true; + } + catch (Exception e) { + Error = e; + return Executing = false; + } + } + + private async Task<bool> TryRestoreRoom() { + Stage = 3; + try { + await Homeserver.Admin.BlockRoom(RoomId, block: false); + Members = Random.Shared.GetItems(Members.ToArray(), Members.Count).ToList(); + StateHasChanged(); + foreach (var member in Members) { + while (true) { + try { + var hs = member.StateKey == Homeserver.WhoAmI.UserId + ? Homeserver + : await Homeserver.Admin.GetHomeserverForUserAsync(member.StateKey!, TimeSpan.FromMinutes(120)); + await hs.GetRoom(RoomId).JoinAsync([Via], reason: "Reconciling state with " + Via, false); + await hs.GetRoom(RoomId).SendStateEventAsync(RoomMemberEventContent.EventId, member.StateKey, member.RawContent); + Members = Members.Skip(1).ToList(); + StateHasChanged(); + break; + } + catch (Exception e) { + Error = new Exception($"{DateTime.Now:u} Failed to join room: {member.StateKey}, retrying\n", e); + } + } + } + + return true; + } + catch (Exception e) { + Error = e; + return Executing = false; + } + } + +} \ No newline at end of file diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/UserList.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/UserList.razor new file mode 100644
index 0000000..54ac800 --- /dev/null +++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/UserList.razor
@@ -0,0 +1,243 @@ +@page "/HSAdmin/Synapse/UserQuery" +@using Microsoft.AspNetCore.WebUtilities +@using ArcaneLibs.Extensions +@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<RoomQuery> Logger + +<h3>Homeserver Administration - User Query</h3> + +<label>Search name: </label> +<InputText @bind-Value="SearchTerm"/><br/> +<label>Order by: </label> +<select @bind="OrderBy"> + @foreach (var item in validOrderBy) { + <option value="@item.Key">@item.Value</option> + } +</select><br/> +<label>Ascending: </label> +<InputCheckbox @bind-Value="Ascending"/><br/> +<details> + <summary> + <span>Local filtering (slow)</span> + </summary> + @* <SynapseRoomQueryFilter Filter="@Filter"/> *@ +</details> +<button class="btn btn-primary" @onclick="Search">Search</button> +<br/> + +@if (Results.Count > 0) { + <p>Found @Results.Count rooms</p> + @* <details> *@ + @* <summary>TSV data (copy/paste)</summary> *@ + @* <pre style="font-size: 0.6em;"> *@ + @* <table> *@ + @* @foreach (var res in Results) { *@ + @* <tr> *@ + @* <td style="padding: 8px;">@res.RoomId@("\t")</td> *@ + @* <td style="padding: 8px;">@res.CanonicalAlias@("\t")</td> *@ + @* <td style="padding: 8px;">@res.Creator@("\t")</td> *@ + @* <td style="padding: 8px;">@res.Name</td> *@ + @* </tr> *@ + @* } *@ + @* </table> *@ + @* </pre> *@ + @* </details> *@ +} + +@foreach (var user in Results) { + <div class="room-list-item"> + <p> + <span>@user.Name</span> + @if (!string.IsNullOrWhiteSpace(user.DisplayName)) { + <span> (@user.DisplayName)</span> + } + <br/> + </p> + <p> + <LinkButton OnClickAsync="@(() => Login(user))">Log in</LinkButton> + @* <LinkButton OnClickAsync="@(() => DeleteRoom(user))">Delete room</LinkButton> *@ + @* <LinkButton target="_blank" href="@($"/HSAdmin/Synapse/ResyncState?roomId={user.RoomId}&via={user.RoomId.Split(':', 2)[1]}")">Resync state</LinkButton> *@ + + </p> + + @{ + List<string?> flags = []; + if (user.IsGuest == true) flags.Add("guest"); + if (user.Admin == true) flags.Add("admin"); + if (user.Deactivated == true) flags.Add("deactivated"); + if (user.Erased == true) flags.Add("erased"); + if (user.ShadowBanned == true) flags.Add("shadow banned"); + if (user.Locked == true) flags.Add("locked"); + if (user.Approved == true) flags.Add("approved"); + + if (!string.IsNullOrWhiteSpace(user.UserType)) flags.Add($"type=\"{user.UserType}\""); + + flags = flags.Where(x => x != null).ToList(); + } + <span>@string.Join(", ", flags)</span> + <br/> + + <details> + <summary>Full result data</summary> + <pre>@user.ToJson(ignoreNull: true)</pre> + </details> + </div> +} + +@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; + + private List<SynapseAdminUserListResult.SynapseAdminUserListResultUser> Results { get; set; } = new(); + + private AuthenticatedHomeserverSynapse Homeserver { get; set; } = null!; + + private SynapseAdminLocalUserQueryFilter Filter { get; set; } = new(); + + protected override async Task OnInitializedAsync() { + var hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true); + if (hs is not AuthenticatedHomeserverSynapse synapse) { + NavigationManager.NavigateTo("/"); + return; + } + + Homeserver = synapse; + 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.RoomIdContains = value[0]!; + // break; + // case "NameContains": + // Filter.NameContains = value[0]!; + // break; + // case "CanonicalAliasContains": + // Filter.CanonicalAliasContains = value[0]!; + // break; + // case "VersionContains": + // Filter.VersionContains = value[0]!; + // break; + // case "CreatorContains": + // Filter.CreatorContains = value[0]!; + // break; + // case "EncryptionContains": + // Filter.EncryptionContains = value[0]!; + // break; + // case "JoinRulesContains": + // Filter.JoinRulesContains = value[0]!; + // break; + // case "GuestAccessContains": + // Filter.GuestAccessContains = value[0]!; + // break; + // case "HistoryVisibilityContains": + // Filter.HistoryVisibilityContains = value[0]!; + // break; + // case "Federatable": + // Filter.Federatable = bool.Parse(value[0]!); + // Filter.CheckFederation = true; + // break; + // case "Public": + // Filter.Public = value[0] == "true"; + // Filter.CheckPublic = true; + // break; + // case "JoinedMembersGreaterThan": + // Filter.JoinedMembersGreaterThan = int.Parse(value[0]!); + // break; + // case "JoinedMembersLessThan": + // Filter.JoinedMembersLessThan = int.Parse(value[0]!); + // break; + // case "JoinedLocalMembersGreaterThan": + // Filter.JoinedLocalMembersGreaterThan = int.Parse(value[0]!); + // break; + // case "JoinedLocalMembersLessThan": + // Filter.JoinedLocalMembersLessThan = int.Parse(value[0]!); + // break; + // case "StateEventsGreaterThan": + // Filter.StateEventsGreaterThan = int.Parse(value[0]!); + // break; + // case "StateEventsLessThan": + // Filter.StateEventsLessThan = int.Parse(value[0]!); + // break; + case "Execute": + execute = true; + break; + default: + Console.WriteLine($"Unknown query parameter: {key}"); + break; + } + } + + if (execute) + _ = Search(); + + return Task.CompletedTask; + } + + private async Task Search() { + Results.Clear(); + var searchRooms = Homeserver.Admin.SearchUsersAsync(orderBy: OrderBy!, dir: Ascending ? "f" : "b", localFilter: Filter).GetAsyncEnumerator(); + while (await searchRooms.MoveNextAsync()) { + var room = searchRooms.Current; + + Results.Add(room); + + if ((Results.Count <= 200 && Results.Count % 10 == 0) || Results.Count % 1000 == 0) { + StateHasChanged(); + await Task.Yield(); + await Task.Delay(1); + } + } + + StateHasChanged(); + + StateHasChanged(); + } + + private readonly Dictionary<string, string> validOrderBy = new() { + { "name", "User name" }, + { "is_guest", "Guest status" }, + { "admin", "Admin status" }, + { "user_type", "User type" }, + { "deactivated", "Deactivation status" }, + { "shadow_banned", "Shadow banned status" }, + { "displayname", "Display name" }, + { "avatar_url", "Avatar URL" }, + { "creation_ts", "Creation time" }, + { "last_seen_ts", "Last activity" }, + }; + + private async Task Login(SynapseAdminUserListResult.SynapseAdminUserListResultUser user) { + var loginResult = await Homeserver.Admin.LoginUserAsync(user.Name, TimeSpan.FromDays(1)); + await sessionStore.AddSession(new() { + AccessToken = loginResult.AccessToken, + DeviceId = loginResult.DeviceId, + UserId = loginResult.UserId, + Homeserver = Homeserver.ServerName, + Proxy = Homeserver.Proxy + }); + + } + +} diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/UserList.razor.css b/MatrixUtils.Web/Pages/HSAdmin/Synapse/UserList.razor.css new file mode 100644
index 0000000..62941e5 --- /dev/null +++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/UserList.razor.css
@@ -0,0 +1,7 @@ +.room-list-item { + background-color: #ffffff11; + border-radius: 0.5em; + display: block; + margin-top: 4px; + padding: 4px; +} \ No newline at end of file