about summary refs log tree commit diff
diff options
context:
space:
mode:
m---------ArcaneLibs0
-rw-r--r--LibMatrix.EventTypes/LibMatrix.EventTypes.csproj2
-rw-r--r--LibMatrix/Extensions/MatrixHttpClient.Single.cs22
-rw-r--r--LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs58
-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
-rw-r--r--LibMatrix/LibMatrix.csproj2
-rw-r--r--LibMatrix/MxcUri.cs10
-rw-r--r--LibMatrix/RoomTypes/GenericRoom.cs2
-rw-r--r--LibMatrix/Services/HomeserverResolverService.cs6
-rw-r--r--LibMatrix/Services/ServiceInstaller.cs7
-rw-r--r--LibMatrix/Services/WellKnownResolver/WellKnownResolverConfiguration.cs49
-rw-r--r--LibMatrix/Services/WellKnownResolver/WellKnownResolverService.cs91
-rw-r--r--LibMatrix/Services/WellKnownResolver/WellKnownResolvers/BaseWellKnownResolver.cs52
-rw-r--r--LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ClientWellKnownResolver.cs42
-rw-r--r--LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ServerWellKnownResolver.cs38
-rw-r--r--LibMatrix/Services/WellKnownResolver/WellKnownResolvers/SupportWellKnownResolver.cs44
-rw-r--r--LibMatrix/Services/WellKnownResolverService.cs88
-rw-r--r--LibMatrix/Services/WellKnownResolvers/ClientWellKnownResolver.cs53
-rw-r--r--LibMatrix/Services/WellKnownResolvers/SupportWellKnownResolver.cs54
-rw-r--r--Tests/LibMatrix.Tests/Tests/HomeserverResolverTests/ClientWellKnownResolverTests.cs10
-rw-r--r--Utilities/LibMatrix.DevTestBot/LibMatrix.DevTestBot.csproj2
39 files changed, 1736 insertions, 271 deletions
diff --git a/ArcaneLibs b/ArcaneLibs
-Subproject 209108f67cddce1b7b94eabcd8ab38805bcfe3e
+Subproject 2956a7ce4e8d12034322a91b6afa449e7035485
diff --git a/LibMatrix.EventTypes/LibMatrix.EventTypes.csproj b/LibMatrix.EventTypes/LibMatrix.EventTypes.csproj

