@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) { Block room:
Purge room:
Force purge room (unsafe):
Media Quarantine local media:
Quarantine remote media:
Delete remote media:
Local users Suspend local users:
Quarantine ALL local user media:
Delete ALL local user media:
Follow tombstone (if any): @if (!EditorOnly) { Exec }
Issue warning to local members (optional) All fields are required if used!
Warning room User ID:
Warning room name:
Warning room message (plaintext):
@if (!EditorOnly) { Execute } } else {
        @(_status?.ToJson() ?? "Loading status...")
    

[Stop tracking] if (_status?.Status == SynapseAdminRoomDeleteStatus.Failed) { [Force delete] } } @code { [Parameter] public required RoomShutdownContext Context { get; set; } [Parameter] public required AuthenticatedHomeserverSynapse Homeserver { get; set; } [Parameter] public bool EditorOnly { get; set; } private NamedCache TaskMap { get; set; } = null!; private SynapseAdminRoomDeleteStatus? _status = null; private bool _isTracking = false; protected override async Task OnInitializedAsync() { if (EditorOnly) return; TaskMap = new NamedCache(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(); 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(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 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(); } } }