about summary refs log tree commit diff
path: root/LibMatrix/Homeservers/ImplementationDetails
diff options
context:
space:
mode:
Diffstat (limited to 'LibMatrix/Homeservers/ImplementationDetails')
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalEventReportQueryFilter.cs27
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalRoomQueryFilter.cs27
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalUserQueryFilter.cs27
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/AdminRoomDeleteRequest.cs23
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRegistrationTokenCreateRequest.cs31
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRoomDeleteRequest.cs54
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/BackgroundUpdates.cs28
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/Destinations.cs56
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs169
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RegistrationTokenListResult.cs31
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomListResult.cs (renamed from LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/AdminRoomListingResult.cs)8
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomMediaListResult.cs11
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomMemberListResult.cs11
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminUserRedactIdResponse.cs22
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseCollectionResult.cs250
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseUserMediaResult.cs40
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/UserListResult.cs71
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs462
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminUserCleanupExecutor.cs27
19 files changed, 1331 insertions, 44 deletions
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalEventReportQueryFilter.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalEventReportQueryFilter.cs
new file mode 100644

index 0000000..c34ad7c --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalEventReportQueryFilter.cs
@@ -0,0 +1,27 @@ +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters; + +public class SynapseAdminLocalEventReportQueryFilter { + public string UserIdContains { get; set; } = ""; + public string NameContains { get; set; } = ""; + public string CanonicalAliasContains { get; set; } = ""; + public string VersionContains { get; set; } = ""; + public string CreatorContains { get; set; } = ""; + public string EncryptionContains { get; set; } = ""; + public string JoinRulesContains { get; set; } = ""; + public string GuestAccessContains { get; set; } = ""; + public string HistoryVisibilityContains { get; set; } = ""; + + public bool Federatable { get; set; } = true; + public bool Public { get; set; } = true; + + public int JoinedMembersGreaterThan { get; set; } + public int JoinedMembersLessThan { get; set; } = int.MaxValue; + + public int JoinedLocalMembersGreaterThan { get; set; } + public int JoinedLocalMembersLessThan { get; set; } = int.MaxValue; + public int StateEventsGreaterThan { get; set; } + public int StateEventsLessThan { get; set; } = int.MaxValue; + + public bool CheckFederation { get; set; } + public bool CheckPublic { get; set; } +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalRoomQueryFilter.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalRoomQueryFilter.cs new file mode 100644
index 0000000..b8929a0 --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalRoomQueryFilter.cs
@@ -0,0 +1,27 @@ +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters; + +public class SynapseAdminLocalRoomQueryFilter { + public string RoomIdContains { get; set; } = ""; + public string NameContains { get; set; } = ""; + public string CanonicalAliasContains { get; set; } = ""; + public string VersionContains { get; set; } = ""; + public string CreatorContains { get; set; } = ""; + public string EncryptionContains { get; set; } = ""; + public string JoinRulesContains { get; set; } = ""; + public string GuestAccessContains { get; set; } = ""; + public string HistoryVisibilityContains { get; set; } = ""; + + public bool Federatable { get; set; } = true; + public bool Public { get; set; } = true; + + public int JoinedMembersGreaterThan { get; set; } + public int JoinedMembersLessThan { get; set; } = int.MaxValue; + + public int JoinedLocalMembersGreaterThan { get; set; } + public int JoinedLocalMembersLessThan { get; set; } = int.MaxValue; + public int StateEventsGreaterThan { get; set; } + public int StateEventsLessThan { get; set; } = int.MaxValue; + + public bool CheckFederation { get; set; } + public bool CheckPublic { get; set; } +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalUserQueryFilter.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalUserQueryFilter.cs new file mode 100644
index 0000000..62b291b --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalUserQueryFilter.cs
@@ -0,0 +1,27 @@ +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters; + +public class SynapseAdminLocalUserQueryFilter { + public string UserIdContains { get; set; } = ""; + public string NameContains { get; set; } = ""; + public string CanonicalAliasContains { get; set; } = ""; + public string VersionContains { get; set; } = ""; + public string CreatorContains { get; set; } = ""; + public string EncryptionContains { get; set; } = ""; + public string JoinRulesContains { get; set; } = ""; + public string GuestAccessContains { get; set; } = ""; + public string HistoryVisibilityContains { get; set; } = ""; + + public bool Federatable { get; set; } = true; + public bool Public { get; set; } = true; + + public int JoinedMembersGreaterThan { get; set; } + public int JoinedMembersLessThan { get; set; } = int.MaxValue; + + public int JoinedLocalMembersGreaterThan { get; set; } + public int JoinedLocalMembersLessThan { get; set; } = int.MaxValue; + public int StateEventsGreaterThan { get; set; } + public int StateEventsLessThan { get; set; } = int.MaxValue; + + public bool CheckFederation { get; set; } + public bool CheckPublic { get; set; } +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/AdminRoomDeleteRequest.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/AdminRoomDeleteRequest.cs deleted file mode 100644
index f4c927a..0000000 --- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/AdminRoomDeleteRequest.cs +++ /dev/null
@@ -1,23 +0,0 @@ -using System.Text.Json.Serialization; - -namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests; - -public class AdminRoomDeleteRequest { - [JsonPropertyName("new_room_user_id")] - public string? NewRoomUserId { get; set; } - - [JsonPropertyName("room_name")] - public string? RoomName { get; set; } - - [JsonPropertyName("block")] - public bool Block { get; set; } - - [JsonPropertyName("purge")] - public bool Purge { get; set; } - - [JsonPropertyName("message")] - public string? Message { get; set; } - - [JsonPropertyName("force_purge")] - public bool ForcePurge { get; set; } -} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRegistrationTokenCreateRequest.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRegistrationTokenCreateRequest.cs new file mode 100644
index 0000000..197fd5d --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRegistrationTokenCreateRequest.cs
@@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses; + +public class SynapseAdminRegistrationTokenUpdateRequest { + [JsonPropertyName("uses_allowed")] + public int? UsesAllowed { get; set; } + + [JsonPropertyName("expiry_time")] + public long? ExpiryTime { get; set; } + + [JsonIgnore] + public DateTime? ExpiresAt { + get => ExpiryTime.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(ExpiryTime.Value).DateTime : null; + set => ExpiryTime = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null; + } + + [JsonIgnore] + public TimeSpan? ExpiresAfter { + get => ExpiryTime.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(ExpiryTime.Value).DateTime - DateTimeOffset.Now : null; + set => ExpiryTime = value.HasValue ? (DateTimeOffset.Now + value.Value).ToUnixTimeMilliseconds() : null; + } +} + +public class SynapseAdminRegistrationTokenCreateRequest : SynapseAdminRegistrationTokenUpdateRequest { + [JsonPropertyName("token")] + public string? Token { get; set; } + + [JsonPropertyName("length")] + public int? Length { get; set; } +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRoomDeleteRequest.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRoomDeleteRequest.cs new file mode 100644
index 0000000..aee2a7e --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRoomDeleteRequest.cs
@@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests; + +public class SynapseAdminRoomDeleteRequest { + [JsonPropertyName("new_room_user_id")] + public string? NewRoomUserId { get; set; } + + [JsonPropertyName("room_name")] + public string? RoomName { get; set; } + + [JsonPropertyName("block")] + public bool Block { get; set; } + + [JsonPropertyName("purge")] + public bool Purge { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("force_purge")] + public bool ForcePurge { get; set; } +} + +public class SynapseAdminRoomDeleteResponse { + [JsonPropertyName("delete_id")] + public string DeleteId { get; set; } = null!; +} + +public class SynapseAdminRoomDeleteStatusList { + [JsonPropertyName("results")] + public List<SynapseAdminRoomDeleteStatus> Results { get; set; } +} +public class SynapseAdminRoomDeleteStatus { + [JsonPropertyName("status")] + public string Status { get; set; } = null!; + + [JsonPropertyName("shutdown_room")] + public RoomShutdownInfo ShutdownRoom { get; set; } + + public class RoomShutdownInfo { + [JsonPropertyName("kicked_users")] + public List<string>? KickedUsers { get; set; } + + [JsonPropertyName("failed_to_kick_users")] + public List<string>? FailedToKickUsers { get; set; } + + [JsonPropertyName("local_aliases")] + public List<string>? LocalAliasses { get; set; } + + [JsonPropertyName("new_room_id")] + public string? NewRoomId { get; set; } + } +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/BackgroundUpdates.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/BackgroundUpdates.cs new file mode 100644
index 0000000..2394b98 --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/BackgroundUpdates.cs
@@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses; + +public class SynapseAdminBackgroundUpdateStatusResponse { + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [JsonPropertyName("current_updates")] + public Dictionary<string, BackgroundUpdateInfo> CurrentUpdates { get; set; } + + public class BackgroundUpdateInfo { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("total_item_count")] + public int TotalItemCount { get; set; } + + [JsonPropertyName("total_duration_ms")] + public double TotalDurationMs { get; set; } + + [JsonPropertyName("average_items_per_ms")] + public double AverageItemsPerMs { get; set; } + + [JsonIgnore] + public TimeSpan TotalDuration => TimeSpan.FromMilliseconds(TotalDurationMs); + } +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/Destinations.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/Destinations.cs new file mode 100644
index 0000000..646a4b5 --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/Destinations.cs
@@ -0,0 +1,56 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses; + +public class SynapseAdminDestinationListResult : SynapseNextTokenTotalCollectionResult { + [JsonPropertyName("destinations")] + public List<SynapseAdminDestinationListResultDestination> Destinations { get; set; } = new(); + + public class SynapseAdminDestinationListResultDestination { + [JsonPropertyName("destination")] + public string Destination { get; set; } + + [JsonPropertyName("retry_last_ts")] + public long RetryLastTs { get; set; } + + [JsonPropertyName("retry_interval")] + public long RetryInterval { get; set; } + + [JsonPropertyName("failure_ts")] + public long? FailureTs { get; set; } + + [JsonPropertyName("last_successful_stream_ordering")] + public long? LastSuccessfulStreamOrdering { get; set; } + + [JsonIgnore] + public DateTime? FailureTsDateTime { + get => FailureTs.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(FailureTs.Value).DateTime : null; + set => FailureTs = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null; + } + + [JsonIgnore] + public DateTime? RetryLastTsDateTime { + get => DateTimeOffset.FromUnixTimeMilliseconds(RetryLastTs).DateTime; + set => RetryLastTs = new DateTimeOffset(value.Value).ToUnixTimeMilliseconds(); + } + + [JsonIgnore] + public TimeSpan RetryIntervalTimeSpan { + get => TimeSpan.FromMilliseconds(RetryInterval); + set => RetryInterval = (long)value.TotalMilliseconds; + } + } +} + +public class SynapseAdminDestinationRoomListResult : SynapseNextTokenTotalCollectionResult { + [JsonPropertyName("rooms")] + public List<SynapseAdminDestinationRoomListResultRoom> Rooms { get; set; } = new(); + + public class SynapseAdminDestinationRoomListResultRoom { + [JsonPropertyName("room_id")] + public string RoomId { get; set; } + + [JsonPropertyName("stream_ordering")] + public int StreamOrdering { get; set; } + } +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs new file mode 100644
index 0000000..10fc039 --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs
@@ -0,0 +1,169 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using ArcaneLibs; +using ArcaneLibs.Attributes; +using ArcaneLibs.Extensions; +using LibMatrix.EventTypes; +using LibMatrix.Extensions; + +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses; + +public class SynapseAdminEventReportListResult : SynapseNextTokenTotalCollectionResult { + [JsonPropertyName("event_reports")] + public List<SynapseAdminEventReportListResultReport> Reports { get; set; } = new(); + + public class SynapseAdminEventReportListResultReport { + [JsonPropertyName("event_id")] + public string EventId { get; set; } + + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("reason")] + public string? Reason { get; set; } + + [JsonPropertyName("score")] + public int? Score { get; set; } + + [JsonPropertyName("received_ts")] + public long ReceivedTs { get; set; } + + [JsonPropertyName("canonical_alias")] + public string? CanonicalAlias { get; set; } + + [JsonPropertyName("room_id")] + public string RoomId { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("sender")] + public string Sender { get; set; } + + [JsonPropertyName("user_id")] + public string UserId { get; set; } + + [JsonIgnore] + public DateTime ReceivedTsDateTime { + get => DateTimeOffset.FromUnixTimeMilliseconds(ReceivedTs).DateTime; + set => ReceivedTs = new DateTimeOffset(value).ToUnixTimeMilliseconds(); + } + } + + public class SynapseAdminEventReportListResultReportWithDetails : SynapseAdminEventReportListResultReport { + [JsonPropertyName("event_json")] + public SynapseEventJson EventJson { get; set; } + + public class SynapseEventJson { + [JsonPropertyName("auth_events")] + public List<string> AuthEvents { get; set; } + + [JsonPropertyName("content")] + public JsonObject? RawContent { get; set; } + + [JsonPropertyName("depth")] + public int Depth { get; set; } + + [JsonPropertyName("hashes")] + public Dictionary<string, string> Hashes { get; set; } + + [JsonPropertyName("origin")] + public string Origin { get; set; } + + [JsonPropertyName("origin_server_ts")] + public long OriginServerTs { get; set; } + + [JsonPropertyName("prev_events")] + public List<string> PrevEvents { get; set; } + + [JsonPropertyName("prev_state")] + public List<object> PrevState { get; set; } + + [JsonPropertyName("room_id")] + public string RoomId { get; set; } + + [JsonPropertyName("sender")] + public string Sender { get; set; } + + [JsonPropertyName("signatures")] + public Dictionary<string, Dictionary<string, string>> Signatures { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("unsigned")] + public JsonObject? Unsigned { get; set; } + + // Extra... copied from StateEventResponse + + [JsonIgnore] + public Type MappedType => StateEvent.GetStateEventType(Type); + + [JsonIgnore] + public bool IsLegacyType => MappedType.GetCustomAttributes<MatrixEventAttribute>().FirstOrDefault(x => x.EventName == Type)?.Legacy ?? false; + + [JsonIgnore] + public string FriendlyTypeName => MappedType.GetFriendlyNameOrNull() ?? Type; + + [JsonIgnore] + public string FriendlyTypeNamePlural => MappedType.GetFriendlyNamePluralOrNull() ?? Type; + + private static readonly JsonSerializerOptions TypedContentSerializerOptions = new() { + Converters = { + new JsonFloatStringConverter(), + new JsonDoubleStringConverter(), + new JsonDecimalStringConverter() + } + }; + + [JsonIgnore] + [SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] + public EventContent? TypedContent { + get { + ClassCollector<EventContent>.ResolveFromAllAccessibleAssemblies(); + // if (Type == "m.receipt") { + // return null; + // } + try { + var mappedType = StateEvent.GetStateEventType(Type); + if (mappedType == typeof(UnknownEventContent)) + Console.WriteLine($"Warning: unknown event type '{Type}'"); + var deserialisedContent = (EventContent)RawContent.Deserialize(mappedType, TypedContentSerializerOptions)!; + return deserialisedContent; + } + catch (JsonException e) { + Console.WriteLine(e); + Console.WriteLine("Content:\n" + (RawContent?.ToJson() ?? "null")); + } + + return null; + } + set { + if (value is null) + RawContent?.Clear(); + else + RawContent = JsonSerializer.Deserialize<JsonObject>(JsonSerializer.Serialize(value, value.GetType(), + new JsonSerializerOptions() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull })); + } + } + + //debug + [JsonIgnore] + public string InternalSelfTypeName { + get { + var res = GetType().Name switch { + "StateEvent`1" => "StateEvent", + _ => GetType().Name + }; + return res; + } + } + + [JsonIgnore] + public string InternalContentTypeName => TypedContent?.GetType().Name ?? "null"; + } + } +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RegistrationTokenListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RegistrationTokenListResult.cs new file mode 100644
index 0000000..fa92ef9 --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RegistrationTokenListResult.cs
@@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses; + +public class SynapseAdminRegistrationTokenListResult { + [JsonPropertyName("registration_tokens")] + public List<SynapseAdminRegistrationTokenListResultToken> RegistrationTokens { get; set; } = new(); + + public class SynapseAdminRegistrationTokenListResultToken { + [JsonPropertyName("token")] + public string Token { get; set; } + + [JsonPropertyName("uses_allowed")] + public int? UsesAllowed { get; set; } + + [JsonPropertyName("pending")] + public int Pending { get; set; } + + [JsonPropertyName("completed")] + public int Completed { get; set; } + + [JsonPropertyName("expiry_time")] + public long? ExpiryTime { get; set; } + + [JsonIgnore] + public DateTime? ExpiryTimeDateTime { + get => ExpiryTime.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(ExpiryTime.Value).DateTime : null; + set => ExpiryTime = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null; + } + } +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/AdminRoomListingResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomListResult.cs
index 7ab96ac..d84c89b 100644 --- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/AdminRoomListingResult.cs +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomListResult.cs
@@ -1,8 +1,8 @@ using System.Text.Json.Serialization; -namespace LibMatrix.Responses.Admin; +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses; -public class AdminRoomListingResult { +public class SynapseAdminRoomListResult { [JsonPropertyName("offset")] public int Offset { get; set; } @@ -16,9 +16,9 @@ public class AdminRoomListingResult { public int? PrevBatch { get; set; } [JsonPropertyName("rooms")] - public List<AdminRoomListingResultRoom> Rooms { get; set; } = new(); + public List<SynapseAdminRoomListResultRoom> Rooms { get; set; } = new(); - public class AdminRoomListingResultRoom { + public class SynapseAdminRoomListResultRoom { [JsonPropertyName("room_id")] public required string RoomId { get; set; } diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomMediaListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomMediaListResult.cs new file mode 100644
index 0000000..97e85ad --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomMediaListResult.cs
@@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses; + +public class SynapseAdminRoomMediaListResult { + [JsonPropertyName("local")] + public List<string> Local { get; set; } = new(); + + [JsonPropertyName("remote")] + public List<string> Remote { get; set; } = new(); +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomMemberListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomMemberListResult.cs new file mode 100644
index 0000000..cb2ec08 --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomMemberListResult.cs
@@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses; + +public class SynapseAdminRoomMemberListResult { + [JsonPropertyName("members")] + public List<string> Members { get; set; } + + [JsonPropertyName("total")] + public int Total { get; set; } +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminUserRedactIdResponse.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminUserRedactIdResponse.cs new file mode 100644
index 0000000..3f5f865 --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminUserRedactIdResponse.cs
@@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses; + +public class SynapseAdminUserRedactIdResponse { + [JsonPropertyName("redact_id")] + public string RedactionId { get; set; } +} + +public class SynapseAdminRedactStatusResponse { + /// <summary> + /// One of "scheduled", "active", "completed", "failed" + /// </summary> + [JsonPropertyName("status")] + public string Status { get; set; } + + /// <summary> + /// Key: Event ID, Value: Error message + /// </summary> + [JsonPropertyName("failed_redactions")] + public Dictionary<string, string> FailedRedactions { get; set; } +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseCollectionResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseCollectionResult.cs new file mode 100644
index 0000000..36a5596 --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseCollectionResult.cs
@@ -0,0 +1,250 @@ +using System.Buffers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using ArcaneLibs.Extensions; + +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses; + +public class SynapseNextTokenTotalCollectionResult { + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("next_token")] + public string? NextToken { get; set; } +} + +// [JsonConverter(typeof(SynapseCollectionJsonConverter<>))] +public class SynapseCollectionResult<T>(string chunkKey = "chunk", string prevTokenKey = "prev_token", string nextTokenKey = "next_token", string totalKey = "total") { + public int? Total { get; set; } + public string? PrevToken { get; set; } + public string? NextToken { get; set; } + public List<T> Chunk { get; set; } = []; + + // TODO: figure out how to provide an IAsyncEnumerable<T> for this + // https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/use-utf8jsonreader#read-from-a-stream-using-utf8jsonreader + + // public async IAsyncEnumerable<T> FromJsonAsync(Stream stream) { + // + // } + + public SynapseCollectionResult<T> FromJson(Stream stream, Action<T> action) { + byte[] buffer = new byte[4096]; + _ = stream.Read(buffer); + var reader = new Utf8JsonReader(buffer, isFinalBlock: false, state: default); + + try { + FromJsonInternal(stream, ref buffer, ref reader, action); + } + catch (JsonException e) { + Console.WriteLine($"Caught a JsonException: {e}"); + int hexdumpWidth = 64; + Console.WriteLine($"Check hexdump line {reader.BytesConsumed / hexdumpWidth} index {reader.BytesConsumed % hexdumpWidth}"); + buffer.HexDump(64); + } + finally { } + + return this; + } + + private void FromJsonInternal(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader, Action<T> action) { + while (!reader.IsFinalBlock) { + while (!reader.Read()) { + GetMoreBytesFromStream(stream, ref buffer, ref reader); + } + + if (reader.TokenType == JsonTokenType.PropertyName) { + var propName = reader.GetString(); + Console.WriteLine($"SynapseCollectionResult: encountered property name: {propName}"); + + while (!reader.Read()) { + GetMoreBytesFromStream(stream, ref buffer, ref reader); + } + + Console.WriteLine($"{reader.BytesConsumed}/{stream.Position} {reader.TokenType}"); + + if (propName == totalKey && reader.TokenType == JsonTokenType.Number) { + Total = reader.GetInt32(); + } + else if (propName == prevTokenKey && reader.TokenType == JsonTokenType.String) { + PrevToken = reader.GetString(); + } + else if (propName == nextTokenKey && reader.TokenType == JsonTokenType.String) { + NextToken = reader.GetString(); + } + else if (propName == chunkKey) { + if (reader.TokenType == JsonTokenType.StartArray) { + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) { + // if (reader.TokenType == JsonTokenType.EndArray) { + // break; + // } + // Console.WriteLine($"Encountered token in chunk: {reader.TokenType}"); + // var _buf = reader.ValueSequence.ToArray(); + // try { + // var item = JsonSerializer.Deserialize<T>(_buf); + // action(item); + // Chunk.Add(item); + // } + // catch(JsonException e) { + // Console.WriteLine($"Caught a JsonException: {e}"); + // int hexdumpWidth = 64; + // + // // Console.WriteLine($"Check hexdump line {reader.BytesConsumed / hexdumpWidth} index {reader.BytesConsumed % hexdumpWidth}"); + // Console.WriteLine($"Buffer length: {_buf.Length}"); + // _buf.HexDump(64); + // throw; + // } + var item = ReadItem(stream, ref buffer, ref reader); + action(item); + Chunk.Add(item); + } + } + } + } + } + } + + private T ReadItem(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader) { + while (!reader.Read()) { + GetMoreBytesFromStream(stream, ref buffer, ref reader); + } + + // handle nullable types + if (typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Nullable<>)) { + if (reader.TokenType == JsonTokenType.Null) { + return default(T); + } + } + + // if(typeof(T) == typeof(string)) { + // return (T)(object)reader.GetString(); + // } + // else if(typeof(T) == typeof(int)) { + // return (T)(object)reader.GetInt32(); + // } + // else { + // var _buf = reader.ValueSequence.ToArray(); + // return JsonSerializer.Deserialize<T>(_buf); + // } + + // default branch uses "object?" cast to avoid compiler error + // add more branches here as nessesary + // reader.Read(); + var call = typeof(T) switch { + Type t when t == typeof(string) => reader.GetString(), + _ => ReadObject<T>(stream, ref buffer, ref reader) + }; + + object ReadObject<T>(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader) { + if (reader.TokenType != JsonTokenType.PropertyName) { + throw new JsonException(); + } + + List<byte> objBuffer = [(byte)'{', ..reader.ValueSequence.ToArray()]; + var currentDepth = reader.CurrentDepth; + while (reader.CurrentDepth >= currentDepth) { + while (!reader.Read()) { + GetMoreBytesFromStream(stream, ref buffer, ref reader); + } + + if (reader.TokenType == JsonTokenType.EndObject && reader.CurrentDepth == currentDepth) { + break; + } + + objBuffer.AddRange(reader.ValueSpan); + } + + return JsonSerializer.Deserialize<T>(objBuffer.ToArray()); + } + + return (T)call; + + // return JsonSerializer.Deserialize<T>(ref reader); + } + + private static void GetMoreBytesFromStream(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader) { + int bytesRead; + if (reader.BytesConsumed < buffer.Length) { + ReadOnlySpan<byte> leftover = buffer.AsSpan((int)reader.BytesConsumed); + + if (leftover.Length == buffer.Length) { + Array.Resize(ref buffer, buffer.Length * 2); + Console.WriteLine($"Increased buffer size to {buffer.Length}"); + } + + leftover.CopyTo(buffer); + bytesRead = stream.Read(buffer.AsSpan(leftover.Length)); + } + else { + bytesRead = stream.Read(buffer); + } + + // Console.WriteLine($"String in buffer is: {Encoding.UTF8.GetString(buffer)}"); + reader = new Utf8JsonReader(buffer, isFinalBlock: bytesRead == 0, reader.CurrentState); + } +} + +public partial class SynapseCollectionJsonConverter<T> : JsonConverter<SynapseCollectionResult<T>> { + public override SynapseCollectionResult<T>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType != JsonTokenType.StartObject) { + throw new JsonException(); + } + + var result = new SynapseCollectionResult<T>(); + while (reader.Read()) { + if (reader.TokenType == JsonTokenType.EndObject) { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) { + throw new JsonException(); + } + + var propName = reader.GetString(); + reader.Read(); + if (propName == "total") { + result.Total = reader.GetInt32(); + } + else if (propName == "prev_token") { + result.PrevToken = reader.GetString(); + } + else if (propName == "next_token") { + result.NextToken = reader.GetString(); + } + else if (propName == "chunk") { + if (reader.TokenType != JsonTokenType.StartArray) { + throw new JsonException(); + } + + while (reader.Read()) { + if (reader.TokenType == JsonTokenType.EndArray) { + break; + } + + var item = JsonSerializer.Deserialize<T>(ref reader, options); + result.Chunk.Add(item); + } + } + } + + return result; + } + + public override void Write(Utf8JsonWriter writer, SynapseCollectionResult<T> value, JsonSerializerOptions options) { + writer.WriteStartObject(); + if (value.Total is not null) + writer.WriteNumber("total", value.Total ?? 0); + if (value.PrevToken is not null) + writer.WriteString("prev_token", value.PrevToken); + if (value.NextToken is not null) + writer.WriteString("next_token", value.NextToken); + + writer.WriteStartArray("chunk"); + foreach (var item in value.Chunk) { + JsonSerializer.Serialize(writer, item, options); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseUserMediaResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseUserMediaResult.cs new file mode 100644
index 0000000..5530cc3 --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseUserMediaResult.cs
@@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses; + +public class SynapseAdminUserMediaResult { + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("next_token")] + public string? NextToken { get; set; } + + [JsonPropertyName("media")] + public List<MediaInfo> Media { get; set; } = new(); + + public class MediaInfo { + [JsonPropertyName("created_ts")] + public long CreatedTimestamp { get; set; } + + [JsonPropertyName("last_access_ts")] + public long? LastAccessTimestamp { get; set; } + + [JsonPropertyName("media_id")] + public string MediaId { get; set; } + + [JsonPropertyName("media_length")] + public int MediaLength { get; set; } + + [JsonPropertyName("media_type")] + public string MediaType { get; set; } + + [JsonPropertyName("quarantined_by")] + public string? QuarantinedBy { get; set; } + + [JsonPropertyName("safe_from_quarantine")] + public bool SafeFromQuarantine { get; set; } + + [JsonPropertyName("upload_name")] + public string UploadName { get; set; } + } +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/UserListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/UserListResult.cs new file mode 100644
index 0000000..3132906 --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/UserListResult.cs
@@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses; + +public class SynapseAdminUserListResult { + [JsonPropertyName("offset")] + public int Offset { get; set; } + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("next_token")] + public string? NextToken { get; set; } + + [JsonPropertyName("users")] + public List<SynapseAdminUserListResultUser> Users { get; set; } = new(); + + public class SynapseAdminUserListResultUser { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("is_guest")] + public bool? IsGuest { get; set; } + + [JsonPropertyName("admin")] + public bool? Admin { get; set; } + + [JsonPropertyName("user_type")] + public string? UserType { get; set; } + + [JsonPropertyName("deactivated")] + public bool Deactivated { get; set; } + + [JsonPropertyName("erased")] + public bool Erased { get; set; } + + [JsonPropertyName("shadow_banned")] + public bool ShadowBanned { get; set; } + + [JsonPropertyName("displayname")] + public string? DisplayName { get; set; } + + [JsonPropertyName("avatar_url")] + public string? AvatarUrl { get; set; } + + [JsonPropertyName("creation_ts")] + public long CreationTs { get; set; } + + [JsonPropertyName("last_seen_ts")] + public long? LastSeenTs { get; set; } + + [JsonPropertyName("locked")] + public bool Locked { get; set; } + + // Requires enabling MSC3866 + [JsonPropertyName("approved")] + public bool? Approved { get; set; } + + [JsonIgnore] + public DateTime CreationTsDateTime { + get => DateTimeOffset.FromUnixTimeMilliseconds(CreationTs).DateTime; + set => CreationTs = new DateTimeOffset(value).ToUnixTimeMilliseconds(); + } + + [JsonIgnore] + public DateTime? LastSeenTsDateTime { + get => LastSeenTs.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(LastSeenTs.Value).DateTime : null; + set => LastSeenTs = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null; + } + } +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs
index ac94a7a..3ed7311 100644 --- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs
@@ -1,90 +1,155 @@ +// #define LOG_SKIP + +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; using ArcaneLibs.Extensions; -using LibMatrix.Filters; -using LibMatrix.Responses.Admin; +using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters; +using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests; +using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses; +using LibMatrix.Responses; namespace LibMatrix.Homeservers.ImplementationDetails.Synapse; public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedHomeserver) { - public async IAsyncEnumerable<AdminRoomListingResult.AdminRoomListingResultRoom> SearchRoomsAsync(int limit = int.MaxValue, string orderBy = "name", string dir = "f", - string? searchTerm = null, LocalRoomQueryFilter? localFilter = null) { - AdminRoomListingResult? res = null; + private SynapseAdminUserCleanupExecutor UserCleanupExecutor { get; } = new(authenticatedHomeserver); + // https://github.com/element-hq/synapse/tree/develop/docs/admin_api + // https://github.com/element-hq/synapse/tree/develop/docs/usage/administration/admin_api + +#region Rooms + + public async IAsyncEnumerable<SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom> SearchRoomsAsync(int limit = int.MaxValue, int chunkLimit = 250, + string orderBy = "name", string dir = "f", string? searchTerm = null, SynapseAdminLocalRoomQueryFilter? localFilter = null) { + SynapseAdminRoomListResult? res = null; var i = 0; int? totalRooms = null; do { - var url = $"/_synapse/admin/v1/rooms?limit={Math.Min(limit, 250)}&dir={dir}&order_by={orderBy}"; + var url = $"/_synapse/admin/v1/rooms?limit={Math.Min(limit, chunkLimit)}&dir={dir}&order_by={orderBy}"; if (!string.IsNullOrEmpty(searchTerm)) url += $"&search_term={searchTerm}"; if (res?.NextBatch is not null) url += $"&from={res.NextBatch}"; Console.WriteLine($"--- ADMIN Querying Room List with URL: {url} - Already have {i} items... ---"); - res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<AdminRoomListingResult>(url); + res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomListResult>(url); totalRooms ??= res.TotalRooms; - Console.WriteLine(res.ToJson(false)); + // Console.WriteLine(res.ToJson(false)); foreach (var room in res.Rooms) { if (localFilter is not null) { - if (!room.RoomId.Contains(localFilter.RoomIdContains)) { + if (!string.IsNullOrWhiteSpace(localFilter.RoomIdContains) && !room.RoomId.Contains(localFilter.RoomIdContains, StringComparison.OrdinalIgnoreCase)) { totalRooms--; +#if LOG_SKIP + Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule roomid."); +#endif continue; } - if (!room.Name?.Contains(localFilter.NameContains) == true) { + if (!string.IsNullOrWhiteSpace(localFilter.NameContains) && room.Name?.Contains(localFilter.NameContains, StringComparison.OrdinalIgnoreCase) != true) { totalRooms--; +#if LOG_SKIP + Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule roomname."); +#endif continue; } - if (!room.CanonicalAlias?.Contains(localFilter.CanonicalAliasContains) == true) { + if (!string.IsNullOrWhiteSpace(localFilter.CanonicalAliasContains) && + room.CanonicalAlias?.Contains(localFilter.CanonicalAliasContains, StringComparison.OrdinalIgnoreCase) != true) { totalRooms--; +#if LOG_SKIP + Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule alias."); +#endif continue; } - if (!room.Version.Contains(localFilter.VersionContains)) { + if (!string.IsNullOrWhiteSpace(localFilter.VersionContains) && !room.Version.Contains(localFilter.VersionContains, StringComparison.OrdinalIgnoreCase)) { totalRooms--; +#if LOG_SKIP + Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule version."); +#endif continue; } - if (!room.Creator.Contains(localFilter.CreatorContains)) { + if (!string.IsNullOrWhiteSpace(localFilter.CreatorContains) && !room.Creator.Contains(localFilter.CreatorContains, StringComparison.OrdinalIgnoreCase)) { totalRooms--; +#if LOG_SKIP + Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule creator."); +#endif continue; } - if (!room.Encryption?.Contains(localFilter.EncryptionContains) == true) { + if (!string.IsNullOrWhiteSpace(localFilter.EncryptionContains) && + room.Encryption?.Contains(localFilter.EncryptionContains, StringComparison.OrdinalIgnoreCase) != true) { totalRooms--; +#if LOG_SKIP + Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule encryption."); +#endif continue; } - if (!room.JoinRules?.Contains(localFilter.JoinRulesContains) == true) { + if (!string.IsNullOrWhiteSpace(localFilter.JoinRulesContains) && + room.JoinRules?.Contains(localFilter.JoinRulesContains, StringComparison.OrdinalIgnoreCase) != true) { totalRooms--; +#if LOG_SKIP + Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joinrules."); +#endif continue; } - if (!room.GuestAccess?.Contains(localFilter.GuestAccessContains) == true) { + if (!string.IsNullOrWhiteSpace(localFilter.GuestAccessContains) && + room.GuestAccess?.Contains(localFilter.GuestAccessContains, StringComparison.OrdinalIgnoreCase) != true) { totalRooms--; +#if LOG_SKIP + Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule guestaccess."); +#endif continue; } - if (!room.HistoryVisibility?.Contains(localFilter.HistoryVisibilityContains) == true) { + if (!string.IsNullOrWhiteSpace(localFilter.HistoryVisibilityContains) && + room.HistoryVisibility?.Contains(localFilter.HistoryVisibilityContains, StringComparison.OrdinalIgnoreCase) != true) { totalRooms--; +#if LOG_SKIP + Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule history visibility."); +#endif continue; } if (localFilter.CheckFederation && room.Federatable != localFilter.Federatable) { totalRooms--; +#if LOG_SKIP + Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule federation."); +#endif continue; } if (localFilter.CheckPublic && room.Public != localFilter.Public) { totalRooms--; +#if LOG_SKIP + Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule public."); +#endif continue; } + if (room.StateEvents < localFilter.StateEventsGreaterThan || room.StateEvents > localFilter.StateEventsLessThan) { + totalRooms--; +#if LOG_SKIP + Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joined local members."); +#endif + continue; + } + if (room.JoinedMembers < localFilter.JoinedMembersGreaterThan || room.JoinedMembers > localFilter.JoinedMembersLessThan) { totalRooms--; +#if LOG_SKIP + Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joined members: {localFilter.JoinedMembersGreaterThan} < {room.JoinedLocalMembers} < {localFilter.JoinedMembersLessThan}."); +#endif continue; } if (room.JoinedLocalMembers < localFilter.JoinedLocalMembersGreaterThan || room.JoinedLocalMembers > localFilter.JoinedLocalMembersLessThan) { totalRooms--; +#if LOG_SKIP + Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joined local members: {localFilter.JoinedLocalMembersGreaterThan} < {room.JoinedLocalMembers} < {localFilter.JoinedLocalMembersLessThan}."); +#endif continue; } } @@ -104,4 +169,367 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH } } while (i < Math.Min(limit, totalRooms ?? limit)); } + +#endregion + +#region Users + + public async IAsyncEnumerable<SynapseAdminUserListResult.SynapseAdminUserListResultUser> SearchUsersAsync(int limit = int.MaxValue, int chunkLimit = 250, + SynapseAdminLocalUserQueryFilter? localFilter = null) { + // TODO: implement filters + string? from = null; + while (limit > 0) { + var url = new Uri("/_synapse/admin/v3/users", UriKind.Relative); + url = url.AddQuery("limit", Math.Min(limit, chunkLimit).ToString()); + if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from); + Console.WriteLine($"--- ADMIN Querying User List with URL: {url} ---"); + // TODO: implement URI methods in http client + var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminUserListResult>(url.ToString()); + foreach (var user in res.Users) { + limit--; + yield return user; + } + + if (string.IsNullOrWhiteSpace(res.NextToken)) break; + from = res.NextToken; + } + } + + public async Task<LoginResponse> LoginUserAsync(string userId, TimeSpan expireAfter) { + var url = new Uri($"/_synapse/admin/v1/users/{userId.UrlEncode()}/login", UriKind.Relative); + url.AddQuery("valid_until_ms", DateTimeOffset.UtcNow.Add(expireAfter).ToUnixTimeMilliseconds().ToString()); + var resp = await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync<JsonObject>(url.ToString(), new()); + var loginResp = await resp.Content.ReadFromJsonAsync<LoginResponse>(); + loginResp.UserId = userId; // Synapse only returns the access token + return loginResp; + } + +#endregion + +#region Reports + + public async IAsyncEnumerable<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReport> GetEventReportsAsync(int limit = int.MaxValue, int chunkLimit = 250, + string dir = "f", SynapseAdminLocalEventReportQueryFilter? filter = null) { + // TODO: implement filters + string? from = null; + while (limit > 0) { + var url = new Uri("/_synapse/admin/v1/event_reports", UriKind.Relative); + url = url.AddQuery("limit", Math.Min(limit, chunkLimit).ToString()); + if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from); + Console.WriteLine($"--- ADMIN Querying Reports with URL: {url} ---"); + var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminEventReportListResult>(url.ToString()); + foreach (var report in res.Reports) { + limit--; + yield return report; + } + + if (string.IsNullOrWhiteSpace(res.NextToken)) break; + from = res.NextToken; + } + } + + public async Task<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails> GetEventReportDetailsAsync(string reportId) { + var url = new Uri($"/_synapse/admin/v1/event_reports/{reportId.UrlEncode()}", UriKind.Relative); + return await authenticatedHomeserver.ClientHttpClient + .GetFromJsonAsync<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails>(url.ToString()); + } + + // Utility function to get details straight away + public async IAsyncEnumerable<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails> GetEventReportsWithDetailsAsync(int limit = int.MaxValue, + int chunkLimit = 250, string dir = "f", SynapseAdminLocalEventReportQueryFilter? filter = null) { + Queue<Task<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails>> tasks = []; + await foreach (var report in GetEventReportsAsync(limit, chunkLimit, dir, filter)) { + tasks.Enqueue(GetEventReportDetailsAsync(report.Id)); + while (tasks.Peek().IsCompleted) yield return await tasks.Dequeue(); // early return if possible + } + + while (tasks.Count > 0) yield return await tasks.Dequeue(); + } + + public async Task DeleteEventReportAsync(string reportId) { + var url = new Uri($"/_synapse/admin/v1/event_reports/{reportId.UrlEncode()}", UriKind.Relative); + await authenticatedHomeserver.ClientHttpClient.DeleteAsync(url.ToString()); + } + +#endregion + +#region Background Updates + + public async Task<bool> GetBackgroundUpdatesEnabledAsync() { + var url = new Uri("/_synapse/admin/v1/background_updates/enabled", UriKind.Relative); + // The return type is technically wrong, but includes the field we want. + var resp = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminBackgroundUpdateStatusResponse>(url.ToString()); + return resp.Enabled; + } + + public async Task<bool> SetBackgroundUpdatesEnabledAsync(bool enabled) { + var url = new Uri("/_synapse/admin/v1/background_updates/enabled", UriKind.Relative); + // The used types are technically wrong, but include the field we want. + var resp = await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync<JsonObject>(url.ToString(), new JsonObject { + ["enabled"] = enabled + }); + var json = await resp.Content.ReadFromJsonAsync<SynapseAdminBackgroundUpdateStatusResponse>(); + return json!.Enabled; + } + + public async Task<SynapseAdminBackgroundUpdateStatusResponse> GetBackgroundUpdatesStatusAsync() { + var url = new Uri("/_synapse/admin/v1/background_updates/status", UriKind.Relative); + return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminBackgroundUpdateStatusResponse>(url.ToString()); + } + + /// <summary> + /// Run a background job + /// </summary> + /// <param name="jobName">One of "populate_stats_process_rooms" or "regenerate_directory"</param> + public async Task RunBackgroundJobsAsync(string jobName) { + var url = new Uri("/_synapse/admin/v1/background_updates/run", UriKind.Relative); + await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync(url.ToString(), new JsonObject() { + ["job_name"] = jobName + }); + } + +#endregion + +#region Federation + + public async IAsyncEnumerable<SynapseAdminDestinationListResult.SynapseAdminDestinationListResultDestination> GetFederationDestinationsAsync(int limit = int.MaxValue, + int chunkLimit = 250) { + string? from = null; + while (limit > 0) { + var url = new Uri("/_synapse/admin/v1/federation/destinations", UriKind.Relative); + url = url.AddQuery("limit", Math.Min(limit, chunkLimit).ToString()); + if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from); + Console.WriteLine($"--- ADMIN Querying Federation Destinations with URL: {url} ---"); + var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminDestinationListResult>(url.ToString()); + foreach (var dest in res.Destinations) { + limit--; + yield return dest; + } + } + } + + public async Task<SynapseAdminDestinationListResult.SynapseAdminDestinationListResultDestination> GetFederationDestinationDetailsAsync(string destination) { + var url = new Uri($"/_synapse/admin/v1/federation/destinations/{destination}", UriKind.Relative); + return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminDestinationListResult.SynapseAdminDestinationListResultDestination>(url.ToString()); + } + + public async IAsyncEnumerable<SynapseAdminDestinationRoomListResult.SynapseAdminDestinationRoomListResultRoom> GetFederationDestinationRoomsAsync(string destination, + int limit = int.MaxValue, int chunkLimit = 250) { + string? from = null; + while (limit > 0) { + var url = new Uri($"/_synapse/admin/v1/federation/destinations/{destination}/rooms", UriKind.Relative); + url = url.AddQuery("limit", Math.Min(limit, chunkLimit).ToString()); + if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from); + Console.WriteLine($"--- ADMIN Querying Federation Destination Rooms with URL: {url} ---"); + var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminDestinationRoomListResult>(url.ToString()); + foreach (var room in res.Rooms) { + limit--; + yield return room; + } + } + } + + public async Task ResetFederationConnectionTimeoutAsync(string destination) { + await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync($"/_synapse/admin/v1/federation/destinations/{destination}/reset_connection", new JsonObject()); + } + +#endregion + +#region Registration Tokens + + // does not support pagination + public async Task<List<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>> GetRegistrationTokensAsync() { + var url = new Uri("/_synapse/admin/v1/registration_tokens", UriKind.Relative); + var resp = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRegistrationTokenListResult>(url.ToString()); + return resp.RegistrationTokens; + } + + public async Task<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken> GetRegistrationTokenAsync(string token) { + var url = new Uri($"/_synapse/admin/v1/registration_tokens/{token.UrlEncode()}", UriKind.Relative); + var resp = + await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>(url.ToString()); + return resp; + } + + public async Task<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken> CreateRegistrationTokenAsync( + SynapseAdminRegistrationTokenCreateRequest request) { + var url = new Uri("/_synapse/admin/v1/", UriKind.Relative); + var resp = await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync(url.ToString(), request); + var token = await resp.Content.ReadFromJsonAsync<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>(); + return token!; + } + + public async Task<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken> UpdateRegistrationTokenAsync(string token, + SynapseAdminRegistrationTokenUpdateRequest request) { + var url = new Uri($"/_synapse/admin/v1/registration_tokens/{token.UrlEncode()}", UriKind.Relative); + var resp = await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync(url.ToString(), request); + return await resp.Content.ReadFromJsonAsync<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>(); + } + + public async Task DeleteRegistrationTokenAsync(string token) { + var url = new Uri($"/_synapse/admin/v1/registration_tokens/{token.UrlEncode()}", UriKind.Relative); + await authenticatedHomeserver.ClientHttpClient.DeleteAsync(url.ToString()); + } + +#endregion + +#region Account Validity + + // Does anyone even use this? + // README: https://github.com/matrix-org/synapse/issues/15271 + // -> Don't implement unless requested, if not for this feature almost never being used. + +#endregion + +#region Experimental Features + + public async Task<Dictionary<string, bool>> GetExperimentalFeaturesAsync(string userId) { + var url = new Uri($"/_synapse/admin/v1/experimental_features/{userId.UrlEncode()}", UriKind.Relative); + var resp = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<JsonObject>(url.ToString()); + return resp["features"]!.GetValue<Dictionary<string, bool>>(); + } + + public async Task SetExperimentalFeaturesAsync(string userId, Dictionary<string, bool> features) { + var url = new Uri($"/_synapse/admin/v1/experimental_features/{userId.UrlEncode()}", UriKind.Relative); + await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync<JsonObject>(url.ToString(), new JsonObject { + ["features"] = JsonSerializer.Deserialize<JsonObject>(features.ToJson()) + }); + } + +#endregion + +#region Media + + public async Task<SynapseAdminRoomMediaListResult> GetRoomMediaAsync(string roomId) { + var url = new Uri($"/_synapse/admin/v1/room/{roomId.UrlEncode()}/media", UriKind.Relative); + return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomMediaListResult>(url.ToString()); + } + + // This is in the user admin API section + // public async IAsyncEnumerable<SynapseAdminRoomMediaListResult> + +#endregion + + public async Task<SynapseAdminUserRedactIdResponse?> DeleteAllMessages(string mxid, List<string>? rooms = null, string? reason = null, int? limit = 100000, + bool waitForCompletion = true) { + rooms ??= []; + + Dictionary<string, object> payload = new(); + if (rooms.Count > 0) payload["rooms"] = rooms; + if (!string.IsNullOrEmpty(reason)) payload["reason"] = reason; + if (limit.HasValue) payload["limit"] = limit.Value; + + var redactIdResp = await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync($"/_synapse/admin/v1/user/{mxid}/redact", payload); + var redactId = await redactIdResp.Content.ReadFromJsonAsync<SynapseAdminUserRedactIdResponse>(); + + if (waitForCompletion) { + while (true) { + var status = await GetRedactStatus(redactId!.RedactionId); + if (status?.Status != "pending") break; + await Task.Delay(1000); + } + } + + return redactId; + } + + public async Task<SynapseAdminRedactStatusResponse?> GetRedactStatus(string redactId) { + return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRedactStatusResponse>( + $"/_synapse/admin/v1/user/redact_status/{redactId}"); + } + + public async Task DeactivateUserAsync(string mxid, bool erase = false, bool eraseMessages = false, bool extraCleanup = false) { + if (eraseMessages) { + await DeleteAllMessages(mxid); + } + + if (extraCleanup) { + await UserCleanupExecutor.CleanupUser(mxid); + } + + await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync($"/_synapse/admin/v1/deactivate", new { erase }); + } + + public async Task ResetPasswordAsync(string mxid, string newPassword, bool logoutDevices = false) { + await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync($"/_synapse/admin/v1/reset_password/{mxid}", + new { new_password = newPassword, logout_devices = logoutDevices }); + } + + public async Task<SynapseAdminUserMediaResult> GetUserMediaAsync(string mxid, int? limit = 100, string? from = null, string? orderBy = null, string? dir = null) { + var url = $"/_synapse/admin/v1/users/{mxid}/media"; + if (limit.HasValue) url += $"?limit={limit}"; + if (!string.IsNullOrEmpty(from)) url += $"&from={from}"; + if (!string.IsNullOrEmpty(orderBy)) url += $"&order_by={orderBy}"; + if (!string.IsNullOrEmpty(dir)) url += $"&dir={dir}"; + return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminUserMediaResult>(url); + } + + public async IAsyncEnumerable<SynapseAdminUserMediaResult.MediaInfo> GetUserMediaEnumerableAsync(string mxid, int chunkSize = 100, string? orderBy = null, string? dir = null) { + SynapseAdminUserMediaResult? res = null; + do { + res = await GetUserMediaAsync(mxid, chunkSize, res?.NextToken, orderBy, dir); + foreach (var media in res.Media) { + yield return media; + } + } while (!string.IsNullOrEmpty(res.NextToken)); + } + + public async Task BlockRoom(string roomId, bool block = true) { + await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/rooms/{roomId}/block", new { + block + }); + } + + public async Task<SynapseAdminRoomDeleteResponse> DeleteRoom(string roomId, SynapseAdminRoomDeleteRequest request, bool waitForCompletion = true) { + var resp = await authenticatedHomeserver.ClientHttpClient.DeleteAsJsonAsync($"/_synapse/admin/v2/rooms/{roomId}", request); + var deleteResp = await resp.Content.ReadFromJsonAsync<SynapseAdminRoomDeleteResponse>(); + + if (waitForCompletion) { + while (true) { + var status = await GetRoomDeleteStatus(deleteResp!.DeleteId); + if (status?.Status != "pending") break; + await Task.Delay(1000); + } + } + + return deleteResp!; + } + + public async Task<SynapseAdminRoomDeleteStatus> GetRoomDeleteStatusByRoomId(string roomId) { + return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomDeleteStatus>( + $"/_synapse/admin/v2/rooms/{roomId}/delete_status"); + } + + public async Task<SynapseAdminRoomDeleteStatus> GetRoomDeleteStatus(string deleteId) { + return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomDeleteStatus>( + $"/_synapse/admin/v2/rooms/delete_status/{deleteId}"); + } + + public async Task<SynapseAdminRoomMemberListResult> GetRoomMembersAsync(string roomId) { + return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomMemberListResult>($"/_synapse/admin/v1/rooms/{roomId}/members"); + } + + public async Task QuarantineMediaByRoomId(string roomId) { + await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/room/{roomId}/media/quarantine", new { }); + } + + public async Task QuarantineMediaByUserId(string mxid) { + await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/user/{mxid}/media/quarantine", new { }); + } + + public async Task QuarantineMediaById(string serverName, string mediaId) { + await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/media/quarantine/{serverName}/{mediaId}", new { }); + } + + public async Task QuarantineMediaById(MxcUri mxcUri) { + await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/media/quarantine/{mxcUri.ServerName}/{mxcUri.MediaId}", new { }); + } + + public async Task DeleteMediaById(string serverName, string mediaId) { + await authenticatedHomeserver.ClientHttpClient.DeleteAsync($"/_synapse/admin/v1/media/{serverName}/{mediaId}"); + } + + public async Task DeleteMediaById(MxcUri mxcUri) { + await authenticatedHomeserver.ClientHttpClient.DeleteAsync($"/_synapse/admin/v1/media/{mxcUri.ServerName}/{mxcUri.MediaId}"); + } } \ No newline at end of file diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminUserCleanupExecutor.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminUserCleanupExecutor.cs new file mode 100644
index 0000000..6edf40c --- /dev/null +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminUserCleanupExecutor.cs
@@ -0,0 +1,27 @@ +namespace LibMatrix.Homeservers.ImplementationDetails.Synapse; + +public class SynapseAdminUserCleanupExecutor(AuthenticatedHomeserverSynapse homeserver) { + /* + Remove mappings of SSO IDs + Delete media uploaded by user (included avatar images) + Delete sent and received messages + Remove the user's creation (registration) timestamp + Remove rate limit overrides + Remove from monthly active users + Remove user's consent information (consent version and timestamp) + */ + public async Task CleanupUser(string mxid) { + // change the user's password to a random one + var newPassword = Guid.NewGuid().ToString(); + await homeserver.Admin.ResetPasswordAsync(mxid, newPassword, true); + await homeserver.Admin.DeleteAllMessages(mxid); + + } + private async Task RunUserTasks(string mxid) { + var auth = await homeserver.Admin.LoginUserAsync(mxid, TimeSpan.FromDays(1)); + var userHs = new AuthenticatedHomeserverSynapse(homeserver.ServerName, homeserver.WellKnownUris, null, auth.AccessToken); + await userHs.Initialise(); + + + } +} \ No newline at end of file