index ae2f4de..647b68f 100644 --- a/LibMatrix.EventTypes/LibMatrix.EventTypes.csproj +++ b/LibMatrix.EventTypes/LibMatrix.EventTypes.csproj
@@ -7,7 +7,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20250208-191806" Condition="'$(Configuration)' == 'Release'" /> + <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20250307-202359" Condition="'$(Configuration)' == 'Release'" /> <ProjectReference Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj" Condition="'$(Configuration)' == 'Debug'"/> </ItemGroup> diff --git a/LibMatrix/Extensions/MatrixHttpClient.Single.cs b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
index b42d94e..42f81a8 100644 --- a/LibMatrix/Extensions/MatrixHttpClient.Single.cs +++ b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
@@ -10,6 +10,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using ArcaneLibs; using ArcaneLibs.Extensions; +using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests; namespace LibMatrix.Extensions; @@ -91,7 +92,14 @@ public class MatrixHttpClient { responseMessage = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); } catch (Exception e) { - if (!e.Message.StartsWith("TypeError: NetworkError")) + if (e is TaskCanceledException or TimeoutException) { + if (request.Method == HttpMethod.Get && !cancellationToken.IsCancellationRequested) { + await Task.Delay(Random.Shared.Next(500, 2500), cancellationToken); + request.ResetSendStatus(); + return await SendAsync(request, cancellationToken); + } + } + else if (!e.ToString().StartsWith("TypeError: NetworkError")) Console.WriteLine( $"Failed to send request {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}):\n{e}"); throw; @@ -238,5 +246,17 @@ public class MatrixHttpClient { }; return await SendAsync(request, cancellationToken); } + + public async Task DeleteAsync(string url) { + var request = new HttpRequestMessage(HttpMethod.Delete, url); + await SendAsync(request); + } + + public async Task<HttpResponseMessage> DeleteAsJsonAsync<T>(string url, T payload) { + var request = new HttpRequestMessage(HttpMethod.Delete, url) { + Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") + }; + return await SendAsync(request); + } } #endif \ No newline at end of file diff --git a/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs b/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs
index 622eef6..9f11fa0 100644 --- a/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs +++ b/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs
@@ -3,35 +3,65 @@ namespace LibMatrix.Homeservers.Extensions.NamedCaches; public class NamedCache<T>(AuthenticatedHomeserverGeneric hs, string name) where T : class { private Dictionary<string, T>? _cache = new(); private DateTime _expiry = DateTime.MinValue; - + private SemaphoreSlim _lock = new(1, 1); + + public TimeSpan ExpiryTime { get; set; } = TimeSpan.FromMinutes(5); + public DateTime GetCurrentExpiryTime() => _expiry; + + /// <summary> + /// Update the cached map with the latest data from the homeserver. + /// </summary> + /// <returns>The updated data</returns> public async Task<Dictionary<string, T>> ReadCacheMapAsync() { - _cache = await hs.GetAccountDataOrNullAsync<Dictionary<string, T>>(name); + _cache = await hs.GetAccountDataAsync<Dictionary<string, T>>(name); return _cache ?? new(); } - - public async Task<Dictionary<string,T>> ReadCacheMapCachedAsync() { + + public async Task<Dictionary<string, T>> ReadCacheMapCachedAsync() { + await _lock.WaitAsync(); if (_expiry < DateTime.Now || _cache == null) { _cache = await ReadCacheMapAsync(); - _expiry = DateTime.Now.AddMinutes(5); + _expiry = DateTime.Now.Add(ExpiryTime); } + _lock.Release(); + return _cache; } - - public virtual async Task<T?> GetValueAsync(string key) { - return (await ReadCacheMapCachedAsync()).GetValueOrDefault(key); + + public virtual async Task<T?> GetValueAsync(string key, bool useCache = true) { + return (await (useCache ? ReadCacheMapCachedAsync() : ReadCacheMapAsync())).GetValueOrDefault(key); } - - public virtual async Task<T> SetValueAsync(string key, T value) { - var cache = await ReadCacheMapCachedAsync(); + + public virtual async Task<T> SetValueAsync(string key, T value, bool unsafeUseCache = false) { + if (!unsafeUseCache) + await _lock.WaitAsync(); + var cache = await (unsafeUseCache ? ReadCacheMapCachedAsync() : ReadCacheMapAsync()); cache[key] = value; await hs.SetAccountDataAsync(name, cache); + if (!unsafeUseCache) + _lock.Release(); + return value; } - - public virtual async Task<T> GetOrSetValueAsync(string key, Func<Task<T>> value) { - return (await ReadCacheMapCachedAsync()).GetValueOrDefault(key) ?? await SetValueAsync(key, await value()); + + public virtual async Task<T> RemoveValueAsync(string key, bool unsafeUseCache = false) { + if (!unsafeUseCache) + await _lock.WaitAsync(); + var cache = await (unsafeUseCache ? ReadCacheMapCachedAsync() : ReadCacheMapAsync()); + var removedValue = cache[key]; + cache.Remove(key); + await hs.SetAccountDataAsync(name, cache); + + if (!unsafeUseCache) + _lock.Release(); + + return removedValue; + } + + public virtual async Task<T> GetOrSetValueAsync(string key, Func<Task<T>> value, bool unsafeUseCache = false) { + return (await (unsafeUseCache ? ReadCacheMapCachedAsync() : ReadCacheMapAsync())).GetValueOrDefault(key) ?? await SetValueAsync(key, await value()); } } \ No newline at end of file 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 diff --git a/LibMatrix/LibMatrix.csproj b/LibMatrix/LibMatrix.csproj
index 4814a18..042d943 100644 --- a/LibMatrix/LibMatrix.csproj +++ b/LibMatrix/LibMatrix.csproj
@@ -18,7 +18,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20250208-191806" Condition="'$(Configuration)' == 'Release'" /> + <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20250307-202359" Condition="'$(Configuration)' == 'Release'" /> <ProjectReference Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj" Condition="'$(Configuration)' == 'Debug'"/> </ItemGroup> diff --git a/LibMatrix/MxcUri.cs b/LibMatrix/MxcUri.cs
index 02a8fa6..875ae53 100644 --- a/LibMatrix/MxcUri.cs +++ b/LibMatrix/MxcUri.cs
@@ -3,9 +3,9 @@ using System.Diagnostics.CodeAnalysis; namespace LibMatrix; public class MxcUri { - public required string ServerName; - public required string MediaId; - + public required string ServerName { get; set; } + public required string MediaId { get; set; } + public static MxcUri Parse([StringSyntax("Uri")] string mxcUri) { if (!mxcUri.StartsWith("mxc://")) throw new ArgumentException("Matrix Content URIs must start with 'mxc://'", nameof(mxcUri)); var parts = mxcUri[6..].Split('/'); @@ -15,13 +15,13 @@ public class MxcUri { MediaId = parts[1] }; } - + public static implicit operator MxcUri(string mxcUri) => Parse(mxcUri); public static implicit operator string(MxcUri mxcUri) => $"mxc://{mxcUri.ServerName}/{mxcUri.MediaId}"; public static implicit operator (string, string)(MxcUri mxcUri) => (mxcUri.ServerName, mxcUri.MediaId); public static implicit operator MxcUri((string serverName, string mediaId) mxcUri) => (mxcUri.serverName, mxcUri.mediaId); // public override string ToString() => $"mxc://{ServerName}/{MediaId}"; - + public string ToDownloadUri(string? baseUrl = null, string? filename = null, int? timeout = null) { var uri = $"{baseUrl}/_matrix/client/v1/media/download/{ServerName}/{MediaId}"; if (filename is not null) uri += $"/{filename}"; diff --git a/LibMatrix/RoomTypes/GenericRoom.cs b/LibMatrix/RoomTypes/GenericRoom.cs
index 8f1b56d..fb56f2e 100644 --- a/LibMatrix/RoomTypes/GenericRoom.cs +++ b/LibMatrix/RoomTypes/GenericRoom.cs
@@ -143,7 +143,7 @@ public class GenericRoom { var url = $"/_matrix/client/v3/rooms/{RoomId}/messages?dir={dir}"; if (!string.IsNullOrWhiteSpace(from)) url += $"&from={from}"; if (limit is not null) url += $"&limit={limit}"; - else if (!string.IsNullOrWhiteSpace(filter)) url += $"&filter={filter}"; + if (!string.IsNullOrWhiteSpace(filter)) url += $"&filter={filter}"; var res = await Homeserver.ClientHttpClient.GetFromJsonAsync<MessagesResponse>(url); return res; diff --git a/LibMatrix/Services/HomeserverResolverService.cs b/LibMatrix/Services/HomeserverResolverService.cs
index fa75f1e..01b11cc 100644 --- a/LibMatrix/Services/HomeserverResolverService.cs +++ b/LibMatrix/Services/HomeserverResolverService.cs
@@ -49,6 +49,7 @@ public class HomeserverResolverService { ArgumentNullException.ThrowIfNull(homeserver); _logger.LogTrace("Resolving client well-known: {homeserver}", homeserver); ClientWellKnown? clientWellKnown = null; + homeserver = homeserver.TrimEnd('/'); // check if homeserver has a client well-known if (homeserver.StartsWith("https://")) { clientWellKnown = await _httpClient.TryGetFromJsonAsync<ClientWellKnown>($"{homeserver}/.well-known/matrix/client"); @@ -80,6 +81,7 @@ public class HomeserverResolverService { ArgumentNullException.ThrowIfNull(homeserver); _logger.LogTrace($"Resolving server well-known: {homeserver}"); ServerWellKnown? serverWellKnown = null; + homeserver = homeserver.TrimEnd('/'); // check if homeserver has a server well-known if (homeserver.StartsWith("https://")) { serverWellKnown = await _httpClient.TryGetFromJsonAsync<ServerWellKnown>($"{homeserver}/.well-known/matrix/server"); @@ -95,7 +97,7 @@ public class HomeserverResolverService { _logger.LogInformation("Server well-known for {hs}: {json}", homeserver, serverWellKnown?.ToJson() ?? "null"); if (!string.IsNullOrWhiteSpace(serverWellKnown?.Homeserver)) { - var resolved = serverWellKnown.Homeserver; + var resolved = serverWellKnown.Homeserver.TrimEnd('/'); if (resolved.StartsWith("https://") || resolved.StartsWith("http://")) return resolved; if (await _httpClient.CheckSuccessStatus($"https://{resolved}/_matrix/federation/v1/version")) @@ -106,7 +108,7 @@ public class HomeserverResolverService { } // fallback: most servers host C2S and S2S on the same domain - var clientUrl = await _tryResolveClientEndpoint(homeserver); + var clientUrl = (await _tryResolveClientEndpoint(homeserver)).TrimEnd('/'); if (clientUrl is not null && await _httpClient.CheckSuccessStatus($"{clientUrl}/_matrix/federation/v1/version")) return clientUrl; diff --git a/LibMatrix/Services/ServiceInstaller.cs b/LibMatrix/Services/ServiceInstaller.cs
index ecc3f09..5ffd43a 100644 --- a/LibMatrix/Services/ServiceInstaller.cs +++ b/LibMatrix/Services/ServiceInstaller.cs
@@ -1,4 +1,5 @@ -using LibMatrix.Services.WellKnownResolvers; +using LibMatrix.Services.WellKnownResolver; +using LibMatrix.Services.WellKnownResolver.WellKnownResolvers; using Microsoft.Extensions.DependencyInjection; namespace LibMatrix.Services; @@ -10,6 +11,10 @@ public static class ServiceInstaller { //Add services services.AddSingleton<ClientWellKnownResolver>(); + services.AddSingleton<ServerWellKnownResolver>(); + services.AddSingleton<SupportWellKnownResolver>(); + if (!services.Any(x => x.ServiceType == typeof(WellKnownResolverConfiguration))) + services.AddSingleton<WellKnownResolverConfiguration>(); services.AddSingleton<WellKnownResolverService>(); // Legacy services.AddSingleton<HomeserverResolverService>(); diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolverConfiguration.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolverConfiguration.cs new file mode 100644
index 0000000..26a4c43 --- /dev/null +++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolverConfiguration.cs
@@ -0,0 +1,49 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Services.WellKnownResolver; + +public class WellKnownResolverConfiguration { + /// <summary> + /// Allow transparent downgrades to plaintext HTTP if HTTPS fails + /// Enabling this is unsafe! + /// </summary> + [JsonPropertyName("allow_http")] + public bool AllowHttp { get; set; } = false; + + /// <summary> + /// Use DNS resolution if available, for resolving SRV records + /// </summary> + [JsonPropertyName("allow_dns")] + public bool AllowDns { get; set; } = true; + + /// <summary> + /// Use system resolver(s) if empty + /// </summary> + [JsonPropertyName("dns_servers")] + public List<string> DnsServers { get; set; } = new(); + + /// <summary> + /// Same as AllowDns, but for DNS over HTTPS - useful in browser contexts + /// </summary> + [JsonPropertyName("allow_doh")] + public bool AllowDoh { get; set; } = true; + + /// <summary> + /// Use DNS over HTTPS - useful in browser contexts + /// Disabled if empty + /// </summary> + [JsonPropertyName("doh_servers")] + public List<string> DohServers { get; set; } = new(); + + /// <summary> + /// Whether to allow fallback subdomain lookups + /// </summary> + [JsonPropertyName("allow_fallback_subdomains")] + public bool AllowFallbackSubdomains { get; set; } = true; + + /// <summary> + /// Fallback subdomains to try if the homeserver is not found + /// </summary> + [JsonPropertyName("fallback_subdomains")] + public List<string> FallbackSubdomains { get; set; } = ["matrix", "chat", "im"]; +} \ No newline at end of file diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolverService.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolverService.cs new file mode 100644
index 0000000..4c78347 --- /dev/null +++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolverService.cs
@@ -0,0 +1,91 @@ +using System.Diagnostics; +using System.Text.Json.Serialization; +using LibMatrix.Extensions; +using LibMatrix.Services.WellKnownResolver.WellKnownResolvers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace LibMatrix.Services.WellKnownResolver; + +public class WellKnownResolverService { + private readonly MatrixHttpClient _httpClient = new(); + + private readonly ILogger<WellKnownResolverService> _logger; + private readonly ClientWellKnownResolver _clientWellKnownResolver; + private readonly SupportWellKnownResolver _supportWellKnownResolver; + private readonly ServerWellKnownResolver _serverWellKnownResolver; + private readonly WellKnownResolverConfiguration _configuration; + + public WellKnownResolverService(ILogger<WellKnownResolverService> logger, ClientWellKnownResolver clientWellKnownResolver, SupportWellKnownResolver supportWellKnownResolver, + WellKnownResolverConfiguration configuration, ServerWellKnownResolver serverWellKnownResolver) { + _logger = logger; + _clientWellKnownResolver = clientWellKnownResolver; + _supportWellKnownResolver = supportWellKnownResolver; + _configuration = configuration; + _serverWellKnownResolver = serverWellKnownResolver; + if (logger is NullLogger<WellKnownResolverService>) { + var stackFrame = new StackTrace(true).GetFrame(1); + Console.WriteLine( + $"WARN | Null logger provided to WellKnownResolverService!\n{stackFrame?.GetMethod()?.DeclaringType?.ToString() ?? "null"} at {stackFrame?.GetFileName() ?? "null"}:{stackFrame?.GetFileLineNumber().ToString() ?? "null"}"); + } + } + + public async Task<WellKnownRecords> TryResolveWellKnownRecords(string homeserver, bool includeClient = true, bool includeServer = true, bool includeSupport = true, + WellKnownResolverConfiguration? config = null) { + WellKnownRecords records = new(); + _logger.LogDebug($"Resolving well-knowns for {homeserver}"); + if (includeClient && await _clientWellKnownResolver.TryResolveWellKnown(homeserver, config ?? _configuration) is { } clientResult) { + records.ClientWellKnown = clientResult; + } + + if (includeServer && await _serverWellKnownResolver.TryResolveWellKnown(homeserver, config ?? _configuration) is { } serverResult) { + records.ServerWellKnown = serverResult; + } + + if (includeSupport && await _supportWellKnownResolver.TryResolveWellKnown(homeserver, config ?? _configuration) is { } supportResult) { + records.SupportWellKnown = supportResult; + } + + return records; + } + + public class WellKnownRecords { + public WellKnownResolutionResult<ClientWellKnown?>? ClientWellKnown { get; set; } + public WellKnownResolutionResult<ServerWellKnown?>? ServerWellKnown { get; set; } + public WellKnownResolutionResult<SupportWellKnown?>? SupportWellKnown { get; set; } + } + + public class WellKnownResolutionResult<T> { + public WellKnownSource Source { get; set; } + public string? SourceUri { get; set; } + public T? Content { get; set; } + public List<WellKnownResolutionWarning> Warnings { get; set; } = []; + } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum WellKnownSource { + None, + Https, + Dns, + Http, + ManualCheck, + Search + } + + public struct WellKnownResolutionWarning { + public WellKnownResolutionWarningType Type { get; set; } + public string Message { get; set; } + [JsonIgnore] + public Exception? Exception { get; set; } + public string? ExceptionMessage => Exception?.Message; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum WellKnownResolutionWarningType { + None, + Exception, + InvalidResponse, + Timeout, + SlowResponse + } + } +} \ No newline at end of file diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/BaseWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/BaseWellKnownResolver.cs new file mode 100644
index 0000000..cbe5b0a --- /dev/null +++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/BaseWellKnownResolver.cs
@@ -0,0 +1,52 @@ +using System.Diagnostics; +using System.Net.Http.Json; +using ArcaneLibs.Collections; +using LibMatrix.Extensions; + +namespace LibMatrix.Services.WellKnownResolver.WellKnownResolvers; + +public class BaseWellKnownResolver<T> where T : class, new() { + internal static readonly SemaphoreCache<WellKnownResolverService.WellKnownResolutionResult<T>> WellKnownCache = new() { + StoreNulls = false + }; + + internal static readonly MatrixHttpClient HttpClient = new(); + + internal async Task<WellKnownResolverService.WellKnownResolutionResult<T>> TryGetWellKnownFromUrl(string url, + WellKnownResolverService.WellKnownSource source) { + var sw = Stopwatch.StartNew(); + try { + var request = await HttpClient.GetAsync(url); + sw.Stop(); + var result = new WellKnownResolverService.WellKnownResolutionResult<T> { + Content = await request.Content.ReadFromJsonAsync<T>(), + Source = source, + SourceUri = url, + Warnings = [] + }; + + if (sw.ElapsedMilliseconds > 1000) { + // logger.LogWarning($"Support well-known resolution took {sw.ElapsedMilliseconds}ms: {url}"); + result.Warnings.Add(new() { + Type = WellKnownResolverService.WellKnownResolutionWarning.WellKnownResolutionWarningType.SlowResponse, + Message = $"Well-known resolution took {sw.ElapsedMilliseconds}ms" + }); + } + + return result; + } + catch (Exception e) { + return new WellKnownResolverService.WellKnownResolutionResult<T> { + Source = source, + SourceUri = url, + Warnings = [ + new() { + Exception = e, + Type = WellKnownResolverService.WellKnownResolutionWarning.WellKnownResolutionWarningType.Exception, + Message = e.Message + } + ] + }; + } + } +} \ No newline at end of file diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ClientWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ClientWellKnownResolver.cs new file mode 100644
index 0000000..f8de38d --- /dev/null +++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ClientWellKnownResolver.cs
@@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; +using ArcaneLibs.Collections; +using LibMatrix.Extensions; +using Microsoft.Extensions.Logging; +using WellKnownType = LibMatrix.Services.WellKnownResolver.WellKnownResolvers.ClientWellKnown; +using ResultType = + LibMatrix.Services.WellKnownResolver.WellKnownResolverService.WellKnownResolutionResult<LibMatrix.Services.WellKnownResolver.WellKnownResolvers.ClientWellKnown?>; + +namespace LibMatrix.Services.WellKnownResolver.WellKnownResolvers; + +public class ClientWellKnownResolver(ILogger<ClientWellKnownResolver> logger, WellKnownResolverConfiguration configuration) + : BaseWellKnownResolver<ClientWellKnown> { + private static readonly SemaphoreCache<WellKnownResolverService.WellKnownResolutionResult<ClientWellKnown>> ClientWellKnownCache = new() { + StoreNulls = false + }; + + private static readonly MatrixHttpClient HttpClient = new(); + + public Task<WellKnownResolverService.WellKnownResolutionResult<ClientWellKnown>> TryResolveWellKnown(string homeserver, WellKnownResolverConfiguration? config = null) { + config ??= configuration; + return ClientWellKnownCache.TryGetOrAdd(homeserver, async () => { + logger.LogTrace($"Resolving client well-known: {homeserver}"); + + WellKnownResolverService.WellKnownResolutionResult<ClientWellKnown> result = + await TryGetWellKnownFromUrl($"https://{homeserver}/.well-known/matrix/client", WellKnownResolverService.WellKnownSource.Https); + if (result.Content != null) return result; + + + return result; + }); + } +} + +public class ClientWellKnown { + [JsonPropertyName("m.homeserver")] + public WellKnownHomeserver Homeserver { get; set; } + + public class WellKnownHomeserver { + [JsonPropertyName("base_url")] + public required string BaseUrl { get; set; } + } +} \ No newline at end of file diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ServerWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ServerWellKnownResolver.cs new file mode 100644
index 0000000..a99185c --- /dev/null +++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ServerWellKnownResolver.cs
@@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; +using ArcaneLibs.Collections; +using LibMatrix.Extensions; +using Microsoft.Extensions.Logging; +using WellKnownType = LibMatrix.Services.WellKnownResolver.WellKnownResolvers.ServerWellKnown; +using ResultType = + LibMatrix.Services.WellKnownResolver.WellKnownResolverService.WellKnownResolutionResult<LibMatrix.Services.WellKnownResolver.WellKnownResolvers.ServerWellKnown?>; + +namespace LibMatrix.Services.WellKnownResolver.WellKnownResolvers; + +public class ServerWellKnownResolver(ILogger<ServerWellKnownResolver> logger, WellKnownResolverConfiguration configuration) + : BaseWellKnownResolver<ServerWellKnown> { + private static readonly SemaphoreCache<WellKnownResolverService.WellKnownResolutionResult<ServerWellKnown>> ClientWellKnownCache = new() { + StoreNulls = false + }; + + private static readonly MatrixHttpClient HttpClient = new(); + + public Task<WellKnownResolverService.WellKnownResolutionResult<ServerWellKnown>> TryResolveWellKnown(string homeserver, WellKnownResolverConfiguration? config = null) { + config ??= configuration; + return ClientWellKnownCache.TryGetOrAdd(homeserver, async () => { + logger.LogTrace($"Resolving client well-known: {homeserver}"); + + WellKnownResolverService.WellKnownResolutionResult<ServerWellKnown> result = + await TryGetWellKnownFromUrl($"https://{homeserver}/.well-known/matrix/server", WellKnownResolverService.WellKnownSource.Https); + if (result.Content != null) return result; + + + return result; + }); + } +} + + +public class ServerWellKnown { + [JsonPropertyName("m.server")] + public string Homeserver { get; set; } +} \ No newline at end of file diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/SupportWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/SupportWellKnownResolver.cs new file mode 100644
index 0000000..99313db --- /dev/null +++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/SupportWellKnownResolver.cs
@@ -0,0 +1,44 @@ +using System.Diagnostics; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using WellKnownType = LibMatrix.Services.WellKnownResolver.WellKnownResolvers.SupportWellKnown; +using ResultType = LibMatrix.Services.WellKnownResolver.WellKnownResolverService.WellKnownResolutionResult< + LibMatrix.Services.WellKnownResolver.WellKnownResolvers.SupportWellKnown? +>; + +namespace LibMatrix.Services.WellKnownResolver.WellKnownResolvers; + +public class SupportWellKnownResolver(ILogger<SupportWellKnownResolver> logger, WellKnownResolverConfiguration configuration) : BaseWellKnownResolver<WellKnownType> { + public Task<ResultType> TryResolveWellKnown(string homeserver, WellKnownResolverConfiguration? config = null) { + config ??= configuration; + return WellKnownCache.TryGetOrAdd(homeserver, async () => { + logger.LogTrace($"Resolving support well-known: {homeserver}"); + + ResultType result = await TryGetWellKnownFromUrl($"https://{homeserver}/.well-known/matrix/support", WellKnownResolverService.WellKnownSource.Https); + if (result.Content != null) + return result; + + return null; + }); + } +} + +public class SupportWellKnown { + [JsonPropertyName("contacts")] + public List<WellKnownContact>? Contacts { get; set; } + + [JsonPropertyName("support_page")] + public Uri? SupportPage { get; set; } + + public class WellKnownContact { + [JsonPropertyName("email_address")] + public string? EmailAddress { get; set; } + + [JsonPropertyName("matrix_id")] + public string? MatrixId { get; set; } + + [JsonPropertyName("role")] + public required string Role { get; set; } + } +} \ No newline at end of file diff --git a/LibMatrix/Services/WellKnownResolverService.cs b/LibMatrix/Services/WellKnownResolverService.cs deleted file mode 100644
index ab2660f..0000000 --- a/LibMatrix/Services/WellKnownResolverService.cs +++ /dev/null
@@ -1,88 +0,0 @@ -using System.Diagnostics; -using System.Text.Json.Serialization; -using ArcaneLibs.Collections; -using ArcaneLibs.Extensions; -using LibMatrix.Extensions; -using LibMatrix.Services.WellKnownResolvers; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace LibMatrix.Services; - -public class WellKnownResolverService { - private readonly MatrixHttpClient _httpClient = new(); - - private readonly ILogger<WellKnownResolverService> _logger; - - public WellKnownResolverService(ILogger<WellKnownResolverService> logger) { - _logger = logger; - if (logger is NullLogger<WellKnownResolverService>) { - var stackFrame = new StackTrace(true).GetFrame(1); - Console.WriteLine( - $"WARN | Null logger provided to WellKnownResolverService!\n{stackFrame?.GetMethod()?.DeclaringType?.ToString() ?? "null"} at {stackFrame?.GetFileName() ?? "null"}:{stackFrame?.GetFileLineNumber().ToString() ?? "null"}"); - } - } - - public async Task<WellKnownRecords> TryResolveWellKnownRecords(string homeserver) { - WellKnownRecords records = new(); - _logger.LogDebug($"Resolving well-knowns for {homeserver}"); - - return records; - } - - - - public class ServerWellKnown { - [JsonPropertyName("m.server")] - public required string Homeserver { get; set; } - } - - public class WellKnownRecords { - public ClientWellKnownResolver.ClientWellKnown? ClientWellKnown { get; set; } - public ServerWellKnown? ServerWellKnown { get; set; } - public SupportWellKnownResolver.SupportWellKnown? SupportWellKnown { get; set; } - - /// <summary> - /// Reports the source of the client well-known data. - /// </summary> - public WellKnownSource? ClientWellKnownSource { get; set; } - - /// <summary> - /// Reports the source of the server well-known data. - /// </summary> - public WellKnownSource? ServerWellKnownSource { get; set; } - - /// <summary> - /// Reports the source of the support well-known data. - /// </summary> - public WellKnownSource? SupportWellKnownSource { get; set; } - } - - public struct WellKnownResolutionResult<T> { - public WellKnownResolverService.WellKnownSource Source { get; set; } - public T WellKnown { get; set; } - public List<WellKnownResolverService.WellKnownResolutionWarning> Warnings { get; set; } - } - - public enum WellKnownSource { - None, - Https, - Dns, - Http, - ManualCheck, - Search - } - - public struct WellKnownResolutionWarning { - public WellKnownResolutionWarningType Type { get; set; } - public string Message { get; set; } - public Exception? Exception { get; set; } - - public enum WellKnownResolutionWarningType { - None, - Exception, - InvalidResponse, - Timeout - } - } -} \ No newline at end of file diff --git a/LibMatrix/Services/WellKnownResolvers/ClientWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolvers/ClientWellKnownResolver.cs deleted file mode 100644
index d4d0166..0000000 --- a/LibMatrix/Services/WellKnownResolvers/ClientWellKnownResolver.cs +++ /dev/null
@@ -1,53 +0,0 @@ -using System.Text.Json.Serialization; -using ArcaneLibs.Collections; -using LibMatrix.Extensions; -using Microsoft.Extensions.Logging; - -namespace LibMatrix.Services.WellKnownResolvers; - -public class ClientWellKnownResolver(ILogger<ClientWellKnownResolver> logger) { - private static readonly SemaphoreCache<WellKnownResolutionResult> ClientWellKnownCache = new() { - StoreNulls = false - }; - private static readonly MatrixHttpClient HttpClient = new(); - - public Task<WellKnownResolutionResult> TryResolveClientWellKnown(string homeserver) { - return ClientWellKnownCache.TryGetOrAdd(homeserver, async () => { - logger.LogTrace($"Resolving client well-known: {homeserver}"); - if ((await TryGetClientWellKnownFromHttps(homeserver)) is { } clientWellKnown) - return new() { - Source = WellKnownResolverService.WellKnownSource.Https, - WellKnown = clientWellKnown - }; - - return default!; - }); - } - - private async Task<ClientWellKnown?> TryGetClientWellKnownFromHttps(string homeserver) { - try { - return await HttpClient.TryGetFromJsonAsync<ClientWellKnown>($"https://{homeserver}/.well-known/matrix/client"); - } - catch { - return null; - } - } - - - - public class ClientWellKnown { - [JsonPropertyName("m.homeserver")] - public required WellKnownHomeserver Homeserver { get; set; } - - public class WellKnownHomeserver { - [JsonPropertyName("base_url")] - public required string BaseUrl { get; set; } - } - } - - public struct WellKnownResolutionResult { - public WellKnownResolverService.WellKnownSource Source { get; set; } - public ClientWellKnown WellKnown { get; set; } - public List<WellKnownResolverService.WellKnownResolutionWarning> Warnings { get; set; } - } -} \ No newline at end of file diff --git a/LibMatrix/Services/WellKnownResolvers/SupportWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolvers/SupportWellKnownResolver.cs deleted file mode 100644
index 1d7567a..0000000 --- a/LibMatrix/Services/WellKnownResolvers/SupportWellKnownResolver.cs +++ /dev/null
@@ -1,54 +0,0 @@ -using System.Text.Json.Serialization; -using ArcaneLibs.Collections; -using LibMatrix.Extensions; -using Microsoft.Extensions.Logging; - -namespace LibMatrix.Services.WellKnownResolvers; - -public class SupportWellKnownResolver(ILogger<SupportWellKnownResolver> logger) { - private static readonly SemaphoreCache<WellKnownResolverService.WellKnownResolutionResult<SupportWellKnown>> ClientWellKnownCache = new() { - StoreNulls = false - }; - - private static readonly MatrixHttpClient HttpClient = new(); - - public Task<WellKnownResolverService.WellKnownResolutionResult<SupportWellKnown>> TryResolveClientWellKnown(string homeserver) { - return ClientWellKnownCache.TryGetOrAdd(homeserver, async () => { - logger.LogTrace($"Resolving client well-known: {homeserver}"); - if ((await TryGetClientWellKnownFromHttps(homeserver)) is { } clientWellKnown) - return new() { - Source = WellKnownResolverService.WellKnownSource.Https, - WellKnown = clientWellKnown - }; - return default!; - }); - } - - private async Task<SupportWellKnown?> TryGetClientWellKnownFromHttps(string homeserver) { - try { - return await HttpClient.TryGetFromJsonAsync<SupportWellKnown>($"https://{homeserver}/.well-known/matrix/support"); - } - catch { - return null; - } - } - - public struct SupportWellKnown { - [JsonPropertyName("contacts")] - public List<WellKnownContact>? Contacts { get; set; } - - [JsonPropertyName("support_page")] - public Uri? SupportPage { get; set; } - - public class WellKnownContact { - [JsonPropertyName("email_address")] - public string? EmailAddress { get; set; } - - [JsonPropertyName("matrix_id")] - public string? MatrixId { get; set; } - - [JsonPropertyName("role")] - public required string Role { get; set; } - } - } -} \ No newline at end of file diff --git a/Tests/LibMatrix.Tests/Tests/HomeserverResolverTests/ClientWellKnownResolverTests.cs b/Tests/LibMatrix.Tests/Tests/HomeserverResolverTests/ClientWellKnownResolverTests.cs
index f6dfd97..ea494fa 100644 --- a/Tests/LibMatrix.Tests/Tests/HomeserverResolverTests/ClientWellKnownResolverTests.cs +++ b/Tests/LibMatrix.Tests/Tests/HomeserverResolverTests/ClientWellKnownResolverTests.cs
@@ -1,5 +1,5 @@ using LibMatrix.Services; -using LibMatrix.Services.WellKnownResolvers; +using LibMatrix.Services.WellKnownResolver.WellKnownResolvers; using LibMatrix.Tests.Fixtures; using Xunit.Abstractions; using Xunit.Microsoft.DependencyInjection.Abstracts; @@ -18,16 +18,16 @@ public class ClientWellKnownResolverTests : TestBed<TestFixture> { [Fact] public async Task ResolveServerClient() { var tasks = _config.ExpectedHomeserverClientMappings.Select(async mapping => { - var server = await _resolver.TryResolveClientWellKnown(mapping.Key); - Assert.Equal(mapping.Value, server.WellKnown.Homeserver.BaseUrl); + var server = await _resolver.TryResolveWellKnown(mapping.Key); + Assert.Equal(mapping.Value, server.Content.Homeserver.BaseUrl); return server; }).ToList(); await Task.WhenAll(tasks); } private async Task AssertClientWellKnown(string homeserver, string expected) { - var server = await _resolver.TryResolveClientWellKnown(homeserver); - Assert.Equal(expected, server.WellKnown.Homeserver.BaseUrl); + var server = await _resolver.TryResolveWellKnown(homeserver); + Assert.Equal(expected, server.Content.Homeserver.BaseUrl); } [Fact] diff --git a/Utilities/LibMatrix.DevTestBot/LibMatrix.DevTestBot.csproj b/Utilities/LibMatrix.DevTestBot/LibMatrix.DevTestBot.csproj
index 8817e9c..7897e28 100644 --- a/Utilities/LibMatrix.DevTestBot/LibMatrix.DevTestBot.csproj +++ b/Utilities/LibMatrix.DevTestBot/LibMatrix.DevTestBot.csproj
@@ -18,7 +18,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="ArcaneLibs.StringNormalisation" Version="1.0.0-preview.20250208-191806" Condition="'$(Configuration)' == 'Release'" /> + <PackageReference Include="ArcaneLibs.StringNormalisation" Version="1.0.0-preview.20250307-202359" Condition="'$(Configuration)' == 'Release'" /> <ProjectReference Include="..\..\ArcaneLibs\ArcaneLibs.StringNormalisation\ArcaneLibs.StringNormalisation.csproj" Condition="'$(Configuration)' == 'Debug'"/> <ProjectReference Include="..\..\LibMatrix\LibMatrix.csproj"/> </ItemGroup>