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/SynapseRoomShutdownWindowContent.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor
index d5daf75..b0e6a89 100644
--- a/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor
+++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor
@@ -1,36 +1,12 @@
+@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)) {
- <b>Media options</b>
- <br/>
- <hr/>
- <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"/>
- <br/>
-
- <b>User options</b>
- <br/>
- <hr/>
- <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/>
-
- <b>Room deletion options</b>
- <br/>
- <hr/>
+@if (string.IsNullOrWhiteSpace(Context.DeleteId) || EditorOnly) {
<span>Block room: </span>
<InputCheckbox @bind-Value="@Context.DeleteRequest.Block"/>
<br/>
@@ -40,19 +16,63 @@
<span>Force purge room (unsafe): </span>
<InputCheckbox @bind-Value="@Context.DeleteRequest.ForcePurge"></InputCheckbox>
<br/>
- <span>Warning room User ID (optional): </span>
- <FancyTextBox @bind-Value="@Context.DeleteRequest.NewRoomUserId"/>
- <br/>
- @if (!string.IsNullOrWhiteSpace(Context.DeleteRequest.NewRoomUserId)) {
+ <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>
- <LinkButton OnClick="@DeleteRoom">Execute</LinkButton>
+ @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 {
@@ -63,14 +83,52 @@
[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();
@@ -80,11 +138,14 @@
ForcePurge = false
};
+ public SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom? RoomDetails { get; set; }
+
public class ExtraDeleteOptions {
- // room options
+ public bool FollowTombstone { get; set; }
+
+ // media options
public bool QuarantineLocalMedia { get; set; }
public bool QuarantineRemoteMedia { get; set; }
-
public bool DeleteRemoteMedia { get; set; }
// user options
@@ -95,9 +156,14 @@
}
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.DeleteId!);
+ await TaskMap.RemoveValueAsync(Context.RoomId!);
+ }
+ catch (Exception e) {
+ Console.WriteLine("Failed to remove completed room shutdown task from map: " + e);
}
finally {
OnCompleteLock.Release();
@@ -105,9 +171,96 @@
}
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
|