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&::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
|