@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();
}
}
}