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
|