diff options
34 files changed, 1420 insertions, 140 deletions
diff --git a/ArcaneLibs b/ArcaneLibs -Subproject f7bcde91ffd1ec285a3f6d3febbb2433daf9634 +Subproject d74542fb951759ee6abef21c3b68a3867933c0b diff --git a/LibMatrix/EventIdResponse.cs b/LibMatrix/EventIdResponse.cs index 6a04229..c2ad273 100644 --- a/LibMatrix/EventIdResponse.cs +++ b/LibMatrix/EventIdResponse.cs @@ -2,7 +2,9 @@ using System.Text.Json.Serialization; namespace LibMatrix; -public class EventIdResponse { +public class EventIdResponse(string eventId) { + public EventIdResponse(StateEventResponse stateEventResponse) : this(stateEventResponse.EventId ?? throw new NullReferenceException("State event ID is null!")) { } + [JsonPropertyName("event_id")] - public required string EventId { get; set; } + public string EventId { get; set; } = eventId; } \ No newline at end of file diff --git a/LibMatrix/Helpers/SyncHelper.cs b/LibMatrix/Helpers/SyncHelper.cs index f9a7cb7..9d339e4 100644 --- a/LibMatrix/Helpers/SyncHelper.cs +++ b/LibMatrix/Helpers/SyncHelper.cs @@ -55,7 +55,7 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg private async Task updateFilterAsync() { if (!string.IsNullOrWhiteSpace(NamedFilterName)) { - _filterId = await homeserver.GetNamedFilterIdOrNullAsync(NamedFilterName); + _filterId = await homeserver.GetOrUploadNamedFilterIdAsync(NamedFilterName); if (_filterId is null) if (logger is null) Console.WriteLine($"Failed to get filter ID for named filter {NamedFilterName}"); else logger.LogWarning("Failed to get filter ID for named filter {NamedFilterName}", NamedFilterName); diff --git a/LibMatrix/MatrixException.cs b/LibMatrix/MatrixException.cs index 8ec8fd5..eb207da 100644 --- a/LibMatrix/MatrixException.cs +++ b/LibMatrix/MatrixException.cs @@ -61,4 +61,39 @@ public class MatrixException : Exception { "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM" => $"Cannot leave server notice room: {Error}", _ => $"Unknown error: {new { ErrorCode, Error, SoftLogout, RetryAfterMs }.ToJson(ignoreNull: true)}" }}"; + + public static class ErrorCodes { + public const string M_FORBIDDEN = "M_FORBIDDEN"; + public const string M_UNKNOWN_TOKEN = "M_UNKNOWN_TOKEN"; + public const string M_MISSING_TOKEN = "M_MISSING_TOKEN"; + public const string M_BAD_JSON = "M_BAD_JSON"; + public const string M_NOT_JSON = "M_NOT_JSON"; + public const string M_NOT_FOUND = "M_NOT_FOUND"; + public const string M_LIMIT_EXCEEDED = "M_LIMIT_EXCEEDED"; + public const string M_UNRECOGNISED = "M_UNRECOGNISED"; + public const string M_UNKOWN = "M_UNKOWN"; + public const string M_UNAUTHORIZED = "M_UNAUTHORIZED"; + public const string M_USER_DEACTIVATED = "M_USER_DEACTIVATED"; + public const string M_USER_IN_USE = "M_USER_IN_USE"; + public const string M_INVALID_USERNAME = "M_INVALID_USERNAME"; + public const string M_ROOM_IN_USE = "M_ROOM_IN_USE"; + public const string M_INVALID_ROOM_STATE = "M_INVALID_ROOM_STATE"; + public const string M_THREEPID_IN_USE = "M_THREEPID_IN_USE"; + public const string M_THREEPID_NOT_FOUND = "M_THREEPID_NOT_FOUND"; + public const string M_THREEPID_AUTH_FAILED = "M_THREEPID_AUTH_FAILED"; + public const string M_THREEPID_DENIED = "M_THREEPID_DENIED"; + public const string M_SERVER_NOT_TRUSTED = "M_SERVER_NOT_TRUSTED"; + public const string M_UNSUPPORTED_ROOM_VERSION = "M_UNSUPPORTED_ROOM_VERSION"; + public const string M_INCOMPATIBLE_ROOM_VERSION = "M_INCOMPATIBLE_ROOM_VERSION"; + public const string M_BAD_STATE = "M_BAD_STATE"; + public const string M_GUEST_ACCESS_FORBIDDEN = "M_GUEST_ACCESS_FORBIDDEN"; + public const string M_CAPTCHA_NEEDED = "M_CAPTCHA_NEEDED"; + public const string M_CAPTCHA_INVALID = "M_CAPTCHA_INVALID"; + public const string M_MISSING_PARAM = "M_MISSING_PARAM"; + public const string M_INVALID_PARAM = "M_INVALID_PARAM"; + public const string M_TOO_LARGE = "M_TOO_LARGE"; + public const string M_EXCLUSIVE = "M_EXCLUSIVE"; + public const string M_RESOURCE_LIMIT_EXCEEDED = "M_RESOURCE_LIMIT_EXCEEDED"; + public const string M_CANNOT_LEAVE_SERVER_NOTICE_ROOM = "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM"; + } } \ No newline at end of file diff --git a/LibMatrix/Responses/CreateRoomRequest.cs b/LibMatrix/Responses/CreateRoomRequest.cs index d78f574..ee4317e 100644 --- a/LibMatrix/Responses/CreateRoomRequest.cs +++ b/LibMatrix/Responses/CreateRoomRequest.cs @@ -12,7 +12,9 @@ namespace LibMatrix.Responses; public class CreateRoomRequest { [JsonIgnore] public CreationContentBaseType CreationContentBaseType; - public CreateRoomRequest() => CreationContentBaseType = new CreationContentBaseType(this); + public CreateRoomRequest() { + CreationContentBaseType = new CreationContentBaseType(this); + } [JsonPropertyName("name")] public string? Name { get; set; } @@ -37,7 +39,7 @@ public class CreateRoomRequest { public string? Visibility { get; set; } [JsonPropertyName("power_level_content_override")] - public RoomPowerLevelEventContent PowerLevelContentOverride { get; set; } = null!; + public RoomPowerLevelEventContent? PowerLevelContentOverride { get; set; } = null!; [JsonPropertyName("creation_content")] public JsonObject CreationContent { get; set; } = new(); diff --git a/LibMatrix/Responses/RoomKeysResponse.cs b/LibMatrix/Responses/RoomKeysResponse.cs new file mode 100644 index 0000000..8dc305a --- /dev/null +++ b/LibMatrix/Responses/RoomKeysResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace LibMatrix.Responses; + +public class RoomKeysRequest { + [JsonPropertyName("algorithm")] + public string Algorithm { get; set; } + + [JsonPropertyName("auth_data")] + public JsonObject AuthData { get; set; } +} +public class RoomKeysResponse : RoomKeysRequest { + [JsonPropertyName("version")] + public string Version { get; set; } + + [JsonPropertyName("etag")] + public string Etag { get; set; } +} \ No newline at end of file diff --git a/LibMatrix/Responses/SyncResponse.cs b/LibMatrix/Responses/SyncResponse.cs index 49259d0..e4addb6 100644 --- a/LibMatrix/Responses/SyncResponse.cs +++ b/LibMatrix/Responses/SyncResponse.cs @@ -83,6 +83,13 @@ public class SyncResponse { public SummaryDataStructure? Summary { get; set; } public class TimelineDataStructure { + public TimelineDataStructure() { } + + public TimelineDataStructure(List<StateEventResponse>? events, bool? limited) { + Events = events; + Limited = limited; + } + [JsonPropertyName("events")] public List<StateEventResponse>? Events { get; set; } diff --git a/LibMatrix/Services/HomeserverProviderService.cs b/LibMatrix/Services/HomeserverProviderService.cs index 7a13816..8e2e15b 100644 --- a/LibMatrix/Services/HomeserverProviderService.cs +++ b/LibMatrix/Services/HomeserverProviderService.cs @@ -43,7 +43,7 @@ public class HomeserverProviderService(ILogger<HomeserverProviderService> logger serverVersion = serverVersion = await (rhs.FederationClient?.GetServerVersionAsync() ?? Task.FromResult<ServerVersionResponse?>(null)!); } catch (Exception e) { - logger.LogError(e, "Failed to get server version for {homeserver}", homeserver); + logger.LogWarning(e, "Failed to get server version for {homeserver}", homeserver); sem.Release(); throw; } diff --git a/LibMatrix/StateEvent.cs b/LibMatrix/StateEvent.cs index 019c428..541fb78 100644 --- a/LibMatrix/StateEvent.cs +++ b/LibMatrix/StateEvent.cs @@ -1,5 +1,6 @@ using System.Collections.Frozen; using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Json; @@ -25,7 +26,7 @@ public class StateEvent { return dict; }).ToFrozenDictionary(); - public static Type GetStateEventType(string type) => KnownStateEventTypesByName.GetValueOrDefault(type) ?? typeof(UnknownEventContent); + public static Type GetStateEventType(string? type) => string.IsNullOrWhiteSpace(type) ? typeof(UnknownEventContent) : KnownStateEventTypesByName.GetValueOrDefault(type) ?? typeof(UnknownEventContent); [JsonIgnore] public Type MappedType => GetStateEventType(Type); @@ -73,10 +74,10 @@ public class StateEvent { } [JsonPropertyName("state_key")] - public string StateKey { get; set; } = ""; + public string StateKey { get; set; } [JsonPropertyName("type")] - public required string Type { get; set; } + public string Type { get; set; } [JsonPropertyName("replaces_state")] public string? ReplacesState { get; set; } @@ -138,7 +139,7 @@ public class StateEvent { public class StateEventResponse : StateEvent { [JsonPropertyName("origin_server_ts")] - public ulong? OriginServerTs { get; set; } + public long? OriginServerTs { get; set; } [JsonPropertyName("room_id")] public string? RoomId { get; set; } @@ -152,9 +153,6 @@ public class StateEventResponse : StateEvent { [JsonPropertyName("event_id")] public string? EventId { get; set; } - [JsonPropertyName("replaces_state")] - public new string? ReplacesState { get; set; } - public class UnsignedData { [JsonPropertyName("age")] public ulong? Age { get; set; } @@ -181,6 +179,12 @@ public class StateEventResponse : StateEvent { internal partial class ChunkedStateEventResponseSerializerContext : JsonSerializerContext; public class EventList { + public EventList() { } + + public EventList(List<StateEventResponse>? events) { + Events = events; + } + [JsonPropertyName("events")] public List<StateEventResponse>? Events { get; set; } = new(); } @@ -190,6 +194,14 @@ public class ChunkedStateEventResponse { public List<StateEventResponse>? Chunk { get; set; } = new(); } +public class PaginatedChunkedStateEventResponse : ChunkedStateEventResponse { + [JsonPropertyName("start")] + public string? Start { get; set; } + + [JsonPropertyName("end")] + public string? End { get; set; } +} + #region Unused code /* diff --git a/Tests/LibMatrix.HomeserverEmulator/.gitignore b/Tests/LibMatrix.HomeserverEmulator/.gitignore new file mode 100644 index 0000000..8fce603 --- /dev/null +++ b/Tests/LibMatrix.HomeserverEmulator/.gitignore @@ -0,0 +1 @@ +data/ diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/AuthController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/AuthController.cs index d0496bf..da56ec4 100644 --- a/Tests/LibMatrix.HomeserverEmulator/Controllers/AuthController.cs +++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/AuthController.cs @@ -8,16 +8,42 @@ namespace LibMatrix.HomeserverEmulator.Controllers; [ApiController] [Route("/_matrix/client/{version}/")] -public class AuthController(ILogger<AuthController> logger, UserStore userStore) : ControllerBase { +public class AuthController(ILogger<AuthController> logger, UserStore userStore, TokenService tokenService) : ControllerBase { [HttpPost("login")] public async Task<LoginResponse> Login(LoginRequest request) { - var user = await userStore.CreateUser($"@{Guid.NewGuid().ToString()}:{Request.Host}", Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), new Dictionary<string, object>()); - var loginResponse = new LoginResponse { - AccessToken = user.AccessToken, - DeviceId = user.DeviceId, - UserId = user.UserId + if(!request.Identifier.User.StartsWith('@')) + request.Identifier.User = $"@{request.Identifier.User}:{tokenService.GenerateServerName(HttpContext)}"; + if(request.Identifier.User.EndsWith("localhost")) + request.Identifier.User = request.Identifier.User.Replace("localhost", tokenService.GenerateServerName(HttpContext)); + + var user = await userStore.GetUserById(request.Identifier.User); + if(user is null) { + user = await userStore.CreateUser(request.Identifier.User); + } + + return user.Login(); + } + + [HttpGet("login")] + public async Task<LoginFlowsResponse> GetLoginFlows() { + return new LoginFlowsResponse { + Flows = ((string[]) [ + "m.login.password", + "m.login.recaptcha", + "m.login.sso", + "m.login.email.identity", + "m.login.msisdn", + "m.login.dummy", + "m.login.registration_token", + ]).Select(x => new LoginFlowsResponse.LoginFlow { Type = x }).ToList() }; + } +} + +public class LoginFlowsResponse { + public required List<LoginFlow> Flows { get; set; } - return loginResponse; + public class LoginFlow { + public required string Type { get; set; } } } \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/HEDebug/HEDebugController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/HEDebug/HEDebugController.cs index 0c4d8bd..9e0c17c 100644 --- a/Tests/LibMatrix.HomeserverEmulator/Controllers/HEDebug/HEDebugController.cs +++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/HEDebug/HEDebugController.cs @@ -8,7 +8,7 @@ namespace LibMatrix.HomeserverEmulator.Controllers; public class HEDebugController(ILogger<HEDebugController> logger, UserStore userStore, RoomStore roomStore) : ControllerBase { [HttpGet("users")] public async Task<List<UserStore.User>> GetUsers() { - return userStore._users; + return userStore._users.ToList(); } [HttpGet("rooms")] diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/KeysController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/KeysController.cs new file mode 100644 index 0000000..7898a8c --- /dev/null +++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/KeysController.cs @@ -0,0 +1,103 @@ +// using System.Security.Cryptography; +// using System.Text.Json.Nodes; +// using System.Text.Json.Serialization; +// using LibMatrix.HomeserverEmulator.Services; +// using LibMatrix.Responses; +// using LibMatrix.Services; +// using Microsoft.AspNetCore.Mvc; +// +// namespace LibMatrix.HomeserverEmulator.Controllers; +// +// [ApiController] +// [Route("/_matrix/client/{version}/")] +// public class KeysController(ILogger<KeysController> logger, TokenService tokenService, UserStore userStore) : ControllerBase { +// [HttpGet("room_keys/version")] +// public async Task<RoomKeysResponse> GetRoomKeys() { +// var token = tokenService.GetAccessToken(HttpContext); +// if (token == null) +// throw new MatrixException() { +// ErrorCode = "M_MISSING_TOKEN", +// Error = "Missing token" +// }; +// +// var user = await userStore.GetUserByToken(token); +// if (user == null) +// throw new MatrixException() { +// ErrorCode = "M_UNKNOWN_TOKEN", +// Error = "No such user" +// }; +// +// if (user.RoomKeys is not { Count: > 0 }) +// throw new MatrixException() { +// ErrorCode = "M_NOT_FOUND", +// Error = "No keys found" +// }; +// +// return user.RoomKeys.Values.Last(); +// } +// +// [HttpPost("room_keys/version")] +// public async Task<RoomKeysResponse> UploadRoomKeys(RoomKeysRequest request) { +// var token = tokenService.GetAccessToken(HttpContext); +// if (token == null) +// throw new MatrixException() { +// ErrorCode = "M_MISSING_TOKEN", +// Error = "Missing token" +// }; +// +// var user = await userStore.GetUserByToken(token); +// if (user == null) +// throw new MatrixException() { +// ErrorCode = "M_UNKNOWN_TOKEN", +// Error = "No such user" +// }; +// +// var roomKeys = new RoomKeysResponse { +// Version = Guid.NewGuid().ToString(), +// Etag = Guid.NewGuid().ToString(), +// Algorithm = request.Algorithm, +// AuthData = request.AuthData +// }; +// user.RoomKeys.Add(roomKeys.Version, roomKeys); +// return roomKeys; +// } +// +// [HttpPost("keys/device_signing/upload")] +// public async Task<object> UploadDeviceSigning(JsonObject request) { +// var token = tokenService.GetAccessToken(HttpContext); +// if (token == null) +// throw new MatrixException() { +// ErrorCode = "M_MISSING_TOKEN", +// Error = "Missing token" +// }; +// +// var user = await userStore.GetUserByToken(token); +// if (user == null) +// throw new MatrixException() { +// ErrorCode = "M_UNKNOWN_TOKEN", +// Error = "No such user" +// }; +// +// return new { }; +// } +// } +// +// public class DeviceSigningRequest { +// public CrossSigningKey? MasterKey { get; set; } +// public CrossSigningKey? SelfSigningKey { get; set; } +// public CrossSigningKey? UserSigningKey { get; set; } +// +// public class CrossSigningKey { +// [JsonPropertyName("keys")] +// public Dictionary<string, string> Keys { get; set; } +// +// [JsonPropertyName("signatures")] +// public Dictionary<string, Dictionary<string, string>> Signatures { get; set; } +// +// [JsonPropertyName("usage")] +// public List<string> Usage { get; set; } +// +// [JsonPropertyName("user_id")] +// public string UserId { get; set; } +// } +// } \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/LegacyController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/LegacyController.cs new file mode 100644 index 0000000..e3f781b --- /dev/null +++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/LegacyController.cs @@ -0,0 +1,56 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using ArcaneLibs.Extensions; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.HomeserverEmulator.Services; +using LibMatrix.Responses; +using LibMatrix.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LibMatrix.HomeserverEmulator.Controllers; + +[ApiController] +[Route("/_matrix/client/{version}/")] +public class LegacyController(ILogger<LegacyController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase { + [HttpGet("rooms/{roomId}/initialSync")] + [SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action", Justification = "Endpoint is expected to wait until data is available or timeout.")] + public async Task<object> Sync([FromRoute] string roomId, [FromQuery] int limit = 20) { + var sw = Stopwatch.StartNew(); + var token = tokenService.GetAccessToken(HttpContext); + if (token == null) + throw new MatrixException() { + ErrorCode = "M_MISSING_TOKEN", + Error = "Missing token" + }; + + var user = await userStore.GetUserByToken(token); + if (user == null) + throw new MatrixException() { + ErrorCode = "M_UNKNOWN_TOKEN", + Error = "No such user" + }; + var room = roomStore.GetRoomById(roomId); + if (room is null) + throw new MatrixException() { + ErrorCode = "M_NOT_FOUND", + Error = "Room not found." + }; + var accountData = room.AccountData.GetOrCreate(user.UserId, _ => []); + var membership = room.State.FirstOrDefault(x => x.Type == "m.room.member" && x.StateKey == user.UserId); + var timelineChunk = room.Timeline.TakeLast(limit).ToList(); + return new { + account_data = accountData, + membership = (membership?.TypedContent as RoomMemberEventContent)?.Membership ?? "leave", + room_id = room.RoomId, + state = room.State.ToList(), + visibility = "public", + messages = new PaginatedChunkedStateEventResponse() { + Chunk = timelineChunk, + End = timelineChunk.Last().EventId, + Start = timelineChunk.Count >= limit ? timelineChunk.First().EventId : null + } + }; + } +} \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/Media/MediaController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Media/MediaController.cs new file mode 100644 index 0000000..dba36d7 --- /dev/null +++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Media/MediaController.cs @@ -0,0 +1,34 @@ +using LibMatrix.HomeserverEmulator.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LibMatrix.HomeserverEmulator.Controllers.Media; + +[ApiController] +[Route("/_matrix/media/{version}/")] +public class MediaController(ILogger<MediaController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase { + [HttpPost("upload")] + public async Task<object> UploadMedia([FromHeader(Name = "Content-Type")] string ContentType, [FromQuery] string filename, [FromBody] Stream file) { + var token = tokenService.GetAccessToken(HttpContext); + if (token == null) + throw new MatrixException() { + ErrorCode = "M_MISSING_TOKEN", + Error = "Missing token" + }; + + var user = await userStore.GetUserByToken(token); + if (user == null) + throw new MatrixException() { + ErrorCode = "M_UNKNOWN_TOKEN", + Error = "No such user" + }; + + + + var mediaId = Guid.NewGuid().ToString(); + var media = new { + content_uri = $"mxc://{tokenService.GenerateServerName(HttpContext)}/{mediaId}" + }; + return media; + + } +} \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomMembersController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomMembersController.cs new file mode 100644 index 0000000..d5f4217 --- /dev/null +++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomMembersController.cs @@ -0,0 +1,58 @@ +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.HomeserverEmulator.Services; +using LibMatrix.Responses; +using LibMatrix.RoomTypes; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Validations.Rules; + +namespace LibMatrix.HomeserverEmulator.Controllers.Rooms; + +[ApiController] +[Route("/_matrix/client/{version}/rooms/{roomId}/")] +public class RoomMembersController(ILogger<RoomMembersController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase { + [HttpGet("members")] + public async Task<List<StateEventResponse>> CreateRoom(string roomId, string? at = null, string? membership = null, string? not_membership = null) { + var token = tokenService.GetAccessToken(HttpContext); + if (token == null) + throw new MatrixException() { + ErrorCode = "M_MISSING_TOKEN", + Error = "Missing token" + }; + + var user = await userStore.GetUserByToken(token); + if (user == null) + throw new MatrixException() { + ErrorCode = "M_UNKNOWN_TOKEN", + Error = "No such user" + }; + + var room = roomStore.GetRoomById(roomId); + if (room == null) + throw new MatrixException() { + ErrorCode = "M_NOT_FOUND", + Error = "Room not found" + }; + + var members = room.State.Where(x => x.Type == "m.room.member").ToList(); + + if(membership != null) + members = members.Where(x => (x.TypedContent as RoomMemberEventContent)?.Membership == membership).ToList(); + + if(not_membership != null) + members = members.Where(x => (x.TypedContent as RoomMemberEventContent)?.Membership != not_membership).ToList(); + + if (at != null) { + var evt = room.Timeline.FirstOrDefault(x => x.EventId == at); + if (evt == null) + throw new MatrixException() { + ErrorCode = "M_NOT_FOUND", + Error = "Event not found" + }; + + members = members.Where(x => x.OriginServerTs <= evt.OriginServerTs).ToList(); + } + + return members; + } +} \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomStateController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomStateController.cs new file mode 100644 index 0000000..593f5b0 --- /dev/null +++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomStateController.cs @@ -0,0 +1,106 @@ +using System.Collections.Frozen; +using LibMatrix.HomeserverEmulator.Extensions; +using LibMatrix.HomeserverEmulator.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LibMatrix.HomeserverEmulator.Controllers.Rooms; + +[ApiController] +[Route("/_matrix/client/{version}/rooms/{roomId}/state")] +public class RoomStateController(ILogger<RoomStateController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase { + [HttpGet("")] + public async Task<FrozenSet<StateEventResponse>> GetState(string roomId) { + var token = tokenService.GetAccessToken(HttpContext); + if (token == null) + throw new MatrixException() { + ErrorCode = "M_MISSING_TOKEN", + Error = "Missing token" + }; + + var user = await userStore.GetUserByToken(token); + if (user == null) + throw new MatrixException() { + ErrorCode = "M_UNKNOWN_TOKEN", + Error = "No such user" + }; + + var room = roomStore.GetRoomById(roomId); + if (room == null) + throw new MatrixException() { + ErrorCode = "M_NOT_FOUND", + Error = "Room not found" + }; + + return room.State; + } + + [HttpGet("{eventType}")] + public async Task<StateEventResponse> GetState(string roomId, string eventType) { + return await GetState(roomId, eventType, ""); + } + + [HttpGet("{eventType}/{stateKey}")] + public async Task<StateEventResponse> GetState(string roomId, string eventType, string stateKey) { + var token = tokenService.GetAccessToken(HttpContext); + if (token == null) + throw new MatrixException() { + ErrorCode = "M_MISSING_TOKEN", + Error = "Missing token" + }; + + var user = await userStore.GetUserByToken(token); + if (user == null) + throw new MatrixException() { + ErrorCode = "M_UNKNOWN_TOKEN", + Error = "No such user" + }; + + var room = roomStore.GetRoomById(roomId); + if (room == null) + throw new MatrixException() { + ErrorCode = "M_NOT_FOUND", + Error = "Room not found" + }; + + var stateEvent = room.State.FirstOrDefault(x => x.Type == eventType && x.StateKey == stateKey); + if (stateEvent == null) + throw new MatrixException() { + ErrorCode = "M_NOT_FOUND", + Error = "Event not found" + }; + return stateEvent; + } + + [HttpPut("{eventType}")] + public async Task<EventIdResponse> SetState(string roomId, string eventType, [FromBody] StateEvent request) { + return await SetState(roomId, eventType, "", request); + } + + [HttpPut("{eventType}/{stateKey}")] + public async Task<EventIdResponse> SetState(string roomId, string eventType, string stateKey, [FromBody] StateEvent request) { + var token = tokenService.GetAccessToken(HttpContext); + if (token == null) + throw new MatrixException() { + ErrorCode = "M_MISSING_TOKEN", + Error = "Missing token" + }; + + var user = await userStore.GetUserByToken(token); + if (user == null) + throw new MatrixException() { + ErrorCode = "M_UNKNOWN_TOKEN", + Error = "No such user" + }; + + var room = roomStore.GetRoomById(roomId); + if (room == null) + throw new MatrixException() { + ErrorCode = "M_NOT_FOUND", + Error = "Room not found" + }; + var evt = room.SetStateInternal(request.ToStateEvent(user, room)); + evt.Type = eventType; + evt.StateKey = stateKey; + return new EventIdResponse(evt); + } +} \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomsController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomsController.cs new file mode 100644 index 0000000..e9f52dc --- /dev/null +++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomsController.cs @@ -0,0 +1,130 @@ +using System.Text.Json.Serialization; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.EventTypes.Spec.State.RoomInfo; +using LibMatrix.HomeserverEmulator.Services; +using LibMatrix.Responses; +using LibMatrix.RoomTypes; +using Microsoft.AspNetCore.Mvc; + +namespace LibMatrix.HomeserverEmulator.Controllers.Rooms; + +[ApiController] +[Route("/_matrix/client/{version}/")] +public class RoomsController(ILogger<RoomsController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase { + //createRoom + [HttpPost("createRoom")] + public async Task<RoomIdResponse> CreateRoom([FromBody] CreateRoomRequest request) { + var token = tokenService.GetAccessToken(HttpContext); + if (token == null) + throw new MatrixException() { + ErrorCode = "M_MISSING_TOKEN", + Error = "Missing token" + }; + + var user = await userStore.GetUserByToken(token); + if (user == null) + throw new MatrixException() { + ErrorCode = "M_UNKNOWN_TOKEN", + Error = "No such user" + }; + + var room = new RoomStore.Room($"!{Guid.NewGuid()}:{tokenService.GenerateServerName(HttpContext)}"); + var createEvent = room.SetStateInternal(new() { + Type = RoomCreateEventContent.EventId, + RawContent = new() { + ["creator"] = user.UserId + } + }); + foreach (var (key, value) in request.CreationContent) { + createEvent.RawContent[key] = value.DeepClone(); + } + + if (!string.IsNullOrWhiteSpace(request.Name)) + room.SetStateInternal(new StateEvent() { + Type = RoomNameEventContent.EventId, + TypedContent = new RoomNameEventContent() { + Name = request.Name + } + }); + + if (!string.IsNullOrWhiteSpace(request.RoomAliasName)) + room.SetStateInternal(new StateEvent() { + Type = RoomCanonicalAliasEventContent.EventId, + TypedContent = new RoomCanonicalAliasEventContent() { + Alias = $"#{request.RoomAliasName}:localhost" + } + }); + + if (request.InitialState is { Count: > 0 }) { + foreach (var stateEvent in request.InitialState) { + room.SetStateInternal(stateEvent); + } + } + + room.AddUser(user.UserId); + + // user.Rooms.Add(room.RoomId, room); + return new() { + RoomId = room.RoomId + }; + } + + [HttpPost("rooms/{roomId}/upgrade")] + public async Task<object> UpgradeRoom(string roomId, [FromBody] UpgradeRoomRequest request) { + var token = tokenService.GetAccessToken(HttpContext); + if (token == null) + throw new MatrixException() { + ErrorCode = "M_MISSING_TOKEN", + Error = "Missing token" + }; + + var user = await userStore.GetUserByToken(token); + if (user == null) + throw new MatrixException() { + ErrorCode = "M_UNKNOWN_TOKEN", + Error = "No such user" + }; + + var oldRoom = roomStore.GetRoomById(roomId); + if (oldRoom == null) + throw new MatrixException() { + ErrorCode = "M_NOT_FOUND", + Error = "Room not found" + }; + + var room = new RoomStore.Room($"!{Guid.NewGuid()}:{tokenService.GenerateServerName(HttpContext)}"); + + var eventTypesToTransfer = new[] { + RoomServerACLEventContent.EventId, + RoomEncryptionEventContent.EventId, + RoomNameEventContent.EventId, + RoomAvatarEventContent.EventId, + RoomTopicEventContent.EventId, + RoomGuestAccessEventContent.EventId, + RoomHistoryVisibilityEventContent.EventId, + RoomJoinRulesEventContent.EventId, + RoomPowerLevelEventContent.EventId, + }; + + var createEvent = room.SetStateInternal(new() { + Type = RoomCreateEventContent.EventId, + RawContent = new() { + ["creator"] = user.UserId + } + }); + + oldRoom.State.Where(x => eventTypesToTransfer.Contains(x.Type)).ToList().ForEach(x => room.SetStateInternal(x)); + + room.AddUser(user.UserId); + + // user.Rooms.Add(room.RoomId, room); + return new { + replacement_room = room.RoomId + }; + } +} + +public class UpgradeRoomRequest { + [JsonPropertyName("new_version")] + public required string NewVersion { get; set; } +} \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/SyncController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/SyncController.cs new file mode 100644 index 0000000..1653110 --- /dev/null +++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/SyncController.cs @@ -0,0 +1,119 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using ArcaneLibs.Extensions; +using LibMatrix.HomeserverEmulator.Services; +using LibMatrix.Responses; +using LibMatrix.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LibMatrix.HomeserverEmulator.Controllers; + +[ApiController] +[Route("/_matrix/client/{version}/")] +public class SyncController(ILogger<SyncController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore, HSEConfiguration cfg) : ControllerBase { + [HttpGet("sync")] + [SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action", Justification = "Endpoint is expected to wait until data is available or timeout.")] + public async Task<SyncResponse> Sync([FromQuery] string? since = null, [FromQuery] int? timeout = 5000) { + var sw = Stopwatch.StartNew(); + var token = tokenService.GetAccessToken(HttpContext); + if (token == null) + throw new MatrixException() { + ErrorCode = "M_MISSING_TOKEN", + Error = "Missing token" + }; + + var user = await userStore.GetUserByToken(token); + if (user == null) + throw new MatrixException() { + ErrorCode = "M_UNKNOWN_TOKEN", + Error = "No such user" + }; + var session = user.AccessTokens[token]; + + if (string.IsNullOrWhiteSpace(since)) + return InitialSync(user, session); + + if (!session.SyncStates.TryGetValue(since, out var syncState)) + if (!cfg.UnknownSyncTokenIsInitialSync) + throw new MatrixException() { + ErrorCode = "M_UNKNOWN", + Error = "Unknown sync token." + }; + else + return InitialSync(user, session); + + var response = new SyncResponse() { + NextBatch = Guid.NewGuid().ToString(), + DeviceOneTimeKeysCount = new() + }; + + session.SyncStates.Add(response.NextBatch, new() { + RoomPositions = syncState.RoomPositions.ToDictionary(x => x.Key, x => new UserStore.User.SessionInfo.UserSyncState.SyncRoomPosition() { + StatePosition = roomStore._rooms.First(y => y.RoomId == x.Key).State.Count, + TimelinePosition = roomStore._rooms.First(y => y.RoomId == x.Key).Timeline.Count, + AccountDataPosition = roomStore._rooms.First(y => y.RoomId == x.Key).AccountData[user.UserId].Count + }) + }); + + if (!string.IsNullOrWhiteSpace(since)) { + while (sw.ElapsedMilliseconds < timeout && response.Rooms?.Join is not { Count: > 0 }) { + await Task.Delay(100); + var rooms = roomStore._rooms.Where(x => x.State.Any(y => y.Type == "m.room.member" && y.StateKey == user.UserId)).ToList(); + foreach (var room in rooms) { + var roomPositions = syncState.RoomPositions[room.RoomId]; + + response.Rooms ??= new(); + response.Rooms.Join ??= new(); + response.Rooms.Join[room.RoomId] = new() { + State = new(room.State.Skip(roomPositions.StatePosition).ToList()), + Timeline = new(events: room.Timeline.Skip(roomPositions.TimelinePosition).ToList(), limited: false), + AccountData = new(room.AccountData.GetOrCreate(user.UserId, _ => []).Skip(roomPositions.AccountDataPosition).ToList()) + }; + session.SyncStates[response.NextBatch].RoomPositions[room.RoomId] = new() { + StatePosition = room.State.Count, + TimelinePosition = room.Timeline.Count, + AccountDataPosition = room.AccountData[user.UserId].Count + }; + + if (response.Rooms.Join[room.RoomId].State.Events.Count == 0 && + response.Rooms.Join[room.RoomId].Timeline.Events.Count == 0 && + response.Rooms.Join[room.RoomId].AccountData.Events.Count == 0 + ) + response.Rooms.Join.Remove(room.RoomId); + } + } + } + + return response; + } + + private SyncResponse InitialSync(UserStore.User user, UserStore.User.SessionInfo session) { + var response = new SyncResponse() { + NextBatch = Guid.NewGuid().ToString(), + DeviceOneTimeKeysCount = new(), + AccountData = new(events: user.AccountData.ToList()) + }; + + session.SyncStates.Add(response.NextBatch, new()); + + var rooms = roomStore._rooms.Where(x => x.State.Any(y => y.Type == "m.room.member" && y.StateKey == user.UserId)).ToList(); + foreach (var room in rooms) { + response.Rooms ??= new(); + response.Rooms.Join ??= new(); + response.Rooms.Join[room.RoomId] = new() { + State = new(room.State.ToList()), + Timeline = new(events: room.Timeline.ToList(), limited: false), + AccountData = new(room.AccountData.GetOrCreate(user.UserId, _ => []).ToList()) + }; + session.SyncStates[response.NextBatch].RoomPositions[room.RoomId] = new() { + StatePosition = room.State.Count, + TimelinePosition = room.Timeline.Count, + AccountDataPosition = room.AccountData[user.UserId].Count + }; + } + + return response; + } +} \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/AccountDataController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/AccountDataController.cs new file mode 100644 index 0000000..8cd5c75 --- /dev/null +++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/AccountDataController.cs @@ -0,0 +1,81 @@ +using System.Text.Json.Nodes; +using ArcaneLibs.Extensions; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.Filters; +using LibMatrix.HomeserverEmulator.Services; +using LibMatrix.Responses; +using Microsoft.AspNetCore.Mvc; + +namespace LibMatrix.HomeserverEmulator.Controllers; + +[ApiController] +[Route("/_matrix/client/{version}/")] +public class AccountDataController(ILogger<AccountDataController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase { + [HttpGet("user/{mxid}/account_data/{type}")] + public async Task<object> GetAccountData(string type) { + var token = tokenService.GetAccessToken(HttpContext); + if (token is null) + throw new MatrixException() { + ErrorCode = "M_UNAUTHORIZED", + Error = "No token passed." + }; + + var user = await userStore.GetUserByToken(token, false); + if (user is null) + throw new MatrixException() { + ErrorCode = "M_UNAUTHORIZED", + Error = "Invalid token." + }; + var value = user.AccountData.FirstOrDefault(x=>x.Type == type); + if (value is null) + throw new MatrixException() { + ErrorCode = "M_NOT_FOUND", + Error = "Key not found." + }; + return value; + } + + [HttpPut("user/{mxid}/account_data/{type}")] + public async Task<object> SetAccountData(string type, [FromBody] JsonObject data) { + var token = tokenService.GetAccessToken(HttpContext); + if (token is null) + throw new MatrixException() { + ErrorCode = "M_UNAUTHORIZED", + Error = "No token passed." + }; + + var user = await userStore.GetUserByToken(token, false); + if (user is null) + throw new MatrixException() { + ErrorCode = "M_UNAUTHORIZED", + Error = "Invalid token." + }; + + user.AccountData.Where(x=>x.Type == type).ToList().ForEach(response => user.AccountData.Remove(response)); + + user.AccountData.Add(new() { + Type = type, + RawContent = data + }); + return data; + } + + // specialised account data... + [HttpGet("pushrules")] + public async Task<object> GetPushRules() { + var token = tokenService.GetAccessToken(HttpContext); + if (token is null) + throw new MatrixException() { + ErrorCode = "M_UNAUTHORIZED", + Error = "No token passed." + }; + + var user = await userStore.GetUserByToken(token, false); + if (user is null) + throw new MatrixException() { + ErrorCode = "M_UNAUTHORIZED", + Error = "Invalid token." + }; + return new { }; + } +} \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/FilterController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/FilterController.cs new file mode 100644 index 0000000..ecbccd4 --- /dev/null +++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/FilterController.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Nodes; +using ArcaneLibs.Extensions; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.Filters; +using LibMatrix.HomeserverEmulator.Services; +using LibMatrix.Responses; +using Microsoft.AspNetCore.Mvc; + +namespace LibMatrix.HomeserverEmulator.Controllers; + +[ApiController] +[Route("/_matrix/client/{version}/")] +public class FilterController(ILogger<FilterController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase { + [HttpPost("user/{mxid}/filter")] + public async Task<object> CreateFilter(string mxid, [FromBody] SyncFilter filter) { + var token = tokenService.GetAccessToken(HttpContext); + if (token is null) + throw new MatrixException() { + ErrorCode = "M_UNAUTHORIZED", + Error = "No token passed." + }; + + var user = await userStore.GetUserByToken(token, false); + if (user is null) + throw new MatrixException() { + ErrorCode = "M_UNAUTHORIZED", + Error = "Invalid token." + }; + var filterId = Guid.NewGuid().ToString(); + user.Filters[filterId] = filter; + return new { + filter_id = filterId + }; + } + + [HttpGet("user/{mxid}/filter/{filterId}")] + public async Task<SyncFilter> GetFilter(string mxid, string filterId) { + var token = tokenService.GetAccessToken(HttpContext); + if (token is null) + throw new MatrixException() { + ErrorCode = "M_UNAUTHORIZED", + Error = "No token passed." + }; + + var user = await userStore.GetUserByToken(token, false); + if (user is null) + throw new MatrixException() { + ErrorCode = "M_UNAUTHORIZED", + Error = "Invalid token." + }; + if (!user.Filters.ContainsKey(filterId)) + throw new MatrixException() { + ErrorCode = "M_NOT_FOUND", + Error = "Filter not found." + }; + return user.Filters[filterId]; + } +} \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/ProfileController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/ProfileController.cs new file mode 100644 index 0000000..c717ba5 --- /dev/null +++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/ProfileController.cs @@ -0,0 +1,52 @@ +using System.Text.Json.Nodes; +using ArcaneLibs.Extensions; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.Filters; +using LibMatrix.HomeserverEmulator.Services; +using LibMatrix.Responses; +using Microsoft.AspNetCore.Mvc; + +namespace LibMatrix.HomeserverEmulator.Controllers; + +[ApiController] +[Route("/_matrix/client/{version}/")] +public class ProfileController(ILogger<ProfileController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase { + [HttpGet("profile/{userId}")] + public async Task<IDictionary<string, object>> GetProfile(string userId) { + var user = await userStore.GetUserById(userId, false); + if (user is null) + throw new MatrixException() { + ErrorCode = "M_NOT_FOUND", + Error = "User not found." + }; + return user.Profile; + } + + [HttpGet("profile/{userId}/{key}")] + public async Task<object> GetProfile(string userId, string key) { + var user = await userStore.GetUserById(userId, false); + if (user is null) + throw new MatrixException() { + ErrorCode = "M_NOT_FOUND", + Error = "User not found." + }; + if (!user.Profile.TryGetValue(key, out var value)) + throw new MatrixException() { + ErrorCode = "M_NOT_FOUND", + Error = "Key not found." + }; + return value; + } + + [HttpPut("profile/{userId}/{key}")] + public async Task<object> SetProfile(string userId, string key, [FromBody] JsonNode value) { + var user = await userStore.GetUserById(userId, false); + if (user is null) + throw new MatrixException() { + ErrorCode = "M_NOT_FOUND", + Error = "User not found." + }; + user.Profile[key] = value[key]; + return value; + } +} \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/UserController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/UserController.cs index d763b26..eb2b879 100644 --- a/Tests/LibMatrix.HomeserverEmulator/Controllers/UserController.cs +++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/UserController.cs @@ -1,5 +1,7 @@ using System.Text.Json.Nodes; using ArcaneLibs.Extensions; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.Filters; using LibMatrix.HomeserverEmulator.Services; using LibMatrix.Responses; using Microsoft.AspNetCore.Mvc; @@ -8,17 +10,17 @@ namespace LibMatrix.HomeserverEmulator.Controllers; [ApiController] [Route("/_matrix/client/{version}/")] -public class UserController(ILogger<UserController> logger, TokenService tokenService, UserStore userStore) : ControllerBase { +public class UserController(ILogger<UserController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase { [HttpGet("account/whoami")] public async Task<WhoAmIResponse> Login() { - var token = tokenService.GetAccessToken(); + var token = tokenService.GetAccessToken(HttpContext); if (token is null) throw new MatrixException() { ErrorCode = "M_UNAUTHORIZED", Error = "No token passed." }; - var user = await userStore.GetUserByToken(token, Random.Shared.Next(101) <= 10, tokenService.GenerateServerName()); + var user = await userStore.GetUserByToken(token, Random.Shared.Next(101) <= 10, tokenService.GenerateServerName(HttpContext)); if (user is null) throw new MatrixException() { ErrorCode = "M_UNKNOWN_TOKEN", @@ -29,37 +31,10 @@ public class UserController(ILogger<UserController> logger, TokenService tokenSe }; return whoAmIResponse; } - - [HttpGet("profile/{userId}")] - public async Task<Dictionary<string, object>> GetProfile(string userId) { - var user = await userStore.GetUserById(userId, false); - if (user is null) - throw new MatrixException() { - ErrorCode = "M_NOT_FOUND", - Error = "User not found." - }; - return user.Profile; - } - - [HttpGet("profile/{userId}/{key}")] - public async Task<object> GetProfile(string userId, string key) { - var user = await userStore.GetUserById(userId, false); - if (user is null) - throw new MatrixException() { - ErrorCode = "M_NOT_FOUND", - Error = "User not found." - }; - if (!user.Profile.TryGetValue(key, out var value)) - throw new MatrixException() { - ErrorCode = "M_NOT_FOUND", - Error = "Key not found." - }; - return value; - } - + [HttpGet("joined_rooms")] public async Task<object> GetJoinedRooms() { - var token = tokenService.GetAccessToken(); + var token = tokenService.GetAccessToken(HttpContext); if (token is null) throw new MatrixException() { ErrorCode = "M_UNAUTHORIZED", @@ -75,7 +50,9 @@ public class UserController(ILogger<UserController> logger, TokenService tokenSe // return user.JoinedRooms; return new { - joined_rooms = user.JoinedRooms + joined_rooms = roomStore._rooms.Where(r => + r.State.Any(s => s.StateKey == user.UserId && s.Type == RoomMemberEventContent.EventId && (s.TypedContent as RoomMemberEventContent).Membership == "join") + ).Select(r => r.RoomId).ToList() }; } } \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/VersionsController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/VersionsController.cs index 1349fac..704e26b 100644 --- a/Tests/LibMatrix.HomeserverEmulator/Controllers/VersionsController.cs +++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/VersionsController.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using LibMatrix.Homeservers; using LibMatrix.Responses; using LibMatrix.Services; @@ -45,4 +46,47 @@ public class VersionsController(ILogger<WellKnownController> logger) : Controlle }; return clientVersions; } + + [HttpGet("client/{version}/capabilities")] + public async Task<CapabilitiesResponse> GetCapabilities() { + return new() { + Capabilities = new() { + ChangePassword = new() { + Enabled = false + }, + RoomVersions = new() { + Default = "11", + Available = new() { + ["11"] = "unstable" + } + } + } + }; + } +} + +public class CapabilitiesResponse { + [JsonPropertyName("capabilities")] + public CapabilitiesContent Capabilities { get; set; } + + public class CapabilitiesContent { + [JsonPropertyName("m.room_versions")] + public RoomVersionsContent RoomVersions { get; set; } + + [JsonPropertyName("m.change_password")] + public ChangePasswordContent ChangePassword { get; set; } + + public class ChangePasswordContent { + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + } + + public class RoomVersionsContent { + [JsonPropertyName("default")] + public string Default { get; set; } + + [JsonPropertyName("available")] + public Dictionary<string, string> Available { get; set; } + } + } } \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Extensions/EventExtensions.cs b/Tests/LibMatrix.HomeserverEmulator/Extensions/EventExtensions.cs new file mode 100644 index 0000000..1d03d7a --- /dev/null +++ b/Tests/LibMatrix.HomeserverEmulator/Extensions/EventExtensions.cs @@ -0,0 +1,18 @@ +using LibMatrix.HomeserverEmulator.Services; + +namespace LibMatrix.HomeserverEmulator.Extensions; + +public static class EventExtensions { + public static StateEventResponse ToStateEvent(this StateEvent stateEvent, UserStore.User user, RoomStore.Room room) { + return new StateEventResponse { + RawContent = stateEvent.RawContent, + EventId = "$" + string.Join("", Random.Shared.GetItems("abcdefghijklmnopqrstuvwxyzABCDEFGHIJLKMNOPQRSTUVWXYZ0123456789".ToCharArray(), 100)), + RoomId = room.RoomId, + Sender = user.UserId, + StateKey = stateEvent.StateKey, + Type = stateEvent.Type, + OriginServerTs = DateTimeOffset.Now.ToUnixTimeMilliseconds() + }; + } + +} \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.csproj b/Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.csproj index e6b4572..6588675 100644 --- a/Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.csproj +++ b/Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.csproj @@ -18,4 +18,8 @@ <ItemGroup> <ProjectReference Include="..\..\LibMatrix\LibMatrix.csproj" /> </ItemGroup> + + <ItemGroup> + <Folder Include="data\rooms\" /> + </ItemGroup> </Project> diff --git a/Tests/LibMatrix.HomeserverEmulator/Program.cs b/Tests/LibMatrix.HomeserverEmulator/Program.cs index 516d380..ddf39c7 100644 --- a/Tests/LibMatrix.HomeserverEmulator/Program.cs +++ b/Tests/LibMatrix.HomeserverEmulator/Program.cs @@ -1,16 +1,20 @@ using System.Net.Mime; +using System.Text.Json.Serialization; using LibMatrix; using LibMatrix.HomeserverEmulator.Services; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http.Timeouts; +using Microsoft.AspNetCore.Mvc; using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); // Add services to the container. -builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddControllers().AddJsonOptions(options => { + options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; +}); + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo() { @@ -20,11 +24,12 @@ builder.Services.AddSwaggerGen(c => { }); c.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, "LibMatrix.HomeserverEmulator.xml")); }); + builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>(); +builder.Services.AddSingleton<HSEConfiguration>(); builder.Services.AddSingleton<UserStore>(); builder.Services.AddSingleton<RoomStore>(); - builder.Services.AddScoped<TokenService>(); builder.Services.AddRequestTimeouts(x => { @@ -45,7 +50,7 @@ builder.Services.AddRequestTimeouts(x => { builder.Services.AddCors(options => { options.AddPolicy( "Open", - policy => policy.AllowAnyOrigin().AllowAnyHeader()); + policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); }); var app = builder.Build(); @@ -62,6 +67,8 @@ app.UseExceptionHandler(exceptionHandlerApp => { var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>(); + if(exceptionHandlerPathFeature?.Error is not null) + Console.WriteLine(exceptionHandlerPathFeature.Error.ToString()!); if (exceptionHandlerPathFeature?.Error is MatrixException mxe) { context.Response.StatusCode = mxe.ErrorCode switch { @@ -87,4 +94,14 @@ app.UseAuthorization(); app.MapControllers(); +app.Map("/_matrix/{*_}", (HttpContext ctx) => { + Console.WriteLine($"Client hit non-existing route: {ctx.Request.Method} {ctx.Request.Path}"); + ctx.Response.StatusCode = StatusCodes.Status404NotFound; + ctx.Response.ContentType = MediaTypeNames.Application.Json; + return ctx.Response.WriteAsJsonAsync(new MatrixException() { + ErrorCode = MatrixException.ErrorCodes.M_UNRECOGNISED, + Error = "Endpoint not implemented" + }); +}); + app.Run(); diff --git a/Tests/LibMatrix.HomeserverEmulator/Properties/launchSettings.json b/Tests/LibMatrix.HomeserverEmulator/Properties/launchSettings.json index 8ab6b3d..4ddf341 100644 --- a/Tests/LibMatrix.HomeserverEmulator/Properties/launchSettings.json +++ b/Tests/LibMatrix.HomeserverEmulator/Properties/launchSettings.json @@ -1,31 +1,32 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:6824", - "sslPort": 0 - } - }, - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://localhost:5298", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:9169", + "sslPort": 44321 + } + }, + "profiles": { + "Development": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5298", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Local": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5298", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Local" + } + } + } +} diff --git a/Tests/LibMatrix.HomeserverEmulator/Services/HSEConfiguration.cs b/Tests/LibMatrix.HomeserverEmulator/Services/HSEConfiguration.cs new file mode 100644 index 0000000..73b0d23 --- /dev/null +++ b/Tests/LibMatrix.HomeserverEmulator/Services/HSEConfiguration.cs @@ -0,0 +1,57 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using ArcaneLibs.Extensions; + +namespace LibMatrix.HomeserverEmulator.Services; + +public class HSEConfiguration { + private static ILogger<HSEConfiguration> _logger; + public static HSEConfiguration Current { get; set; } + + [RequiresUnreferencedCode("Uses reflection binding")] + public HSEConfiguration(ILogger<HSEConfiguration> logger, IConfiguration config, HostBuilderContext host) { + Current = this; + _logger = logger; + logger.LogInformation("Loading configuration for environment: {}...", host.HostingEnvironment.EnvironmentName); + config.GetSection("HomeserverEmulator").Bind(this); + if (StoreData) { + DataStoragePath = ExpandPath(DataStoragePath ?? throw new NullReferenceException("DataStoragePath is not set")); + CacheStoragePath = ExpandPath(CacheStoragePath ?? throw new NullReferenceException("CacheStoragePath is not set")); + } + + _logger.LogInformation("Configuration loaded: {}", this.ToJson()); + } + + public string CacheStoragePath { get; set; } + + public string DataStoragePath { get; set; } + + public bool StoreData { get; set; } = true; + + public bool UnknownSyncTokenIsInitialSync { get; set; } = true; + + private static string ExpandPath(string path, bool retry = true) { + _logger.LogInformation("Expanding path `{}`", path); + + if (path.StartsWith('~')) { + path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[1..]); + } + + Environment.GetEnvironmentVariables().Cast<DictionaryEntry>().OrderByDescending(x => x.Key.ToString()!.Length).ToList().ForEach(x => { + path = path.Replace($"${x.Key}", x.Value.ToString()); + }); + + _logger.LogInformation("Expanded path to `{}`", path); + var tries = 0; + while (retry && path.ContainsAnyOf("~$".Split())) { + if (tries++ > 100) + throw new Exception($"Path `{path}` contains unrecognised environment variables"); + path = ExpandPath(path, false); + } + + if(path.StartsWith("./")) + path = Path.Join(Directory.GetCurrentDirectory(), path[2..].Replace("/", Path.DirectorySeparatorChar.ToString())); + + return path; + } +} \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Services/MediaStore.cs b/Tests/LibMatrix.HomeserverEmulator/Services/MediaStore.cs new file mode 100644 index 0000000..e34a731 --- /dev/null +++ b/Tests/LibMatrix.HomeserverEmulator/Services/MediaStore.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; +using System.Collections.Frozen; +using System.Collections.ObjectModel; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using ArcaneLibs; +using ArcaneLibs.Collections; +using ArcaneLibs.Extensions; +using LibMatrix.EventTypes; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.Responses; + +namespace LibMatrix.HomeserverEmulator.Services; + +public class MediaStore { + private readonly HSEConfiguration _config; + private List<MediaInfo> index = new(); + + public MediaStore(HSEConfiguration config) { + _config = config; + if (config.StoreData) { + var path = Path.Combine(config.DataStoragePath, "media"); + if (!Directory.Exists(path)) Directory.CreateDirectory(path); + if(File.Exists(Path.Combine(path, "index.json"))) + index = JsonSerializer.Deserialize<List<MediaInfo>>(File.ReadAllText(Path.Combine(path, "index.json"))); + } + else + Console.WriteLine("Data storage is disabled, not loading rooms from disk"); + } + + public async Task<object> UploadMedia(string userId, string mimeType, Stream stream, string? filename = null) { + var mediaId = $"mxc://{Guid.NewGuid().ToString()}"; + var path = Path.Combine(_config.DataStoragePath, "media", mediaId); + if (!Directory.Exists(path)) Directory.CreateDirectory(path); + var file = Path.Combine(path, filename ?? "file"); + await using var fs = File.Create(file); + await stream.CopyToAsync(fs); + index.Add(new() { + + }); + return media; + } + + public class MediaInfo { } +} \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Services/RoomStore.cs b/Tests/LibMatrix.HomeserverEmulator/Services/RoomStore.cs index b4624ab..37d9c7d 100644 --- a/Tests/LibMatrix.HomeserverEmulator/Services/RoomStore.cs +++ b/Tests/LibMatrix.HomeserverEmulator/Services/RoomStore.cs @@ -1,5 +1,13 @@ using System.Collections.Concurrent; +using System.Collections.Frozen; +using System.Collections.ObjectModel; using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using ArcaneLibs; +using ArcaneLibs.Collections; +using ArcaneLibs.Extensions; using LibMatrix.EventTypes; using LibMatrix.EventTypes.Spec.State; using LibMatrix.Responses; @@ -9,6 +17,21 @@ namespace LibMatrix.HomeserverEmulator.Services; public class RoomStore { public ConcurrentBag<Room> _rooms = new(); private Dictionary<string, Room> _roomsById = new(); + + public RoomStore(HSEConfiguration config) { + if(config.StoreData) { + var path = Path.Combine(config.DataStoragePath, "rooms"); + if (!Directory.Exists(path)) Directory.CreateDirectory(path); + foreach (var file in Directory.GetFiles(path)) { + var room = JsonSerializer.Deserialize<Room>(File.ReadAllText(file)); + if (room is not null) _rooms.Add(room); + } + } + else + Console.WriteLine("Data storage is disabled, not loading rooms from disk"); + + RebuildIndexes(); + } private void RebuildIndexes() { _roomsById = _rooms.ToDictionary(u => u.RoomId); @@ -26,9 +49,7 @@ public class RoomStore { } public Room CreateRoom(CreateRoomRequest request) { - var room = new Room { - RoomId = $"!{Guid.NewGuid().ToString()}" - }; + var room = new Room(roomId: $"!{Guid.NewGuid().ToString()}"); if (!string.IsNullOrWhiteSpace(request.Name)) room.SetStateInternal(new StateEvent() { Type = RoomNameEventContent.EventId, @@ -54,30 +75,90 @@ public class RoomStore { return room; } - public class Room { + public class Room : NotifyPropertyChanged { + private CancellationTokenSource _debounceCts = new(); + private ObservableCollection<StateEventResponse> _timeline; + private ObservableDictionary<string,List<StateEventResponse>> _accountData; + + public Room(string roomId) { + if (string.IsNullOrWhiteSpace(roomId)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(roomId)); + if (roomId[0] != '!') throw new ArgumentException("Room ID must start with !", nameof(roomId)); + RoomId = roomId; + State = FrozenSet<StateEventResponse>.Empty; + Timeline = new(); + AccountData = new(); + } + public string RoomId { get; set; } - public List<StateEventResponse> State { get; set; } = new(); - public Dictionary<string, EventContent> Timeline { get; set; } = new(); + + public FrozenSet<StateEventResponse> State { get; private set; } + + public ObservableCollection<StateEventResponse> Timeline { + get => _timeline; + set { + if (Equals(value, _timeline)) return; + _timeline = new(value); + _timeline.CollectionChanged += (sender, args) => SaveDebounced(); + OnPropertyChanged(); + } + } + + public ObservableDictionary<string, List<StateEventResponse>> AccountData { + get => _accountData; + set { + if (Equals(value, _accountData)) return; + _accountData = new(value); + _accountData.CollectionChanged += (sender, args) => SaveDebounced(); + OnPropertyChanged(); + } + } internal StateEventResponse SetStateInternal(StateEvent request) { var state = new StateEventResponse() { Type = request.Type, StateKey = request.StateKey, - RawContent = request.RawContent, - EventId = Guid.NewGuid().ToString() + EventId = Guid.NewGuid().ToString(), + RoomId = RoomId, + OriginServerTs = DateTimeOffset.Now.ToUnixTimeMilliseconds(), + Sender = "", + RawContent = request.RawContent ?? (request.TypedContent is not null ? new JsonObject() : JsonSerializer.Deserialize<JsonObject>(JsonSerializer.Serialize(request.TypedContent))) }; - State.Add(state); + Timeline.Add(state); + if(state.StateKey is not null) + // we want state to be deduplicated by type and key, and we want the latest state to be the one that is returned + State = Timeline.Where(s => s.Type == state.Type && s.StateKey == state.StateKey) + .OrderByDescending(s => s.OriginServerTs) + .DistinctBy(x=>(x.Type, x.StateKey)) + .ToFrozenSet(); return state; } public StateEventResponse AddUser(string userId) { - return SetStateInternal(new() { + var state = SetStateInternal(new() { Type = RoomMemberEventContent.EventId, StateKey = userId, TypedContent = new RoomMemberEventContent() { Membership = "join" - } + }, }); + + state.Sender = userId; + return state; + } + + public async Task SaveDebounced() { + if (!HSEConfiguration.Current.StoreData) return; + await _debounceCts.CancelAsync(); + _debounceCts = new CancellationTokenSource(); + try { + await Task.Delay(250, _debounceCts.Token); + // Ensure all state events are in the timeline + State.Where(s=>!Timeline.Contains(s)).ToList().ForEach(s => Timeline.Add(s)); + var path = Path.Combine(HSEConfiguration.Current.DataStoragePath, "rooms", $"{RoomId}.json"); + Console.WriteLine($"Saving room {RoomId} to {path}!"); + await File.WriteAllTextAsync(path, this.ToJson(ignoreNull: true)); + } + catch (TaskCanceledException) { } } } } \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Services/TokenService.cs b/Tests/LibMatrix.HomeserverEmulator/Services/TokenService.cs index 8115bee..1f59342 100644 --- a/Tests/LibMatrix.HomeserverEmulator/Services/TokenService.cs +++ b/Tests/LibMatrix.HomeserverEmulator/Services/TokenService.cs @@ -1,9 +1,7 @@ namespace LibMatrix.HomeserverEmulator.Services; -public class TokenService(IHttpContextAccessor accessor) { - public string? GetAccessToken() { - var ctx = accessor.HttpContext; - if (ctx is null) return null; +public class TokenService{ + public string? GetAccessToken(HttpContext ctx) { //qry if (ctx.Request.Query.TryGetValue("access_token", out var token)) { return token; @@ -11,16 +9,14 @@ public class TokenService(IHttpContextAccessor accessor) { //header if (ctx.Request.Headers.TryGetValue("Authorization", out var auth)) { var parts = auth.ToString().Split(' '); - if (parts.Length == 2 && parts[0] == "Bearer") { + if (parts is ["Bearer", _]) { return parts[1]; } } return null; } - public string? GenerateServerName() { - var ctx = accessor.HttpContext; - if (ctx is null) return null; + public string? GenerateServerName(HttpContext ctx) { return ctx.Request.Host.ToString(); } } \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/Services/UserStore.cs b/Tests/LibMatrix.HomeserverEmulator/Services/UserStore.cs index ca1c577..faf0046 100644 --- a/Tests/LibMatrix.HomeserverEmulator/Services/UserStore.cs +++ b/Tests/LibMatrix.HomeserverEmulator/Services/UserStore.cs @@ -1,72 +1,207 @@ +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Nodes; +using ArcaneLibs; +using ArcaneLibs.Collections; +using ArcaneLibs.Extensions; using LibMatrix.EventTypes.Spec.State; +using LibMatrix.Filters; +using LibMatrix.Responses; namespace LibMatrix.HomeserverEmulator.Services; -public class UserStore(RoomStore roomStore) { - public List<User> _users = new(); - private Dictionary<string, User> _usersById = new(); - private Dictionary<string, User> _usersByToken = new(); +public class UserStore { + public ConcurrentBag<User> _users = new(); + private readonly RoomStore _roomStore; - private void RebuildIndexes() { - _usersById = _users.ToDictionary(u => u.UserId); - _usersByToken = _users.ToDictionary(u => u.AccessToken); + public UserStore(HSEConfiguration config, RoomStore roomStore) { + _roomStore = roomStore; + if (config.StoreData) { + var path = Path.Combine(config.DataStoragePath, "users"); + if (!Directory.Exists(path)) Directory.CreateDirectory(path); + foreach (var file in Directory.GetFiles(path)) { + var user = JsonSerializer.Deserialize<User>(File.ReadAllText(file)); + if (user is not null) _users.Add(user); + } + + Console.WriteLine($"Loaded {_users.Count} users from disk"); + } + else { + Console.WriteLine("Data storage is disabled, not loading users from disk"); + } } public async Task<User?> GetUserById(string userId, bool createIfNotExists = false) { - if (_usersById.TryGetValue(userId, out var user)) { - return user; - } + if (_users.Any(x => x.UserId == userId)) + return _users.First(x => x.UserId == userId); if (!createIfNotExists) return null; - return await CreateUser(userId, Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), new Dictionary<string, object>()); + return await CreateUser(userId); } public async Task<User?> GetUserByToken(string token, bool createIfNotExists = false, string? serverName = null) { - if (_usersByToken.TryGetValue(token, out var user)) { - return user; - } + if (_users.Any(x => x.AccessTokens.ContainsKey(token))) + return _users.First(x => x.AccessTokens.ContainsKey(token)); if (!createIfNotExists) return null; if (string.IsNullOrWhiteSpace(serverName)) throw new NullReferenceException("Server name was not passed"); var uid = $"@{Guid.NewGuid().ToString()}:{serverName}"; - return await CreateUser(uid, Guid.NewGuid().ToString(), token, new Dictionary<string, object>()); + return await CreateUser(uid); } - public async Task<User> CreateUser(string userId, string deviceId, string accessToken, Dictionary<string, object> profile) { + public async Task<User> CreateUser(string userId, Dictionary<string, object>? profile = null) { + profile ??= new(); if (!profile.ContainsKey("displayname")) profile.Add("displayname", userId.Split(":")[0]); if (!profile.ContainsKey("avatar_url")) profile.Add("avatar_url", null); - var user = new User { + var user = new User() { UserId = userId, - DeviceId = deviceId, - AccessToken = accessToken, - Profile = profile + AccountData = new() { + new StateEventResponse() { + Type = "im.vector.analytics", + RawContent = new JsonObject() { + ["pseudonymousAnalyticsOptIn"] = false + }, + }, + new StateEventResponse() { + Type = "im.vector.web.settings", + RawContent = new JsonObject() { + ["developerMode"] = true + } + }, + } }; + user.Profile.AddRange(profile); _users.Add(user); - RebuildIndexes(); - - if (roomStore._rooms.Count > 0) - foreach (var item in Random.Shared.GetItems(roomStore._rooms.ToArray(), Math.Min(roomStore._rooms.Count, 400))) { + if (!_roomStore._rooms.IsEmpty) + foreach (var item in Random.Shared.GetItems(_roomStore._rooms.ToArray(), Math.Min(_roomStore._rooms.Count, 400))) { item.AddUser(userId); } int random = Random.Shared.Next(10); for (int i = 0; i < random; i++) { - var room = roomStore.CreateRoom(new()); + var room = _roomStore.CreateRoom(new()); room.AddUser(userId); } return user; } - public class User { - public string UserId { get; set; } - public string AccessToken { get; set; } - public string DeviceId { get; set; } - public Dictionary<string, object> Profile { get; set; } + public class User : NotifyPropertyChanged { + public User() { + AccessTokens = new(); + Filters = new(); + Profile = new(); + AccountData = new(); + RoomKeys = new(); + } + + private CancellationTokenSource _debounceCts = new(); + private string _userId; + private ObservableDictionary<string, SessionInfo> _accessTokens; + private ObservableDictionary<string, SyncFilter> _filters; + private ObservableDictionary<string, object> _profile; + private ObservableCollection<StateEventResponse> _accountData; + private ObservableDictionary<string, RoomKeysResponse> _roomKeys; + + public string UserId { + get => _userId; + set => SetField(ref _userId, value); + } + + public ObservableDictionary<string, SessionInfo> AccessTokens { + get => _accessTokens; + set { + if (value == _accessTokens) return; + _accessTokens = new(value); + _accessTokens.CollectionChanged += async (sender, args) => await SaveDebounced(); + OnPropertyChanged(); + } + } + + public ObservableDictionary<string, SyncFilter> Filters { + get => _filters; + set { + if (value == _filters) return; + _filters = new(value); + _filters.CollectionChanged += async (sender, args) => await SaveDebounced(); + OnPropertyChanged(); + } + } + + public ObservableDictionary<string, object> Profile { + get => _profile; + set { + if (value == _profile) return; + _profile = new(value); + _profile.CollectionChanged += async (sender, args) => await SaveDebounced(); + OnPropertyChanged(); + } + } + + public ObservableCollection<StateEventResponse> AccountData { + get => _accountData; + set { + if (value == _accountData) return; + _accountData = new(value); + _accountData.CollectionChanged += async (sender, args) => await SaveDebounced(); + OnPropertyChanged(); + } + } + + public ObservableDictionary<string, RoomKeysResponse> RoomKeys { + get => _roomKeys; + set { + if (value == _roomKeys) return; + _roomKeys = new(value); + _roomKeys.CollectionChanged += async (sender, args) => await SaveDebounced(); + OnPropertyChanged(); + } + } + + public async Task SaveDebounced() { + if (!HSEConfiguration.Current.StoreData) return; + _debounceCts.Cancel(); + _debounceCts = new CancellationTokenSource(); + try { + await Task.Delay(250, _debounceCts.Token); + var path = Path.Combine(HSEConfiguration.Current.DataStoragePath, "users", $"{_userId}.json"); + Console.WriteLine($"Saving user {_userId} to {path}!"); + await File.WriteAllTextAsync(path, this.ToJson(ignoreNull: true)); + } + catch (TaskCanceledException) { } + catch (InvalidOperationException) { } // We don't care about 100% data safety, this usually happens when something is updated while serialising + } + + public class SessionInfo { + public string DeviceId { get; set; } = Guid.NewGuid().ToString(); + public Dictionary<string, UserSyncState> SyncStates { get; set; } = new(); + + public class UserSyncState { + public Dictionary<string, SyncRoomPosition> RoomPositions { get; set; } = new(); + public string FilterId { get; set; } + public DateTime SyncStateCreated { get; set; } = DateTime.Now; - public List<string> JoinedRooms { get; set; } = new(); + public class SyncRoomPosition { + public int TimelinePosition { get; set; } + public int StatePosition { get; set; } + public int AccountDataPosition { get; set; } + } + } + } + + public LoginResponse Login() { + var session = new SessionInfo(); + AccessTokens.Add(Guid.NewGuid().ToString(), session); + SaveDebounced(); + return new LoginResponse() { + AccessToken = AccessTokens.Keys.Last(), + DeviceId = session.DeviceId, + UserId = UserId + }; + } } } \ No newline at end of file diff --git a/Tests/LibMatrix.HomeserverEmulator/appsettings.Development.json b/Tests/LibMatrix.HomeserverEmulator/appsettings.Development.json index df83ec5..f14522d 100644 --- a/Tests/LibMatrix.HomeserverEmulator/appsettings.Development.json +++ b/Tests/LibMatrix.HomeserverEmulator/appsettings.Development.json @@ -4,7 +4,9 @@ "Default": "Information", "Microsoft.AspNetCore": "Information", "Microsoft.AspNetCore.Routing": "Warning", - "Microsoft.AspNetCore.Mvc": "Warning" + "Microsoft.AspNetCore.Mvc": "Warning" } + }, + "HomeserverEmulator": { } } |