From 5abe9c320909f041fbc2326b90a8c3dd6da1545d Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:21:03 -0500 Subject: [PATCH 01/55] Scaffolding for new User system --- .../PMDDesktop.Server.Users.Tests.csproj | 25 ++++ PMDDesktop.Server.Users.Tests/UserTests.cs | 31 +++++ PMDDesktop.Server.Users/AccessToken.cs | 53 ++++++++ PMDDesktop.Server.Users/ISaveDataIndexable.cs | 17 +++ .../PMDDesktop.Server.Users.csproj | 14 ++ .../SaveDataReferenceConverter.cs | 29 +++++ PMDDesktop.Server.Users/User.cs | 120 ++++++++++++++++++ PMDDesktop.Server.Users/UserManager.cs | 12 ++ PMDDesktop.Server.Users/UserReference.cs | 44 +++++++ PMDDesktop.Server/User.cs | 20 --- PMDDesktop.slnx | 2 + 11 files changed, 347 insertions(+), 20 deletions(-) create mode 100644 PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj create mode 100644 PMDDesktop.Server.Users.Tests/UserTests.cs create mode 100644 PMDDesktop.Server.Users/AccessToken.cs create mode 100644 PMDDesktop.Server.Users/ISaveDataIndexable.cs create mode 100644 PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj create mode 100644 PMDDesktop.Server.Users/SaveDataReferenceConverter.cs create mode 100644 PMDDesktop.Server.Users/User.cs create mode 100644 PMDDesktop.Server.Users/UserManager.cs create mode 100644 PMDDesktop.Server.Users/UserReference.cs delete mode 100644 PMDDesktop.Server/User.cs diff --git a/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj b/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj new file mode 100644 index 0000000..c988a3a --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs new file mode 100644 index 0000000..6dd070e --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/UserTests.cs @@ -0,0 +1,31 @@ +namespace PMDDesktop.Server.Users.Tests; + +public class UserTests +{ + + [Fact] + public async Task PasswordVerification() + { + + User user = new(); + + await user.SetPassword("Test123"); + + Assert.False(await user.VerifyPassword("Test456")); + Assert.False(await user.VerifyPassword("")); + Assert.True(await user.VerifyPassword("Test123")); + + } + + [Fact] + public async Task NullPasswordVerification() + { + + User user = new(); + + Assert.False(await user.VerifyPassword("Test123")); + Assert.False(await user.VerifyPassword("Test456")); + + } + +} diff --git a/PMDDesktop.Server.Users/AccessToken.cs b/PMDDesktop.Server.Users/AccessToken.cs new file mode 100644 index 0000000..4874c99 --- /dev/null +++ b/PMDDesktop.Server.Users/AccessToken.cs @@ -0,0 +1,53 @@ +using System.Security.Cryptography; + +namespace PMDDesktop.Server.Users; + +public sealed class AccessToken +{ + + public static readonly TimeSpan TIME_UNTIL_EXPIRE = new(8, 0, 0, 0); // 8 days. + public static readonly TimeSpan REFRESH_THRESHOLD = new(23, 0, 0); // Ideally if you log in once a week, you'll stay logged in. + + public static readonly int RANDOM_BYTE_COUNT = 32; + + internal AccessToken(string token, User forUser, DateTime expires) + { + + TokenString = token; + User = new(forUser); + ExpiryDate = expires; + + } + + internal static AccessToken CreateNewToken(User forUser) + { + + string tokenString = Convert.ToBase64String(RandomNumberGenerator.GetBytes(RANDOM_BYTE_COUNT)); + + return new(tokenString, forUser, DateTime.Now + TIME_UNTIL_EXPIRE); + + } + + public DateTime ExpiryDate {get; private set;} + + public string TokenString {get; private set;} + + public UserReference User {get; internal set;} + + public void RefreshToken() + { + + ExpiryDate = DateTime.UtcNow + TIME_UNTIL_EXPIRE; + + } + + public bool IsTokenValid() + { + + DateTime now = DateTime.UtcNow; + + return now < ExpiryDate; + + } + +} \ No newline at end of file diff --git a/PMDDesktop.Server.Users/ISaveDataIndexable.cs b/PMDDesktop.Server.Users/ISaveDataIndexable.cs new file mode 100644 index 0000000..ab2f414 --- /dev/null +++ b/PMDDesktop.Server.Users/ISaveDataIndexable.cs @@ -0,0 +1,17 @@ +using System.Diagnostics.CodeAnalysis; + +namespace PMDDesktop.Server.Users; + +/// +/// Interface for objects that can fetch a from a . +/// +public interface IUserIndexable +{ + + + User GetUser(Guid GUID); + + + bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User user); + +} diff --git a/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj b/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj new file mode 100644 index 0000000..b93788b --- /dev/null +++ b/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj @@ -0,0 +1,14 @@ + + + + Library + net10.0 + enable + enable + + + + + + + diff --git a/PMDDesktop.Server.Users/SaveDataReferenceConverter.cs b/PMDDesktop.Server.Users/SaveDataReferenceConverter.cs new file mode 100644 index 0000000..4c76088 --- /dev/null +++ b/PMDDesktop.Server.Users/SaveDataReferenceConverter.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users; + + +internal class UserReferenceConverter : JsonConverter +{ + public override UserReference Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + + if (reader.TokenType != JsonTokenType.String) + throw new JsonException($"{Type} was expecting a string token to deserialize."); + + string guidString = reader.GetString() ?? throw new JsonException($"We read a null string while trying to deserialize {Type}."); + + Guid guid = Guid.Parse(guidString); + + return new UserReference(guid); + + } + + public override void Write(Utf8JsonWriter writer, UserReference value, JsonSerializerOptions options) + { + + writer.WriteStringValue(value.GUID.ToString()); + + } +} diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs new file mode 100644 index 0000000..4580fb9 --- /dev/null +++ b/PMDDesktop.Server.Users/User.cs @@ -0,0 +1,120 @@ +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Identity; + +namespace PMDDesktop.Server.Users; + +public sealed class User +{ + + static PasswordHasher PASSWORD_HASHER = new(); + + [JsonConstructor] + internal User() + { + + } + + /// + /// A string representing a 's name. + /// + [JsonInclude] + public string Name {get; internal set;} = string.Empty; + + /// + /// A string to use for logging in. Should only ever contain [a-z], [0-9] and (-) + /// Does not neccessarily match in most senarios. + /// Should be unique. + /// + [JsonInclude] + public string LoginHandle {get; internal set;} = string.Empty; + + /// + /// Hashed password used to log in. + /// In the event that it is null, access to this account cannot be granted via password. + /// + [JsonInclude] + internal string? HashedPassword {get; set;} + + /// + /// A unique for each . Stays consistant even when the 's handle changes. + /// This should be used for long-term references to a specific . + /// + [JsonIgnore] + public Guid GUID {get; internal set;} = Guid.NewGuid(); + + /// + /// Salt and hash the user's password, then store it in HashedPassword. + /// + /// + /// + public async Task SetPassword(string plainText) + { + + HashedPassword = PASSWORD_HASHER.HashPassword(this, plainText); + + } + + public async Task VerifyPassword(string plainText) + { + + if (HashedPassword is null) + { + + return false; + + } + + PasswordVerificationResult result = PASSWORD_HASHER.VerifyHashedPassword(this, HashedPassword, plainText); + + if (result == PasswordVerificationResult.SuccessRehashNeeded) + { + + await SetPassword(plainText); + + } + + return result == PasswordVerificationResult.Success || result == PasswordVerificationResult.SuccessRehashNeeded; + + } + + internal string GetUserFolder() + { + + return Path.Combine(AppContext.BaseDirectory, "users", GUID.ToString()); + + } + + public string GetUserJSON() + { + + return Path.Combine(GetUserFolder(), "user.json"); + + } + + public async Task WriteNewData() + { + + + + } + + public override bool Equals(object? obj) + { + + if (obj is User user) + { + + return user.GUID == GUID; + + } + + return false; + + } + + public override int GetHashCode() + { + return HashCode.Combine(GUID); + } + +} diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs new file mode 100644 index 0000000..2e50ea4 --- /dev/null +++ b/PMDDesktop.Server.Users/UserManager.cs @@ -0,0 +1,12 @@ +namespace PMDDesktop.Server.Users; + +public sealed class UserManager() +{ + + private Dictionary users = []; + + private Dictionary tokens = []; + + + +} \ No newline at end of file diff --git a/PMDDesktop.Server.Users/UserReference.cs b/PMDDesktop.Server.Users/UserReference.cs new file mode 100644 index 0000000..d338b14 --- /dev/null +++ b/PMDDesktop.Server.Users/UserReference.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users; + +[JsonConverter(typeof(UserReferenceConverter))] +public sealed class UserReference +{ + + public Guid GUID { get; private set; } + + + public UserReference(User initalValue) + { + + GUID = initalValue.GUID; + + } + + + internal UserReference(Guid directGuid) + { + + GUID = directGuid; + + } + + + public User GetUser(IUserIndexable indexable) + { + + return indexable.GetUser(GUID); + + } + + + public bool TryGetUser(IUserIndexable indexable, [NotNullWhen(true)] out User user) + { + + return indexable.TryGetUser(GUID, out user); + + } + +} diff --git a/PMDDesktop.Server/User.cs b/PMDDesktop.Server/User.cs deleted file mode 100644 index 7435846..0000000 --- a/PMDDesktop.Server/User.cs +++ /dev/null @@ -1,20 +0,0 @@ -using PMDDesktop.Server.Saving; - -namespace PMDDesktop.Server; - -[SaveSubdirectory("users")] -internal sealed class User : SaveData -{ - - public string displayName; - public string loginHandle; - - private User(string handle) : base() - { - - loginHandle = handle; - displayName = handle.ToUpper(); - - } - -} diff --git a/PMDDesktop.slnx b/PMDDesktop.slnx index 12cfc68..c3e0818 100644 --- a/PMDDesktop.slnx +++ b/PMDDesktop.slnx @@ -2,6 +2,8 @@ + + From 5259007d847587050c61e6d0554577df2879ce8d Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:21:17 -0500 Subject: [PATCH 02/55] Change JsonRequestHandler's CreateResponse to be async --- PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs | 4 ++-- .../RequestHandlers/ListSpeciesRequestHandler.cs | 2 +- PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs index 687ee38..7ae343f 100644 --- a/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs @@ -163,7 +163,7 @@ public async Task HandleRequest(HttpContext context, GameServer game) context.Response.StatusCode = 200; context.Response.ContentType = "application/json"; - await context.Response.WriteAsJsonAsync(CreateResponse(deserialized, game), AppInfo.NETWORK_JSON_OPTIONS); + await context.Response.WriteAsJsonAsync(await CreateResponse(deserialized, game), AppInfo.NETWORK_JSON_OPTIONS); } catch (UserRequestException e) // Intended for user-facing errors, like bad inputs, @@ -200,6 +200,6 @@ private static async Task WritePlainText(HttpContext context, int code, string e } - protected abstract TRes CreateResponse(TReq request, GameServer game); + protected abstract Task CreateResponse(TReq request, GameServer game); } diff --git a/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs index 3cbab7b..4e1cc57 100644 --- a/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs @@ -7,7 +7,7 @@ namespace PMDDesktop.Server.RequestHandlers; public class ListSpeciesRequestHandler : JsonRequestHandler { - protected override ListSpeciesResponse CreateResponse(ListSpeciesRequest request, GameServer game) + protected override async Task CreateResponse(ListSpeciesRequest request, GameServer game) { return new() diff --git a/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs index 066f471..6b43b34 100644 --- a/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs @@ -7,7 +7,7 @@ namespace PMDDesktop.Server.RequestHandlers; public class ListTypesRequestHandler : JsonRequestHandler { - protected override ListTypesResponse CreateResponse(ListTypesRequest request, GameServer game) + protected override async Task CreateResponse(ListTypesRequest request, GameServer game) { return new() From 12b13a9d3666d00be26bbd28f1952e926da5e548 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:37:40 -0500 Subject: [PATCH 03/55] Add login and access token distributor --- PMDDesktop.Server.Users.Tests/UserTests.cs | 76 ++++++- PMDDesktop.Server.Users/AccessToken.cs | 53 ----- ...SaveDataIndexable.cs => IUserIndexable.cs} | 6 +- PMDDesktop.Server.Users/User.cs | 193 +++++++++++++++++- PMDDesktop.Server.Users/UserAccessToken.cs | 76 +++++++ .../UserCreationOptions.cs | 10 + PMDDesktop.Server.Users/UserManager.cs | 128 +++++++++++- PMDDesktop.Server.Users/UserReference.cs | 14 +- ...Converter.cs => UserReferenceConverter.cs} | 0 PMDDesktop.Server/Game/GameState.cs | 2 + PMDDesktop.Server/PMDDesktop.Server.csproj | 1 + .../Auth/LoginRequestHandler.cs | 26 +++ PMDDesktop/Requests/Auth/LoginRequest.cs | 10 + PMDDesktop/Requests/Auth/LoginResponse.cs | 8 + 14 files changed, 520 insertions(+), 83 deletions(-) delete mode 100644 PMDDesktop.Server.Users/AccessToken.cs rename PMDDesktop.Server.Users/{ISaveDataIndexable.cs => IUserIndexable.cs} (72%) create mode 100644 PMDDesktop.Server.Users/UserAccessToken.cs create mode 100644 PMDDesktop.Server.Users/UserCreationOptions.cs rename PMDDesktop.Server.Users/{SaveDataReferenceConverter.cs => UserReferenceConverter.cs} (100%) create mode 100644 PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs create mode 100644 PMDDesktop/Requests/Auth/LoginRequest.cs create mode 100644 PMDDesktop/Requests/Auth/LoginResponse.cs diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs index 6dd070e..6e8751a 100644 --- a/PMDDesktop.Server.Users.Tests/UserTests.cs +++ b/PMDDesktop.Server.Users.Tests/UserTests.cs @@ -3,17 +3,30 @@ public class UserTests { + public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new() + { + DisplayName = "Test User", + LoginHandle = "test", + PlainTextPassword = "Test123" + }; + [Fact] public async Task PasswordVerification() { - User user = new(); + UserManager manager = new(); + Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS)); + + string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle; + Assert.NotNull(loginHandle); + Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user)); - await user.SetPassword("Test123"); + string? defaultPassword = DEFAULT_USER_OPTIONS.PlainTextPassword; + Assert.NotNull(defaultPassword); Assert.False(await user.VerifyPassword("Test456")); Assert.False(await user.VerifyPassword("")); - Assert.True(await user.VerifyPassword("Test123")); + Assert.True(await user.VerifyPassword(defaultPassword)); } @@ -21,11 +34,64 @@ public async Task PasswordVerification() public async Task NullPasswordVerification() { - User user = new(); + UserManager manager = new(); + Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS with + { + PlainTextPassword = null + })); + + string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle; + Assert.NotNull(loginHandle); + Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user)); - Assert.False(await user.VerifyPassword("Test123")); + string? defaultPassword = DEFAULT_USER_OPTIONS.PlainTextPassword; + Assert.NotNull(defaultPassword); + + Assert.False(await user.VerifyPassword(defaultPassword)); Assert.False(await user.VerifyPassword("Test456")); } + [Fact] + public async Task AccessTokenGrantsAccess() + { + + UserManager manager = new(); + Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS)); + + string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle; + Assert.NotNull(loginHandle); + Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user)); + + UserAccessToken token = await user.CreateAccessToken(); + + Assert.True(manager.TryUseAccessToken(token.TokenString, out User? outUser)); + + Assert.Equal(user, outUser); + + } + + [Fact] + public async Task LoginHandleChanges() + { + + UserManager manager = new(); + Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS)); + + string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle; + Assert.NotNull(loginHandle); + Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user)); + + string newHandle = "new-handle"; + + Assert.True(await user.TrySetLoginHandle(newHandle)); + + Assert.False(manager.TryGetUserByLoginHandle(loginHandle, out User? oldHandleUser)); + Assert.Null(oldHandleUser); + + Assert.True(manager.TryGetUserByLoginHandle(newHandle, out User? newHandleUser)); + Assert.NotNull(newHandleUser); + + } + } diff --git a/PMDDesktop.Server.Users/AccessToken.cs b/PMDDesktop.Server.Users/AccessToken.cs deleted file mode 100644 index 4874c99..0000000 --- a/PMDDesktop.Server.Users/AccessToken.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Security.Cryptography; - -namespace PMDDesktop.Server.Users; - -public sealed class AccessToken -{ - - public static readonly TimeSpan TIME_UNTIL_EXPIRE = new(8, 0, 0, 0); // 8 days. - public static readonly TimeSpan REFRESH_THRESHOLD = new(23, 0, 0); // Ideally if you log in once a week, you'll stay logged in. - - public static readonly int RANDOM_BYTE_COUNT = 32; - - internal AccessToken(string token, User forUser, DateTime expires) - { - - TokenString = token; - User = new(forUser); - ExpiryDate = expires; - - } - - internal static AccessToken CreateNewToken(User forUser) - { - - string tokenString = Convert.ToBase64String(RandomNumberGenerator.GetBytes(RANDOM_BYTE_COUNT)); - - return new(tokenString, forUser, DateTime.Now + TIME_UNTIL_EXPIRE); - - } - - public DateTime ExpiryDate {get; private set;} - - public string TokenString {get; private set;} - - public UserReference User {get; internal set;} - - public void RefreshToken() - { - - ExpiryDate = DateTime.UtcNow + TIME_UNTIL_EXPIRE; - - } - - public bool IsTokenValid() - { - - DateTime now = DateTime.UtcNow; - - return now < ExpiryDate; - - } - -} \ No newline at end of file diff --git a/PMDDesktop.Server.Users/ISaveDataIndexable.cs b/PMDDesktop.Server.Users/IUserIndexable.cs similarity index 72% rename from PMDDesktop.Server.Users/ISaveDataIndexable.cs rename to PMDDesktop.Server.Users/IUserIndexable.cs index ab2f414..1a8b6fb 100644 --- a/PMDDesktop.Server.Users/ISaveDataIndexable.cs +++ b/PMDDesktop.Server.Users/IUserIndexable.cs @@ -8,10 +8,8 @@ namespace PMDDesktop.Server.Users; public interface IUserIndexable { - - User GetUser(Guid GUID); + User? GetUser(Guid GUID); - - bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User user); + bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user); } diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index 4580fb9..d806fba 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -1,12 +1,21 @@ -using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity; +using System.Text.Json.Serialization; namespace PMDDesktop.Server.Users; public sealed class User { - static PasswordHasher PASSWORD_HASHER = new(); + private static PasswordHasher PASSWORD_HASHER = new(); + + /// + /// Limit of s assigned to one . + /// + /// + /// This is supposed to help combat a senario where a user creates millions of access tokens to intentionally fill up a hard drive or system memory. + /// Downside is that it prevents an honest user from having a large number of legitimate connections. + /// + public static readonly int ACCESS_TOKEN_LIMIT = 20; [JsonConstructor] internal User() @@ -18,7 +27,7 @@ internal User() /// A string representing a 's name. /// [JsonInclude] - public string Name {get; internal set;} = string.Empty; + public string Name { get; internal set; } = string.Empty; /// /// A string to use for logging in. Should only ever contain [a-z], [0-9] and (-) @@ -26,21 +35,29 @@ internal User() /// Should be unique. /// [JsonInclude] - public string LoginHandle {get; internal set;} = string.Empty; + public string? LoginHandle { get; private set; } /// /// Hashed password used to log in. /// In the event that it is null, access to this account cannot be granted via password. /// [JsonInclude] - internal string? HashedPassword {get; set;} + private string? HashedPassword { get; set; } /// /// A unique for each . Stays consistant even when the 's handle changes. /// This should be used for long-term references to a specific . /// [JsonIgnore] - public Guid GUID {get; internal set;} = Guid.NewGuid(); + public Guid GUID { get; internal set; } = Guid.NewGuid(); + + /// + /// + /// + [JsonIgnore] + public UserManager? Manager {get; private set;} + + private List accessTokens = []; /// /// Salt and hash the user's password, then store it in HashedPassword. @@ -54,6 +71,58 @@ public async Task SetPassword(string plainText) } + /// + /// + /// + /// + /// + public async Task TrySetLoginHandle(string? newHandle) + { + + // Handle is not the same. + if (newHandle == LoginHandle) + return false; + + // Handle is free. + if (newHandle is not null && Manager is not null && Manager.loginHandles.ContainsKey(newHandle)) + return false; + + DetachLoginHandle(); + + LoginHandle = newHandle; + + AttachLoginHandle(); + + return true; + + } + + private void AttachLoginHandle() + { + + if (LoginHandle is null) + return; + + Manager?.loginHandles.Add(LoginHandle, this); + + } + + private void DetachLoginHandle() + { + + if (LoginHandle is null) + return; + + Manager?.loginHandles.Remove(LoginHandle); + + } + + /// + /// Check that hashes to the same password stored in the user. + /// May also rehash the password if evidence is shown that it needs a rehash. + /// + /// The password input by the user. + /// True if the password matched. public async Task VerifyPassword(string plainText) { @@ -73,10 +142,15 @@ public async Task VerifyPassword(string plainText) } - return result == PasswordVerificationResult.Success || result == PasswordVerificationResult.SuccessRehashNeeded; + return result is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded; } + /// + /// Return the filesystem folder that should contain all of this user's data. + /// + /// A path pointing to the user's folder. + /// This path is created using the user's GUID. internal string GetUserFolder() { @@ -84,7 +158,11 @@ internal string GetUserFolder() } - public string GetUserJSON() + /// + /// Gets the primary JSON that contains this user's data, located inside the user folder. + /// + /// A path pointing to where the user's json data is/should be stored. + public string GetUserJSONPath() { return Path.Combine(GetUserFolder(), "user.json"); @@ -94,7 +172,13 @@ public string GetUserJSON() public async Task WriteNewData() { - + if (Manager is null) // ????? This state doesn't make any sense. + throw new InvalidOperationException($"{this} shouldn't be writing its data without being attached to a {nameof(Manager)}."); + + if (!Manager.WritingEnabled) + return; + + throw new NotImplementedException(); } @@ -117,4 +201,93 @@ public override int GetHashCode() return HashCode.Combine(GUID); } + public async Task CreateAccessToken() + { + + while (accessTokens.Count >= ACCESS_TOKEN_LIMIT) + await TryRevokeOldestAccessToken(); + + UserAccessToken token = UserAccessToken.CreateNewToken(this); + + accessTokens.Add(token); + + AttachAccessToken(token); + + return token; + + } + + private void AttachAccessToken(UserAccessToken token) + { + + // if (accessTokens.Any(existing => existing.TokenString == token.TokenString)) + // throw new Exception($"The token: {token.TokenString} is already in {this}'s {nameof(accessTokens)}."); + + Manager?.accessTokens.Add(token.TokenString, token); + + } + + public async Task RevokeAccessToken(UserAccessToken token) + { + + accessTokens.Remove(token); + + DetachAccessToken(token); + + } + + private void DetachAccessToken(UserAccessToken token) + { + + Manager?.accessTokens.Remove(token.TokenString); + + } + + private async Task TryRevokeOldestAccessToken() + { + + UserAccessToken? oldest = null; + + foreach (UserAccessToken token in accessTokens) + { + + oldest ??= token; + + if (oldest.ExpiryDate > token.ExpiryDate) + oldest = token; + + } + + if (oldest is null) + return false; + + await RevokeAccessToken(oldest); + return true; + + } + + internal void AttachToManager(UserManager manager) + { + + Manager = manager; + + AttachLoginHandle(); + + foreach (UserAccessToken token in accessTokens) + AttachAccessToken(token); + + } + + internal void DetachFromManager() + { + + DetachLoginHandle(); + + foreach (UserAccessToken token in accessTokens) + DetachAccessToken(token); + + Manager = null; + + } + } diff --git a/PMDDesktop.Server.Users/UserAccessToken.cs b/PMDDesktop.Server.Users/UserAccessToken.cs new file mode 100644 index 0000000..ff4f71f --- /dev/null +++ b/PMDDesktop.Server.Users/UserAccessToken.cs @@ -0,0 +1,76 @@ +using System.Security.Cryptography; + +namespace PMDDesktop.Server.Users; + +public sealed class UserAccessToken +{ + + public static readonly TimeSpan TIME_UNTIL_EXPIRE = new(8, 0, 0, 0); // 8 days. + public static readonly TimeSpan REFRESH_THRESHOLD = new(7, 0, 0, 0); // 7 Days. // Ideally if you log in once a week, you'll stay logged in. + + public static readonly int RANDOM_CHAR_COUNT = 64; + + public static readonly char[] RANDOM_CHARS = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + '-', '_' + ]; + + internal UserAccessToken(string token, User forUser, DateTime expires) + { + + TokenString = token; + User = forUser; + ExpiryDate = expires; + + } + + internal static UserAccessToken CreateNewToken(User forUser) + { + + string tokenString = RandomNumberGenerator.GetString(RANDOM_CHARS, RANDOM_CHAR_COUNT); + + return new(tokenString, forUser, DateTime.Now + TIME_UNTIL_EXPIRE); + + } + + public DateTime ExpiryDate { get; private set; } + + public string TokenString { get; private set; } + + public User User { get; internal set; } + + public bool NeedsRefresh() + { + + return DateTime.UtcNow < ExpiryDate - REFRESH_THRESHOLD; + + } + + public async Task RefreshToken() + { + + ExpiryDate = DateTime.UtcNow + TIME_UNTIL_EXPIRE; + + await User.WriteNewData(); + + } + + public bool IsTokenValid() + { + + DateTime now = DateTime.UtcNow; + + return now < ExpiryDate; + + } + + public async Task RevokeToken() + { + + await User.RevokeAccessToken(this); + + } + +} diff --git a/PMDDesktop.Server.Users/UserCreationOptions.cs b/PMDDesktop.Server.Users/UserCreationOptions.cs new file mode 100644 index 0000000..05b4968 --- /dev/null +++ b/PMDDesktop.Server.Users/UserCreationOptions.cs @@ -0,0 +1,10 @@ +namespace PMDDesktop.Server.Users; + +public sealed record class UserCreationOptions +{ + + public required string DisplayName {get; set;} + public string? PlainTextPassword {get; set;} + public string? LoginHandle {get; set;} + +} diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs index 2e50ea4..f3ba771 100644 --- a/PMDDesktop.Server.Users/UserManager.cs +++ b/PMDDesktop.Server.Users/UserManager.cs @@ -1,12 +1,132 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; + namespace PMDDesktop.Server.Users; -public sealed class UserManager() +public sealed class UserManager() : IEnumerable, IUserIndexable { + /// + /// Does this actually write to files? + /// If this is false, file writes to the filesystem/disk will be skipped. + /// + /// + /// This is property as "false" is very useful in unit testing. Should probably be "true" during runtime. + /// + public bool WritingEnabled { get; private set; } = false; + + /// + /// This is a dictionary with all the s being managed by this . + /// + /// + /// Entries are created and removed by the owning . + /// private Dictionary users = []; - private Dictionary tokens = []; + /// + /// This is a dictionary with all the s mapped to their . + /// + /// + /// This dictionary's entries are not managed by the , but instead by the s inside . Do not let write to this dictionary. + /// + internal Dictionary loginHandles = []; + + /// + /// This is a dictionary with all the s mapped to their . + /// + /// + /// This dictionary's entries are not managed by the , but instead by the s inside . Do not let write to this dictionary. + /// + internal Dictionary accessTokens = []; + + public IEnumerator GetEnumerator() + { + return users.Values.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Try to get a using the string of an access token. Never use this function for any purpose other than authorization. + /// This function respects token expiration, and may clear tokens that have expired. It may also refresh a token's expiration if a certain amount of time has passed. + /// If this is , this function may write to the filesystem/disk to update token information. + /// + /// The token string corresponding to a , but not an error if it doesn't. + /// + public bool TryUseAccessToken(string tokenString, [NotNullWhen(true)] out User? user) + { + + return TryUseAccessToken(tokenString, out user, out _); + + } + + /// + /// Try to get a using the string of an access token. Never use this function for any purpose other than authorization. + /// This function respects token expiration, and may clear tokens that have expired. It may also refresh a token's expiration if a certain amount of time has passed. + /// If this is , this function may write to the filesystem/disk to update token information. + /// + /// The token string corresponding to a , but not an error if it doesn't. + /// True if access should be allowed, otherwise false. + /// Sometimes, and may not be null, even if false was returned. In this case, do not give access, but you may inspect the results. + public bool TryUseAccessToken(string tokenString, [NotNullWhen(true)] out User? user, [NotNullWhen(true)] out UserAccessToken? token) + { + + accessTokens.TryGetValue(tokenString, out token); + user = token?.User; + + if (token is null || user is null) + return false; + + if (!token.IsTokenValid()) + return false; + + return true; + + } + + public User? GetUser(Guid GUID) + { + + TryGetUser(GUID, out User? user); + + return user; + + } + + public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user) + { + + return users.TryGetValue(GUID, out user); + + } + + public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User? user) + { + + return loginHandles.TryGetValue(handle, out user); + + } + + public async Task TryCreateUser(UserCreationOptions options) + { + + User user = new() + { + Name = options.DisplayName + }; + + if (options.LoginHandle is not null) + if (!await user.TrySetLoginHandle(options.LoginHandle)) + return false; + if (options.PlainTextPassword is not null) + await user.SetPassword(options.PlainTextPassword); + + user.AttachToManager(this); + return true; - + } -} \ No newline at end of file +} diff --git a/PMDDesktop.Server.Users/UserReference.cs b/PMDDesktop.Server.Users/UserReference.cs index d338b14..51b9ad7 100644 --- a/PMDDesktop.Server.Users/UserReference.cs +++ b/PMDDesktop.Server.Users/UserReference.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace PMDDesktop.Server.Users; @@ -9,7 +9,7 @@ public sealed class UserReference public Guid GUID { get; private set; } - + public UserReference(User initalValue) { @@ -17,7 +17,7 @@ public UserReference(User initalValue) } - + internal UserReference(Guid directGuid) { @@ -25,16 +25,16 @@ internal UserReference(Guid directGuid) } - - public User GetUser(IUserIndexable indexable) + + public User? GetUser(IUserIndexable indexable) { return indexable.GetUser(GUID); } - - public bool TryGetUser(IUserIndexable indexable, [NotNullWhen(true)] out User user) + + public bool TryGetUser(IUserIndexable indexable, [NotNullWhen(true)] out User? user) { return indexable.TryGetUser(GUID, out user); diff --git a/PMDDesktop.Server.Users/SaveDataReferenceConverter.cs b/PMDDesktop.Server.Users/UserReferenceConverter.cs similarity index 100% rename from PMDDesktop.Server.Users/SaveDataReferenceConverter.cs rename to PMDDesktop.Server.Users/UserReferenceConverter.cs diff --git a/PMDDesktop.Server/Game/GameState.cs b/PMDDesktop.Server/Game/GameState.cs index 9b7d8fb..8dc1af0 100644 --- a/PMDDesktop.Server/Game/GameState.cs +++ b/PMDDesktop.Server/Game/GameState.cs @@ -1,5 +1,6 @@ using PMDDesktop.Server.Assets; using PMDDesktop.Server.Saving; +using PMDDesktop.Server.Users; using System.Diagnostics.CodeAnalysis; namespace PMDDesktop.Server.Game; @@ -50,5 +51,6 @@ public bool TryGetSave(Guid GUID, [NotNullWhen(true)] out T? data) where T : public SaveDataManager Saves { get; } = new(); public AssetManager Assets { get; } = new(); + public UserManager Users { get; } = new(); } diff --git a/PMDDesktop.Server/PMDDesktop.Server.csproj b/PMDDesktop.Server/PMDDesktop.Server.csproj index af927f5..47aaf46 100644 --- a/PMDDesktop.Server/PMDDesktop.Server.csproj +++ b/PMDDesktop.Server/PMDDesktop.Server.csproj @@ -19,6 +19,7 @@ + diff --git a/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs new file mode 100644 index 0000000..e706195 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs @@ -0,0 +1,26 @@ +using PMDDesktop.Requests.Auth; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; + +namespace PMDDesktop.Server.RequestHandlers.Auth; + +public class LoginRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(LoginRequest request, GameServer game) + { + + if (!game.State.Users.TryGetUserByLoginHandle(request.Handle, out User? user)) + throw new UserRequestException($"Invalid username or password."); + + if (!await user.VerifyPassword(request.Password)) + throw new UserRequestException($"Invalid username or password."); // Throw the same thing to be more "secure" I guess. + + return new() + { + AuthorizationToken = (await user.CreateAccessToken()).TokenString + }; + + } + +} diff --git a/PMDDesktop/Requests/Auth/LoginRequest.cs b/PMDDesktop/Requests/Auth/LoginRequest.cs new file mode 100644 index 0000000..28611e4 --- /dev/null +++ b/PMDDesktop/Requests/Auth/LoginRequest.cs @@ -0,0 +1,10 @@ +namespace PMDDesktop.Requests.Auth; + +[ServerRequest("/api/auth/login")] +public sealed class LoginRequest : ServerRequest +{ + + public required string Handle { get; set; } + public required string Password { get; set; } + +} diff --git a/PMDDesktop/Requests/Auth/LoginResponse.cs b/PMDDesktop/Requests/Auth/LoginResponse.cs new file mode 100644 index 0000000..81e8c17 --- /dev/null +++ b/PMDDesktop/Requests/Auth/LoginResponse.cs @@ -0,0 +1,8 @@ +namespace PMDDesktop.Requests.Auth; + +public sealed class LoginResponse : ServerResponse +{ + + public required string AuthorizationToken { get; set; } + +} From 753f26fe1ff2bf7e3282ac549c6a5462cef3758f Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:04:22 -0500 Subject: [PATCH 04/55] Fix GUID methods. --- PMDDesktop.Server.Users.Tests/UserTests.cs | 21 +++++++++++++++++++++ PMDDesktop.Server.Users/User.cs | 10 ++++++++++ PMDDesktop.Server.Users/UserManager.cs | 14 +++++++------- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs index 6e8751a..37db7d2 100644 --- a/PMDDesktop.Server.Users.Tests/UserTests.cs +++ b/PMDDesktop.Server.Users.Tests/UserTests.cs @@ -94,4 +94,25 @@ public async Task LoginHandleChanges() } + [Fact] + public async Task GetViaGUID() + { + + UserManager manager = new(); + Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS)); + + string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle; + Assert.NotNull(loginHandle); + Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user)); + + Guid guid = user.GUID; + + Assert.True(manager.TryGetUser(guid, out User? guidUser)); + Assert.NotNull(guidUser); + + Assert.False(manager.TryGetUser(Guid.Empty, out User? notGuidUser)); + Assert.Null(notGuidUser); + + } + } diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index d806fba..48fa336 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -270,6 +270,8 @@ internal void AttachToManager(UserManager manager) { Manager = manager; + + Manager.guids.Add(GUID, this); AttachLoginHandle(); @@ -281,6 +283,14 @@ internal void AttachToManager(UserManager manager) internal void DetachFromManager() { + if (Manager==null) + throw new NullReferenceException($"{nameof(Manager)} is null. Cannot detach from nothing. Does this call to {nameof(DetachFromManager)} need to be skipped?"); + + if (Manager.guids[GUID] != this) + throw new InvalidOperationException($"{this} was not inside {Manager.guids} with key {GUID}."); + + Manager.guids.Remove(GUID); + DetachLoginHandle(); foreach (UserAccessToken token in accessTokens) diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs index f3ba771..b3979e6 100644 --- a/PMDDesktop.Server.Users/UserManager.cs +++ b/PMDDesktop.Server.Users/UserManager.cs @@ -16,18 +16,18 @@ public sealed class UserManager() : IEnumerable, IUserIndexable public bool WritingEnabled { get; private set; } = false; /// - /// This is a dictionary with all the s being managed by this . + /// This is a dictionary with all the s mapped to their . /// /// - /// Entries are created and removed by the owning . + /// This dictionary's entries are not managed by the , but instead by the s as they are attached/detached. Do not let write to this dictionary. /// - private Dictionary users = []; + internal Dictionary guids = []; /// /// This is a dictionary with all the s mapped to their . /// /// - /// This dictionary's entries are not managed by the , but instead by the s inside . Do not let write to this dictionary. + /// This dictionary's entries are not managed by the , but instead by the s inside . Do not let write to this dictionary. /// internal Dictionary loginHandles = []; @@ -35,13 +35,13 @@ public sealed class UserManager() : IEnumerable, IUserIndexable /// This is a dictionary with all the s mapped to their . /// /// - /// This dictionary's entries are not managed by the , but instead by the s inside . Do not let write to this dictionary. + /// This dictionary's entries are not managed by the , but instead by the s inside . Do not let write to this dictionary. /// internal Dictionary accessTokens = []; public IEnumerator GetEnumerator() { - return users.Values.GetEnumerator(); + return guids.Values.GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() @@ -99,7 +99,7 @@ public bool TryUseAccessToken(string tokenString, [NotNullWhen(true)] out User? public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user) { - return users.TryGetValue(GUID, out user); + return guids.TryGetValue(GUID, out user); } From 47975427f2f8eba6c991d0911f7b2106f38802be Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:11:04 -0500 Subject: [PATCH 05/55] edit TryCreateUser() to output Task --- PMDDesktop.Server.Users.Tests/UserTests.cs | 35 ++++++++-------------- PMDDesktop.Server.Users/UserManager.cs | 8 ++--- 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs index 37db7d2..af52c85 100644 --- a/PMDDesktop.Server.Users.Tests/UserTests.cs +++ b/PMDDesktop.Server.Users.Tests/UserTests.cs @@ -15,11 +15,8 @@ public async Task PasswordVerification() { UserManager manager = new(); - Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS)); - - string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle; - Assert.NotNull(loginHandle); - Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user)); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); string? defaultPassword = DEFAULT_USER_OPTIONS.PlainTextPassword; Assert.NotNull(defaultPassword); @@ -35,14 +32,11 @@ public async Task NullPasswordVerification() { UserManager manager = new(); - Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS with + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS with { PlainTextPassword = null - })); - - string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle; - Assert.NotNull(loginHandle); - Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user)); + }); + Assert.NotNull(user); string? defaultPassword = DEFAULT_USER_OPTIONS.PlainTextPassword; Assert.NotNull(defaultPassword); @@ -57,11 +51,8 @@ public async Task AccessTokenGrantsAccess() { UserManager manager = new(); - Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS)); - - string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle; - Assert.NotNull(loginHandle); - Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user)); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); UserAccessToken token = await user.CreateAccessToken(); @@ -76,11 +67,12 @@ public async Task LoginHandleChanges() { UserManager manager = new(); - Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS)); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle; Assert.NotNull(loginHandle); - Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user)); + Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out _)); string newHandle = "new-handle"; @@ -99,11 +91,8 @@ public async Task GetViaGUID() { UserManager manager = new(); - Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS)); - - string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle; - Assert.NotNull(loginHandle); - Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user)); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); Guid guid = user.GUID; diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs index b3979e6..2294b04 100644 --- a/PMDDesktop.Server.Users/UserManager.cs +++ b/PMDDesktop.Server.Users/UserManager.cs @@ -110,22 +110,22 @@ public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User? } - public async Task TryCreateUser(UserCreationOptions options) + public async Task TryCreateUser(UserCreationOptions options) { User user = new() { Name = options.DisplayName }; - + if (options.LoginHandle is not null) if (!await user.TrySetLoginHandle(options.LoginHandle)) - return false; + return null; if (options.PlainTextPassword is not null) await user.SetPassword(options.PlainTextPassword); user.AttachToManager(this); - return true; + return user; } From 558c6ac76c45a1b957a0f6685242ad8f92d4c968 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:43:22 -0500 Subject: [PATCH 06/55] Add token limit tests. --- PMDDesktop.Server.Users.Tests/UserTests.cs | 50 ++++++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs index af52c85..5f717fb 100644 --- a/PMDDesktop.Server.Users.Tests/UserTests.cs +++ b/PMDDesktop.Server.Users.Tests/UserTests.cs @@ -3,7 +3,7 @@ public class UserTests { - public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new() + public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new() { DisplayName = "Test User", LoginHandle = "test", @@ -49,7 +49,7 @@ public async Task NullPasswordVerification() [Fact] public async Task AccessTokenGrantsAccess() { - + UserManager manager = new(); User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); Assert.NotNull(user); @@ -65,7 +65,7 @@ public async Task AccessTokenGrantsAccess() [Fact] public async Task LoginHandleChanges() { - + UserManager manager = new(); User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); Assert.NotNull(user); @@ -89,7 +89,7 @@ public async Task LoginHandleChanges() [Fact] public async Task GetViaGUID() { - + UserManager manager = new(); User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); Assert.NotNull(user); @@ -104,4 +104,46 @@ public async Task GetViaGUID() } + [Fact] + public async Task IsTokenLimitEnforced() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + for (int i = 0; i < User.ACCESS_TOKEN_LIMIT + 100; ++i) + { + await user.CreateAccessToken(); + } + + Assert.Equal(manager.accessTokens.Count, User.ACCESS_TOKEN_LIMIT); + + } + + [Fact] + public async Task AreOldestTokensRemovedOnLimit() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + for (int i = 0; i < User.ACCESS_TOKEN_LIMIT; ++i) + await user.CreateAccessToken(); + + Assert.Equal(manager.accessTokens.Count, User.ACCESS_TOKEN_LIMIT); + + List seenTokens = [.. manager.accessTokens.Values]; + + for (int i = 0; i < User.ACCESS_TOKEN_LIMIT; ++i) + await user.CreateAccessToken(); + + Assert.Equal(manager.accessTokens.Count, User.ACCESS_TOKEN_LIMIT); + + seenTokens.AddRange(manager.accessTokens.Values); + Assert.Distinct(seenTokens); + + } + } From be0615d7e44990b50602bdf03356a7181677eeba Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:24:49 -0500 Subject: [PATCH 07/55] Add User removal --- PMDDesktop.Server.Users.Tests/UserTests.cs | 29 +++++++ PMDDesktop.Server.Users/User.cs | 92 +++++++++++++++++++--- 2 files changed, 110 insertions(+), 11 deletions(-) diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs index 5f717fb..b865828 100644 --- a/PMDDesktop.Server.Users.Tests/UserTests.cs +++ b/PMDDesktop.Server.Users.Tests/UserTests.cs @@ -146,4 +146,33 @@ public async Task AreOldestTokensRemovedOnLimit() } + [Fact] + public async Task DeleteUserTest() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + await user.CreateAccessToken(); + + Assert.True(manager.TryGetUser(user.GUID, out _)); + + Assert.NotEmpty(manager.accessTokens); + Assert.NotEmpty(manager.guids); + Assert.NotEmpty(manager.loginHandles); + + Assert.True(user.IsAlive()); + + Assert.True(await user.TryRemoveUser()); + + Assert.False(manager.TryGetUser(user.GUID, out _)); + + Assert.Empty(manager.accessTokens); + Assert.Empty(manager.guids); + Assert.Empty(manager.loginHandles); + + Assert.False(user.IsAlive()); + + } + } diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index 48fa336..54710d9 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Identity; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace PMDDesktop.Server.Users; @@ -8,6 +9,11 @@ public sealed class User private static PasswordHasher PASSWORD_HASHER = new(); + /// + /// A list + /// + private static string[] DELETE_USER_FOLDER_ITEMS = ["user.json", "picture.jpg", "picture.png"]; + /// /// Limit of s assigned to one . /// @@ -52,11 +58,13 @@ internal User() public Guid GUID { get; internal set; } = Guid.NewGuid(); /// - /// + /// /// [JsonIgnore] public UserManager? Manager {get; private set;} + public bool? WritingEnabled {get; private set;} + private List accessTokens = []; /// @@ -72,13 +80,13 @@ public async Task SetPassword(string plainText) } /// - /// + /// /// /// /// public async Task TrySetLoginHandle(string? newHandle) { - + // Handle is not the same. if (newHandle == LoginHandle) return false; @@ -109,7 +117,7 @@ private void AttachLoginHandle() private void DetachLoginHandle() { - + if (LoginHandle is null) return; @@ -169,19 +177,57 @@ public string GetUserJSONPath() } - public async Task WriteNewData() + [ExcludeFromCodeCoverage] + internal async Task WriteNewData() { if (Manager is null) // ????? This state doesn't make any sense. throw new InvalidOperationException($"{this} shouldn't be writing its data without being attached to a {nameof(Manager)}."); - if (!Manager.WritingEnabled) + if (WritingEnabled is null) + throw new InvalidOperationException($"{this} shouldn't be deleting its data while {WritingEnabled} is null!"); + + if (WritingEnabled != true) return; throw new NotImplementedException(); } + /// + /// Deletes the user folder and all *explicitly written* data inside it. + /// + /// + /// + /// This function does not recursive delete. Instead it has a list of items to delete. This approach is taken to minimize data loss in the event that something goes wrong. + /// + [ExcludeFromCodeCoverage] + private async Task DeleteUserFolder() + { + + if (WritingEnabled is null) + throw new InvalidOperationException($"{this} shouldn't be deleting its data while {WritingEnabled} is null!"); + + if (WritingEnabled != true) + return; + + foreach (string item in DELETE_USER_FOLDER_ITEMS) + { + + string path = Path.Join(GetUserFolder(), item); + + if (File.Exists(path)) + File.Delete(path); + + } + + if (Directory.GetFileSystemEntries(GetUserFolder()).Length == 0) + Directory.Delete(GetUserFolder(), false); + else + Console.WriteLine($"CANNOT DELETE ALL OF {this}'s user folder!!!"); + + } + public override bool Equals(object? obj) { @@ -219,7 +265,7 @@ public async Task CreateAccessToken() private void AttachAccessToken(UserAccessToken token) { - + // if (accessTokens.Any(existing => existing.TokenString == token.TokenString)) // throw new Exception($"The token: {token.TokenString} is already in {this}'s {nameof(accessTokens)}."); @@ -250,7 +296,7 @@ private async Task TryRevokeOldestAccessToken() foreach (UserAccessToken token in accessTokens) { - + oldest ??= token; if (oldest.ExpiryDate > token.ExpiryDate) @@ -268,11 +314,12 @@ private async Task TryRevokeOldestAccessToken() internal void AttachToManager(UserManager manager) { - + Manager = manager; + WritingEnabled = manager.WritingEnabled; Manager.guids.Add(GUID, this); - + AttachLoginHandle(); foreach (UserAccessToken token in accessTokens) @@ -288,7 +335,7 @@ internal void DetachFromManager() if (Manager.guids[GUID] != this) throw new InvalidOperationException($"{this} was not inside {Manager.guids} with key {GUID}."); - + Manager.guids.Remove(GUID); DetachLoginHandle(); @@ -300,4 +347,27 @@ internal void DetachFromManager() } + public bool IsAlive() => Manager is not null; + + /// + /// Try to remove this from its . + /// If the assigned has , then this function will also attempt to delete the user's folder from the filesystem, so that it will not be restored in future sessions. + /// + /// + /// If is enabled, this function may still throw if an IO-related error occurs. + /// + public async Task TryRemoveUser() + { + + if (Manager is null) + return false; + + DetachFromManager(); + + await DeleteUserFolder(); + + return true; + + } + } From 55ecf969e1eb17db8d7b923d406cdc1673682c9a Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:53:01 -0500 Subject: [PATCH 08/55] Add UserReferenceTests --- .../PMDDesktop.Server.Users.Tests.csproj | 1 + .../UserReferenceTests.cs | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 PMDDesktop.Server.Users.Tests/UserReferenceTests.cs diff --git a/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj b/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj index c988a3a..a233e0d 100644 --- a/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj +++ b/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs b/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs new file mode 100644 index 0000000..ac6c533 --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.ApiExplorer; + +namespace PMDDesktop.Server.Users.Tests; + +public class UserReferenceTests +{ + + public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new() + { + DisplayName = "Test User", + LoginHandle = "test", + PlainTextPassword = "Test123" + }; + + [Fact] + public async Task CanGetReference() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + UserReference reference = new(user); + + reference.TryGetUser(manager, out User? referUser); + + Assert.NotNull(referUser); + Assert.Equal(referUser, user); + + } + + [Fact] + public async Task SerializationIsSimple() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + UserReference preReference = new(user); + + JsonElement json = JsonSerializer.SerializeToElement(preReference, AppInfo.JSON_OPTIONS); + + Assert.Equal(JsonValueKind.String, json.ValueKind); + + UserReference? postReference = JsonSerializer.Deserialize(json); + + Assert.NotNull(postReference); + + postReference.TryGetUser(manager, out User? referUser); + + Assert.NotNull(referUser); + Assert.Equal(referUser, user); + + } + +} From 77e69d8dcb08e0ad53571bd1dc77ceb4ba4a3c7a Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:03:12 -0500 Subject: [PATCH 09/55] remove random unused import??? --- PMDDesktop.Server.Users.Tests/UserReferenceTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs b/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs index ac6c533..f534ddc 100644 --- a/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs +++ b/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using Microsoft.AspNetCore.Mvc.ApiExplorer; namespace PMDDesktop.Server.Users.Tests; From 66badd1aed2e48408ea2f9254054a32493f54142 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:04:43 -0500 Subject: [PATCH 10/55] remove GetUser in IUserIndexable --- PMDDesktop.Server.Users/IUserIndexable.cs | 2 -- PMDDesktop.Server.Users/UserManager.cs | 9 --------- PMDDesktop.Server.Users/UserReference.cs | 8 -------- 3 files changed, 19 deletions(-) diff --git a/PMDDesktop.Server.Users/IUserIndexable.cs b/PMDDesktop.Server.Users/IUserIndexable.cs index 1a8b6fb..78474b5 100644 --- a/PMDDesktop.Server.Users/IUserIndexable.cs +++ b/PMDDesktop.Server.Users/IUserIndexable.cs @@ -8,8 +8,6 @@ namespace PMDDesktop.Server.Users; public interface IUserIndexable { - User? GetUser(Guid GUID); - bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user); } diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs index 2294b04..d09097a 100644 --- a/PMDDesktop.Server.Users/UserManager.cs +++ b/PMDDesktop.Server.Users/UserManager.cs @@ -87,15 +87,6 @@ public bool TryUseAccessToken(string tokenString, [NotNullWhen(true)] out User? } - public User? GetUser(Guid GUID) - { - - TryGetUser(GUID, out User? user); - - return user; - - } - public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user) { diff --git a/PMDDesktop.Server.Users/UserReference.cs b/PMDDesktop.Server.Users/UserReference.cs index 51b9ad7..86011b9 100644 --- a/PMDDesktop.Server.Users/UserReference.cs +++ b/PMDDesktop.Server.Users/UserReference.cs @@ -26,14 +26,6 @@ internal UserReference(Guid directGuid) } - public User? GetUser(IUserIndexable indexable) - { - - return indexable.GetUser(GUID); - - } - - public bool TryGetUser(IUserIndexable indexable, [NotNullWhen(true)] out User? user) { From 1f686c01e13f7c2b997c91cf635f53cf0f093942 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:05:56 -0500 Subject: [PATCH 11/55] Add IUserIndexable to GameServer and GameState --- PMDDesktop.Server/Game/GameServer.cs | 7 ++++++- PMDDesktop.Server/Game/GameState.cs | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/PMDDesktop.Server/Game/GameServer.cs b/PMDDesktop.Server/Game/GameServer.cs index 0a755f7..5e29772 100644 --- a/PMDDesktop.Server/Game/GameServer.cs +++ b/PMDDesktop.Server/Game/GameServer.cs @@ -1,10 +1,11 @@ using PMDDesktop.Server.Assets; using PMDDesktop.Server.Saving; +using PMDDesktop.Server.Users; using System.Diagnostics.CodeAnalysis; namespace PMDDesktop.Server.Game; -public class GameServer : IAssetIndexable, ISaveDataIndexable +public class GameServer : IAssetIndexable, ISaveDataIndexable, IUserIndexable { public GameServer() @@ -74,4 +75,8 @@ public bool TryGetSave(Guid GUID, [NotNullWhen(true)] out T? data) where T : return State.TryGetSave(GUID, out data); } + public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user) + { + return State.TryGetUser(GUID, out user); + } } diff --git a/PMDDesktop.Server/Game/GameState.cs b/PMDDesktop.Server/Game/GameState.cs index 8dc1af0..d8c80b8 100644 --- a/PMDDesktop.Server/Game/GameState.cs +++ b/PMDDesktop.Server/Game/GameState.cs @@ -5,7 +5,7 @@ namespace PMDDesktop.Server.Game; -public class GameState : IAssetIndexable, ISaveDataIndexable +public class GameState : IAssetIndexable, ISaveDataIndexable, IUserIndexable { [ExcludeFromCodeCoverage] @@ -49,6 +49,11 @@ public bool TryGetSave(Guid GUID, [NotNullWhen(true)] out T? data) where T : return Saves.TryGetSave(GUID, out data); } + public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user) + { + return Users.TryGetUser(GUID, out user); + } + public SaveDataManager Saves { get; } = new(); public AssetManager Assets { get; } = new(); public UserManager Users { get; } = new(); From a3068a9643aff321439488670189689eb645c77e Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:56:51 -0500 Subject: [PATCH 12/55] read Authorization header on request --- .../Auth/LoginRequestHandler.cs | 2 +- .../RequestHandlers/IRequestHandler.cs | 20 ++++++++++++++++++- .../RequestHandlers/JsonRequestHandler.cs | 10 +++++++--- .../ListSpeciesRequestHandler.cs | 3 ++- .../ListTypesRequestHandler.cs | 3 ++- 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs index e706195..0d3c6d4 100644 --- a/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs @@ -7,7 +7,7 @@ namespace PMDDesktop.Server.RequestHandlers.Auth; public class LoginRequestHandler : JsonRequestHandler { - protected override async Task CreateResponse(LoginRequest request, GameServer game) + protected override async Task CreateResponse(LoginRequest request, GameServer game, User? requestingUser) { if (!game.State.Users.TryGetUserByLoginHandle(request.Handle, out User? user)) diff --git a/PMDDesktop.Server/RequestHandlers/IRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/IRequestHandler.cs index 5c92853..d715491 100644 --- a/PMDDesktop.Server/RequestHandlers/IRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/IRequestHandler.cs @@ -1,6 +1,8 @@ -using PMDDesktop.Exceptions; +using System.Diagnostics.CodeAnalysis; +using PMDDesktop.Exceptions; using PMDDesktop.Requests; using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; using PMDDesktop.Utils; namespace PMDDesktop.Server.RequestHandlers; @@ -44,6 +46,22 @@ static void AddApiPoints(GameServer server) } + static bool TryGetUserFromContext(UserManager manager, HttpContext context, [NotNullWhen(true)] out User? user, [NotNullWhen(true)] out UserAccessToken? accessToken) + { + + string? token = context.Request.Headers.Authorization; + + if (token is null) + { + user = null; + accessToken = null; + return false; + } + + return manager.TryUseAccessToken(token, out user, out accessToken); + + } + /// /// Process the full request as needed. /// diff --git a/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs index 7ae343f..ee6cecf 100644 --- a/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs @@ -1,5 +1,6 @@ using PMDDesktop.Requests; using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; using PMDDesktop.Utils; using System.Reflection; using System.Text; @@ -158,15 +159,18 @@ public async Task HandleRequest(HttpContext context, GameServer game) } + if (IRequestHandler.TryGetUserFromContext(game.State.Users, context, out User? user, out UserAccessToken? token)) + await token.RefreshToken(); + try { context.Response.StatusCode = 200; context.Response.ContentType = "application/json"; - await context.Response.WriteAsJsonAsync(await CreateResponse(deserialized, game), AppInfo.NETWORK_JSON_OPTIONS); + await context.Response.WriteAsJsonAsync(await CreateResponse(deserialized, game, user), AppInfo.NETWORK_JSON_OPTIONS); } - catch (UserRequestException e) // Intended for user-facing errors, like bad inputs, + catch (UserRequestException e) // Intended for user-facing errors, like bad inputs, { await WritePlainText(context, 400, e.Message); @@ -200,6 +204,6 @@ private static async Task WritePlainText(HttpContext context, int code, string e } - protected abstract Task CreateResponse(TReq request, GameServer game); + protected abstract Task CreateResponse(TReq request, GameServer game, User? requestingUser); } diff --git a/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs index 4e1cc57..70ce47a 100644 --- a/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs @@ -1,13 +1,14 @@ using PMDDesktop.Requests; using PMDDesktop.Server.Assets.Data; using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; namespace PMDDesktop.Server.RequestHandlers; public class ListSpeciesRequestHandler : JsonRequestHandler { - protected override async Task CreateResponse(ListSpeciesRequest request, GameServer game) + protected override async Task CreateResponse(ListSpeciesRequest request, GameServer game, User? requestingUser) { return new() diff --git a/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs index 6b43b34..56092e4 100644 --- a/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs @@ -1,13 +1,14 @@ using PMDDesktop.Requests; using PMDDesktop.Server.Assets.Data; using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; namespace PMDDesktop.Server.RequestHandlers; public class ListTypesRequestHandler : JsonRequestHandler { - protected override async Task CreateResponse(ListTypesRequest request, GameServer game) + protected override async Task CreateResponse(ListTypesRequest request, GameServer game, User? requestingUser) { return new() From 754330817aa107112083eebf35c52b49c57bb6b0 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:57:17 -0500 Subject: [PATCH 13/55] Add "auth/who-am-i" endpoint. --- .../Auth/WhoAmIRequestHandler.cs | 23 +++++++++++++++++++ PMDDesktop/Requests/Auth/WhoAmIRequest.cs | 9 ++++++++ PMDDesktop/Requests/Auth/WhoAmIResponse.cs | 8 +++++++ 3 files changed, 40 insertions(+) create mode 100644 PMDDesktop.Server/RequestHandlers/Auth/WhoAmIRequestHandler.cs create mode 100644 PMDDesktop/Requests/Auth/WhoAmIRequest.cs create mode 100644 PMDDesktop/Requests/Auth/WhoAmIResponse.cs diff --git a/PMDDesktop.Server/RequestHandlers/Auth/WhoAmIRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Auth/WhoAmIRequestHandler.cs new file mode 100644 index 0000000..5457b15 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Auth/WhoAmIRequestHandler.cs @@ -0,0 +1,23 @@ +using PMDDesktop.Requests.Auth; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; + +namespace PMDDesktop.Server.RequestHandlers.Auth; + +public class WhoAmIRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(WhoAmIRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"Please send your valid token in the Authorization header to access this endpoint."); + + return new() + { + GUID = requestingUser.GUID + }; + + } + +} diff --git a/PMDDesktop/Requests/Auth/WhoAmIRequest.cs b/PMDDesktop/Requests/Auth/WhoAmIRequest.cs new file mode 100644 index 0000000..dc72dc6 --- /dev/null +++ b/PMDDesktop/Requests/Auth/WhoAmIRequest.cs @@ -0,0 +1,9 @@ +namespace PMDDesktop.Requests.Auth; + +[ServerRequest("/api/auth/who-am-i")] +public sealed class WhoAmIRequest : ServerRequest +{ + + // Expecting Authorization header for this request. + +} diff --git a/PMDDesktop/Requests/Auth/WhoAmIResponse.cs b/PMDDesktop/Requests/Auth/WhoAmIResponse.cs new file mode 100644 index 0000000..afa4623 --- /dev/null +++ b/PMDDesktop/Requests/Auth/WhoAmIResponse.cs @@ -0,0 +1,8 @@ +namespace PMDDesktop.Requests.Auth; + +public sealed class WhoAmIResponse : ServerResponse +{ + + public required Guid GUID {get; set;} + +} From 191b3754af9c10218b8bca315167b03dd85fa9be Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:28:02 -0500 Subject: [PATCH 14/55] Add api/users/get-info endpoint --- .../Users/GetUserInfoRequestHandler.cs | 24 +++++++++++++++++++ .../Requests/Users/GetUserInfoRequest.cs | 10 ++++++++ .../Requests/Users/GetUserInfoResponse.cs | 9 +++++++ 3 files changed, 43 insertions(+) create mode 100644 PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs create mode 100644 PMDDesktop/Requests/Users/GetUserInfoRequest.cs create mode 100644 PMDDesktop/Requests/Users/GetUserInfoResponse.cs diff --git a/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs new file mode 100644 index 0000000..b2ccf27 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs @@ -0,0 +1,24 @@ +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; + +namespace PMDDesktop.Server.RequestHandlers.Users; + +public class GetUserInfoRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(GetUserInfoRequest request, GameServer game, User? requestingUser) + { + + if (!game.TryGetUser(request.GUID, out User? user)) + throw new UserRequestException($"{request.GUID} does not match a user."); + + return new() + { + DisplayName = user.Name, + GUID = user.GUID + }; + + } + +} diff --git a/PMDDesktop/Requests/Users/GetUserInfoRequest.cs b/PMDDesktop/Requests/Users/GetUserInfoRequest.cs new file mode 100644 index 0000000..78d431d --- /dev/null +++ b/PMDDesktop/Requests/Users/GetUserInfoRequest.cs @@ -0,0 +1,10 @@ +namespace PMDDesktop.Requests.Users; + +[ServerRequest("/api/users/get-info")] +public sealed class GetUserInfoRequest : ServerRequest +{ + + [QueryParameter] + public required Guid GUID { get; set; } + +} diff --git a/PMDDesktop/Requests/Users/GetUserInfoResponse.cs b/PMDDesktop/Requests/Users/GetUserInfoResponse.cs new file mode 100644 index 0000000..7583ea9 --- /dev/null +++ b/PMDDesktop/Requests/Users/GetUserInfoResponse.cs @@ -0,0 +1,9 @@ +namespace PMDDesktop.Requests.Users; + +public sealed class GetUserInfoResponse : ServerResponse +{ + + public required string DisplayName { get; set; } + public required Guid GUID { get; set; } + +} From 307495499128729f991ce4e503f1c885d847ebc8 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:37:23 -0500 Subject: [PATCH 15/55] Add User saving and loading. --- .../PMDDesktop.Server.Users.csproj | 6 +- PMDDesktop.Server.Users/User.cs | 63 +++++++--- PMDDesktop.Server.Users/UserConverter.cs | 114 ++++++++++++++++++ PMDDesktop.Server.Users/UserManager.cs | 82 +++++++++++++ PMDDesktop.Server/Game/GameState.cs | 1 + 5 files changed, 251 insertions(+), 15 deletions(-) create mode 100644 PMDDesktop.Server.Users/UserConverter.cs diff --git a/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj b/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj index b93788b..1e2e7de 100644 --- a/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj +++ b/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj @@ -8,7 +8,11 @@ - + + + + + diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index 54710d9..b5cc339 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -1,18 +1,25 @@ using Microsoft.AspNetCore.Identity; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Text.Json.Serialization; namespace PMDDesktop.Server.Users; +[JsonConverter(typeof(UserConverter))] public sealed class User { private static PasswordHasher PASSWORD_HASHER = new(); /// - /// A list + /// The name of the file that data should be serialized to. /// - private static string[] DELETE_USER_FOLDER_ITEMS = ["user.json", "picture.jpg", "picture.png"]; + public static readonly string USER_FILE_NAME = "user.json"; + + /// + /// A list of items to delete when deleting a . + /// + private static string[] DELETE_USER_FOLDER_ITEMS = [USER_FILE_NAME, "picture.jpg", "picture.png"]; /// /// Limit of s assigned to one . @@ -41,14 +48,14 @@ internal User() /// Should be unique. /// [JsonInclude] - public string? LoginHandle { get; private set; } + public string? LoginHandle { get; internal set; } /// /// Hashed password used to log in. /// In the event that it is null, access to this account cannot be granted via password. /// [JsonInclude] - private string? HashedPassword { get; set; } + internal string? HashedPassword { get; set; } /// /// A unique for each . Stays consistant even when the 's handle changes. @@ -61,11 +68,21 @@ internal User() /// /// [JsonIgnore] - public UserManager? Manager {get; private set;} + public UserManager? Manager { get; private set; } + + public bool? WritingEnabled { get; private set; } + + internal List accessTokens = []; + + public async Task SetName(string newName) + { + + Name = newName; - public bool? WritingEnabled {get; private set;} + if (Manager is not null) + await WriteNewData(); - private List accessTokens = []; + } /// /// Salt and hash the user's password, then store it in HashedPassword. @@ -77,6 +94,9 @@ public async Task SetPassword(string plainText) HashedPassword = PASSWORD_HASHER.HashPassword(this, plainText); + if (Manager is not null) + await WriteNewData(); + } /// @@ -101,6 +121,9 @@ public async Task TrySetLoginHandle(string? newHandle) AttachLoginHandle(); + if (Manager is not null) + await WriteNewData(); + return true; } @@ -173,7 +196,7 @@ internal string GetUserFolder() public string GetUserJSONPath() { - return Path.Combine(GetUserFolder(), "user.json"); + return Path.Combine(GetUserFolder(), USER_FILE_NAME); } @@ -187,10 +210,16 @@ internal async Task WriteNewData() if (WritingEnabled is null) throw new InvalidOperationException($"{this} shouldn't be deleting its data while {WritingEnabled} is null!"); + foreach (UserAccessToken token in accessTokens) + if (DateTime.UtcNow >= token.ExpiryDate) + await RevokeAccessToken(token, false); + if (WritingEnabled != true) return; - throw new NotImplementedException(); + using FileStream jsonFile = File.OpenWrite(GetUserJSONPath()); + + await JsonSerializer.SerializeAsync(jsonFile, this, AppInfo.JSON_OPTIONS); } @@ -251,7 +280,7 @@ public async Task CreateAccessToken() { while (accessTokens.Count >= ACCESS_TOKEN_LIMIT) - await TryRevokeOldestAccessToken(); + await TryRevokeOldestAccessToken(false); UserAccessToken token = UserAccessToken.CreateNewToken(this); @@ -259,6 +288,9 @@ public async Task CreateAccessToken() AttachAccessToken(token); + if (Manager is not null) + await WriteNewData(); + return token; } @@ -273,13 +305,16 @@ private void AttachAccessToken(UserAccessToken token) } - public async Task RevokeAccessToken(UserAccessToken token) + public async Task RevokeAccessToken(UserAccessToken token, bool writeAfterwards = true) { accessTokens.Remove(token); DetachAccessToken(token); + if (Manager is not null && writeAfterwards) + await WriteNewData(); + } private void DetachAccessToken(UserAccessToken token) @@ -289,7 +324,7 @@ private void DetachAccessToken(UserAccessToken token) } - private async Task TryRevokeOldestAccessToken() + private async Task TryRevokeOldestAccessToken(bool writeAfterwards = true) { UserAccessToken? oldest = null; @@ -307,7 +342,7 @@ private async Task TryRevokeOldestAccessToken() if (oldest is null) return false; - await RevokeAccessToken(oldest); + await RevokeAccessToken(oldest, writeAfterwards); return true; } @@ -330,7 +365,7 @@ internal void AttachToManager(UserManager manager) internal void DetachFromManager() { - if (Manager==null) + if (Manager == null) throw new NullReferenceException($"{nameof(Manager)} is null. Cannot detach from nothing. Does this call to {nameof(DetachFromManager)} need to be skipped?"); if (Manager.guids[GUID] != this) diff --git a/PMDDesktop.Server.Users/UserConverter.cs b/PMDDesktop.Server.Users/UserConverter.cs new file mode 100644 index 0000000..ae021f0 --- /dev/null +++ b/PMDDesktop.Server.Users/UserConverter.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users; + + +internal class UserConverter : JsonConverter +{ + + public static readonly string TOKENS_PROPERTY_NAME = "Tokens"; + public static readonly string CONFIG_PROPERTY_NAME = "Config"; + public static readonly string TOKEN_STRING_PROPERTY_NAME = "String"; + public static readonly string TOKEN_EXPIRE_PROPERTY_NAME = "ExpiryDate"; + + public override User Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + + using JsonDocument json = JsonDocument.ParseValue(ref reader); + + List propertyList = [.. json.RootElement.EnumerateObject().Select(element => element.Name)]; + + User user = new() + { + Name = QuickReadString(json, propertyList, nameof(User.Name)), + HashedPassword = QuickReadNullableString(json, propertyList, nameof(User.HashedPassword)), + LoginHandle = QuickReadNullableString(json, propertyList, nameof(User.LoginHandle)) + }; + + propertyList.Remove(TOKENS_PROPERTY_NAME); + user.accessTokens = ReadTokenArray(json, user); + + if (propertyList.Count > 0) + throw new JsonException($"The user json had left-over properties after parsing, such as {propertyList[0]}. These unused properties cannot be accounted for, and may result in data loss. Is it from a newer version?"); + + if (user.IsAlive()) + throw new InvalidOperationException($"{user} should not be attached to a manager at this point, or otherwise \"alive\"."); + + return user; + + } + + public override void Write(Utf8JsonWriter writer, User value, JsonSerializerOptions options) + { + + writer.WriteStartObject(); + + writer.WriteString(nameof(User.Name), value.Name); + writer.WriteString(nameof(User.HashedPassword), value.HashedPassword); + writer.WriteString(nameof(User.LoginHandle), value.LoginHandle); + + writer.WriteStartArray(TOKENS_PROPERTY_NAME); + foreach (UserAccessToken token in value.accessTokens) + WriteToken(writer, token); + writer.WriteEndArray(); + + writer.WriteEndObject(); + + } + + private static void WriteToken(Utf8JsonWriter writer, UserAccessToken token) + { + + writer.WriteStartObject(); + + writer.WriteString(TOKEN_STRING_PROPERTY_NAME, token.TokenString); + writer.WriteString(TOKEN_EXPIRE_PROPERTY_NAME, token.ExpiryDate); + + writer.WriteEndObject(); + + } + + private static string QuickReadString(JsonDocument document, List propertyList, string name) + { + + propertyList.Remove(name); + + return document.RootElement.GetProperty(name).GetString() + ?? throw new JsonException($"{name} should not be null in {document}"); + + } + + private static string? QuickReadNullableString(JsonDocument document, List propertyList, string name) + { + + propertyList.Remove(name); + + return document.RootElement.GetProperty(name).GetString(); + + } + + private static List ReadTokenArray(JsonDocument document, User user) + { + + List tokens = []; + + JsonElement tokenJsonArray = document.RootElement.GetProperty(TOKENS_PROPERTY_NAME); + + foreach (JsonElement token in tokenJsonArray.EnumerateArray()) + { + + string tokenString = token.GetProperty(TOKEN_STRING_PROPERTY_NAME).GetString() + ?? throw new JsonException($"{TOKEN_STRING_PROPERTY_NAME} should not be null in {token}"); + + DateTime expiryDate = token.GetProperty(TOKEN_EXPIRE_PROPERTY_NAME).GetDateTime(); + + tokens.Add(new(tokenString, user, expiryDate)); + + } + + return tokens; + + } + +} diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs index d09097a..b9421fa 100644 --- a/PMDDesktop.Server.Users/UserManager.cs +++ b/PMDDesktop.Server.Users/UserManager.cs @@ -1,5 +1,6 @@ using System.Collections; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; namespace PMDDesktop.Server.Users; @@ -39,6 +40,87 @@ public sealed class UserManager() : IEnumerable, IUserIndexable /// internal Dictionary accessTokens = []; + /// + /// Loads from the 'users' and 'roles' directories, and enables . + /// + /// + /// This can throw under many, many circumstances. If it does, cancel all operations and make sure the error is conveyed to the user. + /// + [ExcludeFromCodeCoverage] + public async Task LoadFromFilesAndEnableWriting() + { + + // Since Users check the WritingEnabled value, we need to set this before loading. + WritingEnabled = true; + + await LoadAllUserData(); + + await LoadAllRoleData(); + + } + + /// + /// This manages creating objects by loading their data from the "users" folder. + /// + [ExcludeFromCodeCoverage] + private async Task LoadAllUserData() + { + + string userRootDirPath = Path.Combine(AppContext.BaseDirectory, "users"); + + if (!Directory.Exists(userRootDirPath)) + Directory.CreateDirectory(userRootDirPath); + + foreach (string subDirPath in Directory.EnumerateDirectories(userRootDirPath)) + { + + string guidParsable = Path.GetFileName(subDirPath); + + // This is not okay. All user folders should have GUIDs are their names. + if (!Guid.TryParse(guidParsable, out Guid loadedGUID)) + throw new Exception($"Couldn't parse {guidParsable} as a GUID at {subDirPath}"); + + string userFilePath = Path.Join(subDirPath, User.USER_FILE_NAME); + + using FileStream readStream = File.OpenRead(userFilePath); + + await LoadAndAddUserJson(readStream, loadedGUID); + + } + + } + + /// + /// Internal function for loading a JSON stream and adding the resulting to this . + /// + /// A JSON stream with data inside. + /// + /// + /// This can be safely unit tested, as it takes in a stream instead of reading from a file. + /// + internal async Task LoadAndAddUserJson(Stream stream, Guid guid) + { + + User deserialized = JsonSerializer.Deserialize(stream, AppInfo.JSON_OPTIONS) + ?? throw new Exception($"Deserialized save data from {stream} was null."); + + deserialized.GUID = guid; // GUID will be randomized by default, we need to load the previous GUID. + + deserialized.AttachToManager(this); + + } + + /// + /// This manages creating objects by loading their data from the "users" folder. + /// + [ExcludeFromCodeCoverage] + private async Task LoadAllRoleData() + { + + // Not yet implemented. + + } + public IEnumerator GetEnumerator() { return guids.Values.GetEnumerator(); diff --git a/PMDDesktop.Server/Game/GameState.cs b/PMDDesktop.Server/Game/GameState.cs index d8c80b8..5212ecb 100644 --- a/PMDDesktop.Server/Game/GameState.cs +++ b/PMDDesktop.Server/Game/GameState.cs @@ -26,6 +26,7 @@ public async Task LoadAllFiles() await Saves.LoadFromFilesAndEnableWriting(); await Assets.LoadFromFiles(); + await Users.LoadFromFilesAndEnableWriting(); } From 972ee794adb15f45b1c77f74afa2cdad48747bca Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:50:44 -0500 Subject: [PATCH 16/55] Fix written file not being truncated. --- PMDDesktop.Server.Users/User.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index b5cc339..0cd8f6a 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -217,7 +217,7 @@ internal async Task WriteNewData() if (WritingEnabled != true) return; - using FileStream jsonFile = File.OpenWrite(GetUserJSONPath()); + using FileStream jsonFile = File.Create(GetUserJSONPath()); await JsonSerializer.SerializeAsync(jsonFile, this, AppInfo.JSON_OPTIONS); From 85a6329b43821b4ca53d55a79f219408161fe142 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:14:07 -0500 Subject: [PATCH 17/55] Add UserConverterTests and return User in LoadAndAddUserJson --- .../UserConverterTests.cs | 80 +++++++++++++++++++ PMDDesktop.Server.Users/UserManager.cs | 4 +- 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 PMDDesktop.Server.Users.Tests/UserConverterTests.cs diff --git a/PMDDesktop.Server.Users.Tests/UserConverterTests.cs b/PMDDesktop.Server.Users.Tests/UserConverterTests.cs new file mode 100644 index 0000000..2643302 --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/UserConverterTests.cs @@ -0,0 +1,80 @@ +using System.Text.Json; + +namespace PMDDesktop.Server.Users.Tests; + +public class UserConverterTests +{ + + public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new() + { + DisplayName = "Test User", + LoginHandle = "test", + PlainTextPassword = "Test123" + }; + + [Fact] + public async Task ToAndFromJsonKeepsName() + { + + UserManager manager1 = new(); + User? user1 = await manager1.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user1); + + using MemoryStream jsonStream = new(); + + await JsonSerializer.SerializeAsync(jsonStream, user1, AppInfo.JSON_OPTIONS); + + jsonStream.Position = 0; // Rewind the stream so it can be read. + + UserManager manager2 = new(); + User user2 = await manager2.LoadAndAddUserJson(jsonStream, user1.GUID); + + Assert.Equal(user1.GUID, user2.GUID); + Assert.Equal(user1.Name, user2.Name); + Assert.Equal(user1.HashedPassword, user2.HashedPassword); + Assert.Equal(user1.LoginHandle, user2.LoginHandle); + + } + + [Fact] + public async Task ToAndFromJsonReattachesTokens() + { + + UserManager manager1 = new(); + User? user1 = await manager1.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user1); + + for (int i = 0; i < 100; ++i) // Creating an excess so we make sure that discarded tokens are also treated correctly. + await user1.CreateAccessToken(); + + Assert.NotEmpty(manager1.accessTokens); + Assert.NotEmpty(user1.accessTokens); + + using MemoryStream jsonStream = new(); + + await JsonSerializer.SerializeAsync(jsonStream, user1, AppInfo.JSON_OPTIONS); + + jsonStream.Position = 0; // Rewind the stream so it can be read. + + UserManager manager2 = new(); + User user2 = await manager2.LoadAndAddUserJson(jsonStream, user1.GUID); + + Assert.NotEmpty(manager2.accessTokens); + Assert.NotEmpty(user2.accessTokens); + + Assert.All(manager1.accessTokens.Values, originalToken => + { + + Assert.True(manager2.TryUseAccessToken(originalToken.TokenString, out User? user, out UserAccessToken? token)); + + Assert.Equal(user2, user); + Assert.Equal(originalToken.ExpiryDate, token.ExpiryDate); + Assert.Equal(originalToken.TokenString, token.TokenString); + + }); + + Assert.Equal(manager1.accessTokens.Count, manager2.accessTokens.Count); + + } + +} diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs index b9421fa..a3368fd 100644 --- a/PMDDesktop.Server.Users/UserManager.cs +++ b/PMDDesktop.Server.Users/UserManager.cs @@ -98,7 +98,7 @@ private async Task LoadAllUserData() /// /// This can be safely unit tested, as it takes in a stream instead of reading from a file. /// - internal async Task LoadAndAddUserJson(Stream stream, Guid guid) + internal async Task LoadAndAddUserJson(Stream stream, Guid guid) { User deserialized = JsonSerializer.Deserialize(stream, AppInfo.JSON_OPTIONS) @@ -108,6 +108,8 @@ internal async Task LoadAndAddUserJson(Stream stream, Guid guid) deserialized.AttachToManager(this); + return deserialized; + } /// From df103c8f0a52f5c1ccf244bd9cf4453be27843fe Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:18:19 -0500 Subject: [PATCH 18/55] Add UserRole and UserPermission with tests --- .../Roles/UserPermissionTests.cs | 18 +++ .../Roles/UserRoleConverterTests.cs | 126 ++++++++++++++++++ .../Roles/UserRoleTests.cs | 33 +++++ .../Roles/UserPermission.cs | 87 ++++++++++++ PMDDesktop.Server.Users/Roles/UserRole.cs | 35 +++++ .../Roles/UserRoleConverter.cs | 105 +++++++++++++++ 6 files changed, 404 insertions(+) create mode 100644 PMDDesktop.Server.Users.Tests/Roles/UserPermissionTests.cs create mode 100644 PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs create mode 100644 PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs create mode 100644 PMDDesktop.Server.Users/Roles/UserPermission.cs create mode 100644 PMDDesktop.Server.Users/Roles/UserRole.cs create mode 100644 PMDDesktop.Server.Users/Roles/UserRoleConverter.cs diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserPermissionTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserPermissionTests.cs new file mode 100644 index 0000000..19aa2d3 --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/Roles/UserPermissionTests.cs @@ -0,0 +1,18 @@ +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.Users.Tests.Roles; + +public class UserPermissionTests +{ + + [Theory] + [InlineData("manage-users")] + [InlineData("manage-roles")] + public void UserPermissionAllIncludesPermissions(string dataName) + { + + Assert.Contains(UserPermission.ALL, permission => permission.DataName == dataName); + + } + +} diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs new file mode 100644 index 0000000..a7bd7df --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs @@ -0,0 +1,126 @@ +using System.Text.Json; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.Users.Tests.Roles; + +public class UserRoleConverterTests +{ + + [Fact] + public async Task RoleJsonContainsNullsForUnspecified() + { + + UserRole role = new(); + + await role.SetPermission(UserPermission.MANAGE_USERS, true); + + JsonDocument document = JsonSerializer.SerializeToDocument(role); + + JsonElement manageRolesElement = document.RootElement.GetProperty(UserRoleConverter.PERMISSIONS_PROPERTY).GetProperty(UserPermission.MANAGE_ROLES.DataName); + + Assert.Equal(JsonValueKind.Null, manageRolesElement.ValueKind); + + } + + [Fact] + public async Task RoleJsonContainsTrueWhenSpecified() + { + + UserRole role = new(); + + await role.SetPermission(UserPermission.MANAGE_USERS, true); + + JsonDocument document = JsonSerializer.SerializeToDocument(role); + + JsonElement manageRolesElement = document.RootElement.GetProperty(UserRoleConverter.PERMISSIONS_PROPERTY).GetProperty(UserPermission.MANAGE_USERS.DataName); + + Assert.Equal(JsonValueKind.True, manageRolesElement.ValueKind); + + } + + [Fact] + public async Task RoleJsonContainsFalseWhenSpecified() + { + + UserRole role = new(); + + await role.SetPermission(UserPermission.MANAGE_USERS, false); + + JsonDocument document = JsonSerializer.SerializeToDocument(role); + + JsonElement manageRolesElement = document.RootElement.GetProperty(UserRoleConverter.PERMISSIONS_PROPERTY).GetProperty(UserPermission.MANAGE_USERS.DataName); + + Assert.Equal(JsonValueKind.False, manageRolesElement.ValueKind); + + } + + [Fact] + public async Task RoleJsonWithSpecifiedPermissionThenUnspecifiedContainsNull() + { + + UserRole role = new(); + + await role.SetPermission(UserPermission.MANAGE_USERS, true); + await role.SetPermission(UserPermission.MANAGE_USERS, null); + + JsonDocument document = JsonSerializer.SerializeToDocument(role); + + JsonElement manageRolesElement = document.RootElement.GetProperty(UserRoleConverter.PERMISSIONS_PROPERTY).GetProperty(UserPermission.MANAGE_USERS.DataName); + + Assert.Equal(JsonValueKind.Null, manageRolesElement.ValueKind); + + } + + [Fact] + public async Task RoundTripRoleWithSpecifiedPermissionKeepsTrue() + { + + UserRole role = new(); + + await role.SetPermission(UserPermission.MANAGE_ROLES, true); + + JsonDocument document = JsonSerializer.SerializeToDocument(role); + + UserRole? newRole = JsonSerializer.Deserialize(document); + Assert.NotNull(newRole); + + Assert.Equal(true, newRole.GetPermission(UserPermission.MANAGE_ROLES)); + + } + + [Fact] + public async Task RoundTripRoleWithSpecifiedPermissionKeepsFalse() + { + + UserRole role = new(); + + await role.SetPermission(UserPermission.MANAGE_ROLES, false); + + JsonDocument document = JsonSerializer.SerializeToDocument(role); + + UserRole? newRole = JsonSerializer.Deserialize(document); + Assert.NotNull(newRole); + + Assert.Equal(false, newRole.GetPermission(UserPermission.MANAGE_ROLES)); + + } + + [Fact] + public async Task RoundTripRoleWithSpecifiedPermissionKeepsNull() + { + + UserRole role = new(); + + await role.SetPermission(UserPermission.MANAGE_USERS, null); + + JsonDocument document = JsonSerializer.SerializeToDocument(role); + + UserRole? newRole = JsonSerializer.Deserialize(document); + Assert.NotNull(newRole); + + Assert.Null(newRole.GetPermission(UserPermission.MANAGE_USERS)); + Assert.Null(newRole.GetPermission(UserPermission.MANAGE_ROLES)); + + } + +} diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs new file mode 100644 index 0000000..0dd55d3 --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs @@ -0,0 +1,33 @@ +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.Users.Tests.Roles; + +public class UserRoleTests +{ + + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(null)] + public async Task SetPermissionsAndReturnExpectedPermissions(bool? value) + { + + UserRole role = new(); + + await role.SetPermission(UserPermission.MANAGE_ROLES, value); + + Assert.Equal(value, role.GetPermission(UserPermission.MANAGE_ROLES)); + + } + + [Fact] + public async Task UnsetPermissionsAreNull() + { + + UserRole role = new(); + + Assert.All(UserPermission.ALL, permission => Assert.Null(role.GetPermission(permission))); + + } + +} diff --git a/PMDDesktop.Server.Users/Roles/UserPermission.cs b/PMDDesktop.Server.Users/Roles/UserPermission.cs new file mode 100644 index 0000000..0c8519e --- /dev/null +++ b/PMDDesktop.Server.Users/Roles/UserPermission.cs @@ -0,0 +1,87 @@ +using System.Collections.Immutable; +using System.Reflection; + +namespace PMDDesktop.Server.Users.Roles; + +public sealed class UserPermission +{ + + static UserPermission() + { + + FieldInfo[] fields = typeof(UserPermission).GetFields(BindingFlags.Static | BindingFlags.Public); + + IEnumerable permissionFields = fields.Where(field => field.FieldType.IsAssignableTo(typeof(UserPermission))); + + IEnumerable permissionObjects = permissionFields.Select(field => + (UserPermission?) field.GetValue(ALL) + ?? throw new NullReferenceException($"Unable to convert {field} to {nameof(UserPermission)}")); + + ALL = ImmutableArray.Create([.. permissionObjects]); + + } + + public static readonly ImmutableArray ALL; + + public static readonly UserPermission MANAGE_USERS = new() + { + DataName = "manage-users", + FriendlyName = "Manage Users", + FriendlyDescription = "Allow creation, modification, and deletion of user accounts.", + AlwaysForAdmin = true, + EnabledForDefault = false + }; + + public static readonly UserPermission MANAGE_ROLES = new() + { + DataName = "manage-roles", + FriendlyName = "Manage Roles", + FriendlyDescription = "Allow creation, modification, and removal of roles and their permissions.", + AlwaysForAdmin = true, + EnabledForDefault = false + }; + + /// + /// Name that is stored and compared to track permission in a . + /// + public required string DataName {get; init;} + + /// + /// User-facing name that is displayed to users when viewing permissions. + /// + public required string FriendlyName {get; init;} + + /// + /// User-facing description for what a permission does. + /// + public required string FriendlyDescription {get; init;} + + /// + /// Weather to always grant this permission for admin users, to prevent losing ability to regain the permission. + /// + public required bool AlwaysForAdmin {get; init;} + + public required bool EnabledForDefault {get; init;} + + private UserPermission() { } + + public override int GetHashCode() + { + return HashCode.Combine(DataName); + } + + public override bool Equals(object? obj) + { + + if (obj is UserPermission permission) + return permission.DataName == DataName; + + return base.Equals(obj); + } + + public override string ToString() + { + return $"{nameof(UserPermission)}:{DataName}"; + } + +} diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs new file mode 100644 index 0000000..326eff0 --- /dev/null +++ b/PMDDesktop.Server.Users/Roles/UserRole.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users.Roles; + +[JsonConverter(typeof(UserRoleConverter))] +public sealed class UserRole +{ + + internal UserRole() { } + + internal Dictionary permissionSettings = []; + public string Name {get; set;} = "Unnamed Role"; + public Guid guid = Guid.NewGuid(); + + public async Task SetPermission(UserPermission permission, bool? preference) + { + + if (preference is bool boolPreference) + permissionSettings[permission] = boolPreference; + else + permissionSettings.Remove(permission); + + } + + public bool? GetPermission(UserPermission permission) + { + + if (permissionSettings.TryGetValue(permission, out bool value)) + return value; + + return null; + + } + +} diff --git a/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs b/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs new file mode 100644 index 0000000..e42d2c7 --- /dev/null +++ b/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users.Roles; + +public class UserRoleConverter : JsonConverter +{ + + internal static readonly string NAME_PROPERTY = "Name"; + internal static readonly string PERMISSIONS_PROPERTY = "Permissions"; + + public override UserRole? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + + using JsonDocument document = JsonDocument.ParseValue(ref reader); + + List propertyList = [.. document.RootElement.EnumerateObject().Select(element => element.Name)]; + + UserRole role = new() + { + Name = QuickReadString(document, propertyList, NAME_PROPERTY) + }; + + propertyList.Remove(PERMISSIONS_PROPERTY); + role.permissionSettings = ReadPermissions(document); + + if (propertyList.Count > 0) + throw new JsonException($"The {nameof(UserRole)} json had left-over properties after parsing, such as {propertyList[0]}. These unused properties cannot be accounted for, and may result in data loss. Is it from a newer version?"); + + return role; + + } + + public override void Write(Utf8JsonWriter writer, UserRole value, JsonSerializerOptions options) + { + + writer.WriteStartObject(); + + // Write name + writer.WriteString(NAME_PROPERTY, value.Name); + + // Write permissions + writer.WriteStartObject(PERMISSIONS_PROPERTY); + foreach (UserPermission permission in UserPermission.ALL) + QuickWritePermission(writer, permission, value); + writer.WriteEndObject(); + + writer.WriteEndObject(); + + } + + private static void QuickWritePermission(Utf8JsonWriter writer, UserPermission permission, UserRole value) + { + + bool? result = value.GetPermission(permission); + + if (result is bool boolResult) + writer.WriteBoolean(permission.DataName, boolResult); + else + writer.WriteNull(permission.DataName); + + } + + private static string QuickReadString(JsonDocument document, List propertyList, string name) + { + + propertyList.Remove(name); + + return document.RootElement.GetProperty(name).GetString() + ?? throw new JsonException($"{name} should not be null in {document}"); + + } + + private static Dictionary ReadPermissions(JsonDocument document) + { + + Dictionary permissionSettings = []; + JsonElement jsonObj = document.RootElement.GetProperty(PERMISSIONS_PROPERTY); + + foreach (JsonProperty property in jsonObj.EnumerateObject()) + { + + string dataName = property.Name; + + // We get this first, because if we can't find a permission even for a "null" permission, there's a chance we could be causing data loss in a different role. + UserPermission permission = UserPermission.ALL.First(perm => perm.DataName == dataName); + + bool? value = property.Value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => throw new JsonException($"Unsupported value kind for {dataName}: {property.Value.ValueKind}") + }; + + if (value is bool boolValue) + permissionSettings.Add(permission, boolValue); + + } + + return permissionSettings; + + } + +} From 5ee04ebe21bf1186bc83fe517bce8f67d93d998a Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:18:37 -0500 Subject: [PATCH 19/55] Disable default launch settings file --- PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj b/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj index 1e2e7de..70d22b0 100644 --- a/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj +++ b/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj @@ -5,6 +5,7 @@ net10.0 enable enable + true From 83d896aaa3cf7eabd9d786e01e16d5da98f83c21 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:28:46 -0500 Subject: [PATCH 20/55] add "default" role creation method --- .../Roles/UserRoleTests.cs | 20 +++++++++++++++++++ PMDDesktop.Server.Users/Roles/UserRole.cs | 18 ++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs index 0dd55d3..0bade5e 100644 --- a/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs +++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs @@ -30,4 +30,24 @@ public async Task UnsetPermissionsAreNull() } + [Fact] + public void DefaultRoleHasAllEnabledByDefaultPermissions() + { + + UserRole role = UserRole.CreateDefaultRole(); + + Assert.All(UserPermission.ALL.Where(perm => perm.EnabledForDefault), perm => Assert.True(role.GetPermission(perm))); + + } + + [Fact] + public void DefaultRoleHasNoDisabledByDefaultPermissions() + { + + UserRole role = UserRole.CreateDefaultRole(); + + Assert.All(UserPermission.ALL.Where(perm => !perm.EnabledForDefault), perm => Assert.Null(role.GetPermission(perm))); + + } + } diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs index 326eff0..51cc6dc 100644 --- a/PMDDesktop.Server.Users/Roles/UserRole.cs +++ b/PMDDesktop.Server.Users/Roles/UserRole.cs @@ -10,7 +10,7 @@ internal UserRole() { } internal Dictionary permissionSettings = []; public string Name {get; set;} = "Unnamed Role"; - public Guid guid = Guid.NewGuid(); + public Guid GUID { get; internal set; } = Guid.NewGuid(); public async Task SetPermission(UserPermission permission, bool? preference) { @@ -32,4 +32,20 @@ public async Task SetPermission(UserPermission permission, bool? preference) } + internal static UserRole CreateDefaultRole() + { + + UserRole role = new() + { + GUID = Guid.Empty, + Name = "Default (Everyone)" + }; + + foreach (UserPermission permission in UserPermission.ALL.Where(perm => perm.EnabledForDefault)) + role.permissionSettings.Add(permission, true); + + return role; + + } + } From 111e3960a9f3b659f265c2dd288ff3ed45eea90b Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:00:31 -0500 Subject: [PATCH 21/55] Trim test RoundTripWithSpecifiedPermissionKeepsValue() --- .../Roles/UserRoleConverterTests.cs | 50 ++++--------------- 1 file changed, 9 insertions(+), 41 deletions(-) diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs index a7bd7df..8183305 100644 --- a/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs +++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs @@ -1,5 +1,5 @@ -using System.Text.Json; -using PMDDesktop.Server.Users.Roles; +using PMDDesktop.Server.Users.Roles; +using System.Text.Json; namespace PMDDesktop.Server.Users.Tests.Roles; @@ -71,55 +71,23 @@ public async Task RoleJsonWithSpecifiedPermissionThenUnspecifiedContainsNull() } - [Fact] - public async Task RoundTripRoleWithSpecifiedPermissionKeepsTrue() - { - - UserRole role = new(); - - await role.SetPermission(UserPermission.MANAGE_ROLES, true); - - JsonDocument document = JsonSerializer.SerializeToDocument(role); - - UserRole? newRole = JsonSerializer.Deserialize(document); - Assert.NotNull(newRole); - - Assert.Equal(true, newRole.GetPermission(UserPermission.MANAGE_ROLES)); - - } - - [Fact] - public async Task RoundTripRoleWithSpecifiedPermissionKeepsFalse() + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(null)] + public async Task RoundTripRoleWithSpecifiedPermissionKeepsValue(bool? value) { UserRole role = new(); - await role.SetPermission(UserPermission.MANAGE_ROLES, false); - - JsonDocument document = JsonSerializer.SerializeToDocument(role); - - UserRole? newRole = JsonSerializer.Deserialize(document); - Assert.NotNull(newRole); - - Assert.Equal(false, newRole.GetPermission(UserPermission.MANAGE_ROLES)); - - } - - [Fact] - public async Task RoundTripRoleWithSpecifiedPermissionKeepsNull() - { - - UserRole role = new(); - - await role.SetPermission(UserPermission.MANAGE_USERS, null); + await role.SetPermission(UserPermission.MANAGE_USERS, value); JsonDocument document = JsonSerializer.SerializeToDocument(role); UserRole? newRole = JsonSerializer.Deserialize(document); Assert.NotNull(newRole); - Assert.Null(newRole.GetPermission(UserPermission.MANAGE_USERS)); - Assert.Null(newRole.GetPermission(UserPermission.MANAGE_ROLES)); + Assert.Equal(value, newRole.GetPermission(UserPermission.MANAGE_USERS)); } From cf37d01f693b97bb4be94d948131813c2729f575 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:20:51 -0500 Subject: [PATCH 22/55] Add throw for trying to attach user while already attached --- PMDDesktop.Server.Users/User.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index 0cd8f6a..03b2e51 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -350,6 +350,9 @@ private async Task TryRevokeOldestAccessToken(bool writeAfterwards = true) internal void AttachToManager(UserManager manager) { + if (Manager != null) + throw new InvalidOperationException($"This existing value of {nameof(Manager)} is not null. Cannot attach while already attached. Does this call to {nameof(AttachToManager)} need to be skipped, or does {nameof(DetachFromManager)} need to be called first?"); + Manager = manager; WritingEnabled = manager.WritingEnabled; From ff4053f84336293c0ecfc30952f7dcbb37b4dbcf Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:47:52 -0500 Subject: [PATCH 23/55] Add UserRoleManager (no saving yet) --- .../Roles/UserRoleManagerTests.cs | 194 ++++++++++++++++++ .../Roles/UserRoleTests.cs | 10 + .../Roles/IRoleIndexable.cs | 13 ++ PMDDesktop.Server.Users/Roles/UserRole.cs | 34 ++- .../Roles/UserRoleManager.cs | 101 +++++++++ PMDDesktop.Server.Users/UserManager.cs | 23 ++- 6 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 PMDDesktop.Server.Users.Tests/Roles/UserRoleManagerTests.cs create mode 100644 PMDDesktop.Server.Users/Roles/IRoleIndexable.cs create mode 100644 PMDDesktop.Server.Users/Roles/UserRoleManager.cs diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleManagerTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleManagerTests.cs new file mode 100644 index 0000000..1373c2f --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleManagerTests.cs @@ -0,0 +1,194 @@ +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.Users.Tests.Roles; + +public class UserRoleManagerTests +{ + + [Fact] + public void UserRoleManagerPointsBackAtUserManager() + { + + UserManager manager = new(); + UserRoleManager roleManager = manager.RoleManager; + + Assert.NotNull(roleManager); + + Assert.Equal(manager, roleManager.Manager); + + Assert.Equal(roleManager, roleManager.Manager.RoleManager); + + } + + [Fact] + public void UserRoleManagerHasDefaultRole() + { + + UserManager manager = new(); + UserRoleManager roleManager = manager.RoleManager; + + Assert.NotNull(roleManager.DefaultRole); + + Assert.True(roleManager.DefaultRole.IsDefaultRole); + + Assert.Equal(Guid.Empty, roleManager.DefaultRole.GUID); + + // Seeing if that's what we get for Guid.Empty + + Assert.True(roleManager.TryGetRole(Guid.Empty, out UserRole? tryEmptyRole)); + Assert.NotNull(tryEmptyRole); + Assert.Equal(roleManager.DefaultRole, tryEmptyRole); + + // Test comparing to mock default role. + + UserRole exampleDefault = UserRole.CreateDefaultRole(); + UserRole managerDefault = roleManager.DefaultRole; + + Assert.All(UserPermission.ALL, perm => Assert.Equal(exampleDefault.GetPermission(perm), managerDefault.GetPermission(perm))); + + } + + [Fact] + public async Task NewRolesAreOrderedLastBeforeDefault() + { + + UserManager manager = new(); + UserRoleManager roleManager = manager.RoleManager; + + // Role 0 + UserRole role0 = roleManager.DefaultRole; + Assert.Equal(role0, roleManager.Skip(0).First()); + Assert.Single(roleManager); + Assert.Equal(role0, roleManager.SkipLast(0).Last()); + + // Role 1 + + UserRole? role1 = await roleManager.TryCreateRole(); + Assert.Equal(role1, roleManager.Skip(0).First()); + Assert.Equal(role0, roleManager.Skip(1).First()); + Assert.Equal(2, roleManager.Count()); + Assert.Equal(role1, roleManager.SkipLast(1).Last()); + Assert.Equal(role0, roleManager.SkipLast(0).Last()); + + // Role 2 + + UserRole? role2 = await roleManager.TryCreateRole(); + Assert.Equal(role1, roleManager.Skip(0).First()); + Assert.Equal(role2, roleManager.Skip(1).First()); + Assert.Equal(role0, roleManager.Skip(2).First()); + Assert.Equal(3, roleManager.Count()); + Assert.Equal(role1, roleManager.SkipLast(2).Last()); + Assert.Equal(role2, roleManager.SkipLast(1).Last()); + Assert.Equal(role0, roleManager.SkipLast(0).Last()); + + // Role 3 + + UserRole? role3 = await roleManager.TryCreateRole(); + Assert.Equal(role1, roleManager.Skip(0).First()); + Assert.Equal(role2, roleManager.Skip(1).First()); + Assert.Equal(role3, roleManager.Skip(2).First()); + Assert.Equal(role0, roleManager.Skip(3).First()); + Assert.Equal(4, roleManager.Count()); + Assert.Equal(role1, roleManager.SkipLast(3).Last()); + Assert.Equal(role2, roleManager.SkipLast(2).Last()); + Assert.Equal(role3, roleManager.SkipLast(1).Last()); + Assert.Equal(role0, roleManager.SkipLast(0).Last()); + + // Role 4 + + UserRole? role4 = await roleManager.TryCreateRole(); + Assert.Equal(role1, roleManager.Skip(0).First()); + Assert.Equal(role2, roleManager.Skip(1).First()); + Assert.Equal(role3, roleManager.Skip(2).First()); + Assert.Equal(role4, roleManager.Skip(3).First()); + Assert.Equal(role0, roleManager.Skip(4).First()); + Assert.Equal(5, roleManager.Count()); + Assert.Equal(role1, roleManager.SkipLast(4).Last()); + Assert.Equal(role2, roleManager.SkipLast(3).Last()); + Assert.Equal(role3, roleManager.SkipLast(2).Last()); + Assert.Equal(role4, roleManager.SkipLast(1).Last()); + Assert.Equal(role0, roleManager.SkipLast(0).Last()); + + } + + [Fact] + public async Task SortingAffectsEnumeration() + { + + UserManager manager = new(); + UserRoleManager roleManager = manager.RoleManager; + + UserRole defaultRole = roleManager.DefaultRole; + UserRole? role1 = await roleManager.TryCreateRole(); + UserRole? role2 = await roleManager.TryCreateRole(); + UserRole? role3 = await roleManager.TryCreateRole(); + UserRole? role4 = await roleManager.TryCreateRole(); + + Assert.NotNull(role1); + Assert.NotNull(role2); + Assert.NotNull(role3); + Assert.NotNull(role4); + + UserRole[] array1 = [.. roleManager]; + + Assert.Equal(role1, array1[0]); + Assert.Equal(role2, array1[1]); + Assert.Equal(role3, array1[2]); + Assert.Equal(role4, array1[3]); + Assert.Equal(defaultRole, array1[4]); + + UserRole[] newSort2 = [role3, role2, role1, role4]; + + await roleManager.SetOrder(newSort2.Select(role => role.GUID)); + + UserRole[] array2 = [.. roleManager]; + + Assert.Equal(newSort2[0], array2[0]); + Assert.Equal(newSort2[1], array2[1]); + Assert.Equal(newSort2[2], array2[2]); + Assert.Equal(newSort2[3], array2[3]); + Assert.Equal(defaultRole, array2[4]); + + } + + [Fact] + public async Task SortingAffectsEnumerationWithDefaultSpecified() + { + + UserManager manager = new(); + UserRoleManager roleManager = manager.RoleManager; + + UserRole defaultRole = roleManager.DefaultRole; + UserRole? role1 = await roleManager.TryCreateRole(); + UserRole? role2 = await roleManager.TryCreateRole(); + UserRole? role3 = await roleManager.TryCreateRole(); + UserRole? role4 = await roleManager.TryCreateRole(); + + Assert.NotNull(role1); + Assert.NotNull(role2); + Assert.NotNull(role3); + Assert.NotNull(role4); + + UserRole[] array1 = [.. roleManager]; + + Assert.Equal(role1, array1[0]); + Assert.Equal(role2, array1[1]); + Assert.Equal(role3, array1[2]); + Assert.Equal(role4, array1[3]); + Assert.Equal(defaultRole, array1[4]); + + UserRole[] newSort2 = [role3, role2, role1, role4, defaultRole]; + + await roleManager.SetOrder(newSort2.Select(role => role.GUID)); + + UserRole[] array2 = [.. roleManager]; + + Assert.Equal(newSort2[0], array2[0]); + Assert.Equal(newSort2[1], array2[1]); + Assert.Equal(newSort2[2], array2[2]); + Assert.Equal(newSort2[3], array2[3]); + Assert.Equal(newSort2[4], array2[4]); + + } + +} diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs index 0bade5e..16c0136 100644 --- a/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs +++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs @@ -50,4 +50,14 @@ public void DefaultRoleHasNoDisabledByDefaultPermissions() } + [Fact] + public void DefaultRoleIsMarkedAsDefaultRole() + { + + UserRole role = UserRole.CreateDefaultRole(); + + Assert.True(role.IsDefaultRole); + + } + } diff --git a/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs b/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs new file mode 100644 index 0000000..3613b50 --- /dev/null +++ b/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs @@ -0,0 +1,13 @@ +using System.Diagnostics.CodeAnalysis; + +namespace PMDDesktop.Server.Users.Roles; + +/// +/// Interface for objects that can fetch a from a . +/// +public interface IRoleIndexable +{ + + bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role); + +} diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs index 51cc6dc..5d3f68f 100644 --- a/PMDDesktop.Server.Users/Roles/UserRole.cs +++ b/PMDDesktop.Server.Users/Roles/UserRole.cs @@ -9,8 +9,10 @@ public sealed class UserRole internal UserRole() { } internal Dictionary permissionSettings = []; - public string Name {get; set;} = "Unnamed Role"; + public string Name { get; set; } = "Unnamed Role"; public Guid GUID { get; internal set; } = Guid.NewGuid(); + public bool IsDefaultRole { get => GUID == Guid.Empty; } + public UserRoleManager? Manager {get; private set;} public async Task SetPermission(UserPermission permission, bool? preference) { @@ -48,4 +50,34 @@ internal static UserRole CreateDefaultRole() } + internal void AttachToManager(UserRoleManager newManager) + { + + if (Manager != null) + throw new InvalidOperationException($"This existing value of {nameof(Manager)} is not null. Cannot attach while already attached. Does this call to {nameof(AttachToManager)} need to be skipped, or does {nameof(DetachFromManager)} need to be called first?"); + + Manager = newManager; + + newManager.guids.Add(GUID, this); + + if (!IsDefaultRole) + newManager.roleOrder = [.. newManager.roleOrder, GUID]; + + } + + internal void DetachFromManager() + { + + if (Manager == null) + throw new NullReferenceException($"{nameof(Manager)} is null. Cannot detach from nothing. Does this call to {nameof(DetachFromManager)} need to be skipped?"); + + if (Manager.guids[GUID] != this) + throw new InvalidOperationException($"{this} was not inside {Manager.guids} with key {GUID}."); + + Manager.guids.Remove(GUID); + + Manager = null; + + } + } diff --git a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs new file mode 100644 index 0000000..4cdd8f5 --- /dev/null +++ b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs @@ -0,0 +1,101 @@ +using System.Collections; +using System.Data; +using System.Diagnostics.CodeAnalysis; + +namespace PMDDesktop.Server.Users.Roles; + +public sealed class UserRoleManager : IRoleIndexable, IEnumerable +{ + + internal UserRoleManager(UserManager userManager) + { + + Manager = userManager; + + UserRole.CreateDefaultRole().AttachToManager(this); + + } + + public UserManager Manager { get; private init; } + + /// + /// This is a dictionary with all the s mapped to their . + /// + /// + /// This dictionary's entries are not managed by the , but instead by the s as they are attached/detached. Do not let write to this dictionary. + /// + internal Dictionary guids = []; + + /// + /// Default applied to all users when checking for s. + /// Cannot be added or removed from s. + /// + internal UserRole DefaultRole { get => guids[Guid.Empty]; } + + /// + /// Order to check when evaluating s based on s. + /// Should never contain (). + /// + internal Guid[] roleOrder = []; + + /// + /// Attempt to set the order of roles by providing the s of every in this . + /// s should be provided in the order of most important (highest) to least important (lowest). s that are higher (provided first) will take priority over lower roles when they have clashing s. + /// If provided, () will be ignored. + /// Will fail if duplicated GUIDs are provided, or GUIDs that do not correspond to s in this . + /// + /// + /// Task resolves once order is set and changes are saved. + public async Task SetOrder(IEnumerable guidOrder) + { + + Guid[] orderArray = [.. guidOrder.Where(guid => guid != Guid.Empty)]; + + // Each role's GUID should be provided exactly once. + foreach (UserRole role in guids.Values.Where(role => !role.IsDefaultRole)) + if (orderArray.Count(role.GUID) != 1) + throw new ArgumentException($"{role}'s GUID ({role.GUID}) was listed {orderArray.Count(role.GUID)} times in the provided order. There needs to be exactly 1 GUID for each role."); + + // All GUIDs should match a role. + foreach (Guid guid in orderArray) + if (!guids.ContainsKey(guid)) + throw new ArgumentException($"There is no matching {nameof(UserRole)} in {this} that has GUID {guid}."); + + roleOrder = orderArray; + + return; + + } + + public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role) + { + return guids.TryGetValue(GUID, out role); + } + + public async Task TryCreateRole() + { + + UserRole role = new(); + + role.AttachToManager(this); + + return role; + + } + + public IEnumerator GetEnumerator() + { + + foreach (Guid guid in roleOrder) + yield return guids[guid]; + + yield return DefaultRole; + + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + +} diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs index a3368fd..ab395ee 100644 --- a/PMDDesktop.Server.Users/UserManager.cs +++ b/PMDDesktop.Server.Users/UserManager.cs @@ -1,12 +1,26 @@ -using System.Collections; +using PMDDesktop.Server.Users.Roles; +using System.Collections; using System.Diagnostics.CodeAnalysis; using System.Text.Json; namespace PMDDesktop.Server.Users; -public sealed class UserManager() : IEnumerable, IUserIndexable +public sealed class UserManager : IEnumerable, IUserIndexable, IRoleIndexable { + public UserManager() + { + + RoleManager = new(this); + + } + + /// + /// attached to this + /// Used when checking for s and their s. + /// + public UserRoleManager RoleManager { get; } + /// /// Does this actually write to files? /// If this is false, file writes to the filesystem/disk will be skipped. @@ -204,4 +218,9 @@ public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User? } + public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role) + { + return RoleManager.TryGetRole(GUID, out role); + } + } From 4083fdd89917a9d8b982cc0b380b109dd45ad0ba Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:48:36 -0500 Subject: [PATCH 24/55] Make Users.RoleManager accessable in GameState.Roles --- PMDDesktop.Server/Game/GameState.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/PMDDesktop.Server/Game/GameState.cs b/PMDDesktop.Server/Game/GameState.cs index 5212ecb..1aa0c65 100644 --- a/PMDDesktop.Server/Game/GameState.cs +++ b/PMDDesktop.Server/Game/GameState.cs @@ -1,6 +1,7 @@ using PMDDesktop.Server.Assets; using PMDDesktop.Server.Saving; using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; using System.Diagnostics.CodeAnalysis; namespace PMDDesktop.Server.Game; @@ -8,6 +9,16 @@ namespace PMDDesktop.Server.Game; public class GameState : IAssetIndexable, ISaveDataIndexable, IUserIndexable { + public GameState() + { + + Saves = []; + Assets = new(); + Users = new(); + Roles = Users.RoleManager; + + } + [ExcludeFromCodeCoverage] public static async Task CreateAndLoadFiles() { @@ -55,8 +66,9 @@ public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user) return Users.TryGetUser(GUID, out user); } - public SaveDataManager Saves { get; } = new(); - public AssetManager Assets { get; } = new(); - public UserManager Users { get; } = new(); + public SaveDataManager Saves { get; } + public AssetManager Assets { get; } + public UserManager Users { get; } + public UserRoleManager Roles { get; } } From c1ede699b8e286ffb72d6ceb2288e644d859c4ba Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:21:21 -0500 Subject: [PATCH 25/55] Add Role saving --- PMDDesktop.Server.Users/Roles/UserRole.cs | 97 ++++++++++++- .../Roles/UserRoleManager.cs | 130 ++++++++++++++++++ PMDDesktop.Server.Users/UserManager.cs | 14 +- 3 files changed, 226 insertions(+), 15 deletions(-) diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs index 5d3f68f..26c2ee0 100644 --- a/PMDDesktop.Server.Users/Roles/UserRole.cs +++ b/PMDDesktop.Server.Users/Roles/UserRole.cs @@ -1,4 +1,6 @@ -using System.Text.Json.Serialization; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; namespace PMDDesktop.Server.Users.Roles; @@ -9,10 +11,20 @@ public sealed class UserRole internal UserRole() { } internal Dictionary permissionSettings = []; - public string Name { get; set; } = "Unnamed Role"; + public string Name { get; internal set; } = "Unnamed Role"; public Guid GUID { get; internal set; } = Guid.NewGuid(); public bool IsDefaultRole { get => GUID == Guid.Empty; } - public UserRoleManager? Manager {get; private set;} + public UserRoleManager? Manager { get; private set; } + public bool? WritingEnabled { get; private set; } + + public async Task SetName(string newName) + { + + Name = newName; + + await WriteNewData(); + + } public async Task SetPermission(UserPermission permission, bool? preference) { @@ -22,6 +34,9 @@ public async Task SetPermission(UserPermission permission, bool? preference) else permissionSettings.Remove(permission); + if (WritingEnabled is true) + await WriteNewData(); + } public bool? GetPermission(UserPermission permission) @@ -57,6 +72,7 @@ internal void AttachToManager(UserRoleManager newManager) throw new InvalidOperationException($"This existing value of {nameof(Manager)} is not null. Cannot attach while already attached. Does this call to {nameof(AttachToManager)} need to be skipped, or does {nameof(DetachFromManager)} need to be called first?"); Manager = newManager; + WritingEnabled = Manager.WritingEnabled; newManager.guids.Add(GUID, this); @@ -76,8 +92,83 @@ internal void DetachFromManager() Manager.guids.Remove(GUID); + if (!IsDefaultRole) + Manager.roleOrder = [.. Manager.roleOrder.Where(guid => guid != GUID)]; + Manager = null; } + /// + /// Gets the primary JSON that contains this 's data, located inside the folder. + /// + /// A path pointing to where the 's json data is/should be stored. + internal string GetRoleJSONPath() + { + + return Path.Combine(AppContext.BaseDirectory, "roles", $"{GUID}.json"); + + } + + [ExcludeFromCodeCoverage] + internal async Task WriteNewData() + { + + if (Manager is null) // ????? This state doesn't make any sense. + throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(Manager)} is null!"); + + if (WritingEnabled is null) + throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(WritingEnabled)} is null!"); + + if (WritingEnabled != true) + return; + + using FileStream jsonFile = File.Create(GetRoleJSONPath()); + + await JsonSerializer.SerializeAsync(jsonFile, this, AppInfo.JSON_OPTIONS); + + } + + /// + /// Deletes the folder and all *explicitly written* data inside it. + /// + /// + /// + /// This function does not recursive delete. Instead it has a list of items to delete. This approach is taken to minimize data loss in the event that something goes wrong. + /// + [ExcludeFromCodeCoverage] + private async Task DeleteRoleFolder() + { + + if (WritingEnabled is null) + throw new InvalidOperationException($"{this} shouldn't be deleting its data from a file while {nameof(WritingEnabled)} is null!"); + + if (WritingEnabled != true) + return; + + File.Delete(GetRoleJSONPath()); + + } + + public async Task TryRemoveRole() + { + + if (Manager is null) + return false; + + if (IsDefaultRole) + return false; // Default role cannot be removed manually. + + UserRoleManager oldManager = Manager; + + DetachFromManager(); + + await DeleteRoleFolder(); + + await oldManager.WriteNewRoleOrder(); + + return true; + + } + } diff --git a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs index 4cdd8f5..909694c 100644 --- a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs +++ b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Data; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; namespace PMDDesktop.Server.Users.Roles; @@ -18,6 +19,16 @@ internal UserRoleManager(UserManager userManager) public UserManager Manager { get; private init; } + /// + /// Does this actually write to files? + /// If this is false, file writes to the filesystem/disk will be skipped. + /// This property checks to get its value. + /// + /// + /// This is property as "false" is very useful in unit testing. Should probably be "true" during runtime. + /// + public bool WritingEnabled { get => Manager.WritingEnabled; } + /// /// This is a dictionary with all the s mapped to their . /// @@ -38,6 +49,103 @@ internal UserRoleManager(UserManager userManager) /// internal Guid[] roleOrder = []; + internal async Task LoadFromFiles() + { + + await LoadAllRoleData(); + + await LoadRoleOrder(); + + } + + /// + /// This manages creating objects by loading their data from the "roles" folder. + /// + [ExcludeFromCodeCoverage] + internal async Task LoadAllRoleData() + { + + string roleRootDirPath = Path.Combine(AppContext.BaseDirectory, "roles"); + + if (!Directory.Exists(roleRootDirPath)) + Directory.CreateDirectory(roleRootDirPath); + + foreach (string filePath in Directory.EnumerateFiles(roleRootDirPath)) + { + + string guidParsable = Path.GetFileNameWithoutExtension(filePath); + + if (!Guid.TryParse(guidParsable, out Guid loadedGUID)) + throw new Exception($"Couldn't parse {guidParsable} as a GUID at {filePath}"); + + string userFilePath = Path.Join(filePath, User.USER_FILE_NAME); + + using FileStream readStream = File.OpenRead(userFilePath); + + await LoadAndAddRoleJson(readStream, loadedGUID); + + } + + } + + /// + /// Internal function for loading a JSON stream and adding the resulting to this . + /// + /// A JSON stream with data inside. + /// + /// + /// This can be safely unit tested, as it takes in a stream instead of reading from a file. + /// + internal async Task LoadAndAddRoleJson(Stream stream, Guid guid) + { + + UserRole deserialized = JsonSerializer.Deserialize(stream, AppInfo.JSON_OPTIONS) + ?? throw new Exception($"Deserialized user role data from {stream} was null."); + + deserialized.GUID = guid; // GUID will be randomized by default, we need to load the previous GUID. + + deserialized.AttachToManager(this); + + return deserialized; + + } + + /// + /// Gets the primary JSON that contains this 's data, located inside the folder. + /// + /// A path pointing to where the 's json data is/should be stored. + internal static string GetOrderJSONPath() + { + + return Path.Combine(AppContext.BaseDirectory, "roleOrder.json"); + + } + + [ExcludeFromCodeCoverage] + private async Task LoadRoleOrder() + { + + string path = GetOrderJSONPath(); + + if (!File.Exists(path)) + return; // No biggie! Just assume default order. + + using Stream stream = File.OpenRead(path); + + await LoadRoleOrder(stream); + + } + + internal async Task LoadRoleOrder(Stream stream) + { + + Guid[] order = await JsonSerializer.DeserializeAsync(stream, AppInfo.JSON_OPTIONS) + ?? throw new Exception($"Deserialized user role order from {stream} was null."); + + await SetOrder(order); + + } + /// /// Attempt to set the order of roles by providing the s of every in this . /// s should be provided in the order of most important (highest) to least important (lowest). s that are higher (provided first) will take priority over lower roles when they have clashing s. @@ -63,10 +171,28 @@ public async Task SetOrder(IEnumerable guidOrder) roleOrder = orderArray; + if (WritingEnabled) + await WriteNewRoleOrder(); + return; } + public async Task WriteNewRoleOrder() + { + + if (Manager is null) // ????? This state doesn't make any sense. + throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(Manager)} is null!"); + + if (!WritingEnabled) + return; + + using FileStream jsonFile = File.Create(GetOrderJSONPath()); + + await JsonSerializer.SerializeAsync(jsonFile, roleOrder, AppInfo.JSON_OPTIONS); + + } + public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role) { return guids.TryGetValue(GUID, out role); @@ -79,6 +205,10 @@ public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role) role.AttachToManager(this); + await role.WriteNewData(); + + await WriteNewRoleOrder(); + return role; } diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs index ab395ee..62c767e 100644 --- a/PMDDesktop.Server.Users/UserManager.cs +++ b/PMDDesktop.Server.Users/UserManager.cs @@ -69,7 +69,7 @@ public async Task LoadFromFilesAndEnableWriting() await LoadAllUserData(); - await LoadAllRoleData(); + await RoleManager.LoadFromFiles(); } @@ -126,17 +126,6 @@ internal async Task LoadAndAddUserJson(Stream stream, Guid guid) } - /// - /// This manages creating objects by loading their data from the "users" folder. - /// - [ExcludeFromCodeCoverage] - private async Task LoadAllRoleData() - { - - // Not yet implemented. - - } - public IEnumerator GetEnumerator() { return guids.Values.GetEnumerator(); @@ -214,6 +203,7 @@ public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User? await user.SetPassword(options.PlainTextPassword); user.AttachToManager(this); + await user.WriteNewData(); return user; } From 686b7c622845dca8a309b2f2280ed084200e200c Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:03:24 -0500 Subject: [PATCH 26/55] fix confusing exceptions in User --- PMDDesktop.Server.Users/User.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index 03b2e51..f3ed766 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -205,10 +205,10 @@ internal async Task WriteNewData() { if (Manager is null) // ????? This state doesn't make any sense. - throw new InvalidOperationException($"{this} shouldn't be writing its data without being attached to a {nameof(Manager)}."); + throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(Manager)} is null!"); if (WritingEnabled is null) - throw new InvalidOperationException($"{this} shouldn't be deleting its data while {WritingEnabled} is null!"); + throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(WritingEnabled)} is null!"); foreach (UserAccessToken token in accessTokens) if (DateTime.UtcNow >= token.ExpiryDate) @@ -235,7 +235,7 @@ private async Task DeleteUserFolder() { if (WritingEnabled is null) - throw new InvalidOperationException($"{this} shouldn't be deleting its data while {WritingEnabled} is null!"); + throw new InvalidOperationException($"{this} shouldn't be deleting its data from the filesystem while {nameof(WritingEnabled)} is null!"); if (WritingEnabled != true) return; From 7af959c87267e16b1578a2a8e74994f302837a09 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:33:52 -0500 Subject: [PATCH 27/55] Add regions for functions in User This file is getting really big!! This helps it be more managable. I'll try to region stuff better in the future. --- PMDDesktop.Server.Users/User.cs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index f3ed766..05e8558 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -74,6 +74,8 @@ internal User() internal List accessTokens = []; + #region Name + public async Task SetName(string newName) { @@ -84,6 +86,10 @@ public async Task SetName(string newName) } + #endregion + + #region Login Handle & Password + /// /// Salt and hash the user's password, then store it in HashedPassword. /// @@ -177,6 +183,10 @@ public async Task VerifyPassword(string plainText) } + #endregion + + #region Saving + /// /// Return the filesystem folder that should contain all of this user's data. /// @@ -257,6 +267,10 @@ private async Task DeleteUserFolder() } + #endregion + + #region Generic Object Overrides + public override bool Equals(object? obj) { @@ -276,6 +290,10 @@ public override int GetHashCode() return HashCode.Combine(GUID); } + #endregion + + #region Access Tokens + public async Task CreateAccessToken() { @@ -347,6 +365,10 @@ private async Task TryRevokeOldestAccessToken(bool writeAfterwards = true) } + #endregion + + #region Manager Attaching + internal void AttachToManager(UserManager manager) { @@ -387,6 +409,10 @@ internal void DetachFromManager() public bool IsAlive() => Manager is not null; + #endregion + + #region User Deletion + /// /// Try to remove this from its . /// If the assigned has , then this function will also attempt to delete the user's folder from the filesystem, so that it will not be restored in future sessions. @@ -408,4 +434,6 @@ public async Task TryRemoveUser() } + #endregion + } From 773745146dc97d7f710955766d8a3fc079482fef Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:41:26 -0500 Subject: [PATCH 28/55] Add RoleReference and Tests --- .../Roles/RoleReferenceTests.cs | 51 +++++++++++++++++++ .../Roles/RoleReference.cs | 34 +++++++++++++ .../Roles/RoleReferenceConverter.cs | 29 +++++++++++ 3 files changed, 114 insertions(+) create mode 100644 PMDDesktop.Server.Users.Tests/Roles/RoleReferenceTests.cs create mode 100644 PMDDesktop.Server.Users/Roles/RoleReference.cs create mode 100644 PMDDesktop.Server.Users/Roles/RoleReferenceConverter.cs diff --git a/PMDDesktop.Server.Users.Tests/Roles/RoleReferenceTests.cs b/PMDDesktop.Server.Users.Tests/Roles/RoleReferenceTests.cs new file mode 100644 index 0000000..29d8e62 --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/Roles/RoleReferenceTests.cs @@ -0,0 +1,51 @@ +using PMDDesktop.Server.Users.Roles; +using System.Text.Json; + +namespace PMDDesktop.Server.Users.Tests.Roles; + +public class RoleReferenceTests +{ + + [Fact] + public async Task CanGetReference() + { + + UserManager manager = new(); + UserRoleManager roleManager = manager.RoleManager; + UserRole? role = await roleManager.TryCreateRole(); + Assert.NotNull(role); + + RoleReference reference = new(role); + + Assert.True(reference.TryGetRole(manager, out UserRole? referRole)); + + Assert.Equal(role, referRole); + + } + + [Fact] + public async Task SerializationIsSimple() + { + + UserManager manager = new(); + UserRoleManager roleManager = manager.RoleManager; + UserRole? role = await roleManager.TryCreateRole(); + Assert.NotNull(role); + + RoleReference preReference = new(role); + + JsonElement json = JsonSerializer.SerializeToElement(preReference, AppInfo.JSON_OPTIONS); + + Assert.Equal(JsonValueKind.String, json.ValueKind); + + RoleReference? postReference = JsonSerializer.Deserialize(json); + + Assert.NotNull(postReference); + + Assert.True(postReference.TryGetRole(manager, out UserRole? referRole)); + + Assert.Equal(role, referRole); + + } + +} diff --git a/PMDDesktop.Server.Users/Roles/RoleReference.cs b/PMDDesktop.Server.Users/Roles/RoleReference.cs new file mode 100644 index 0000000..da0235f --- /dev/null +++ b/PMDDesktop.Server.Users/Roles/RoleReference.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users.Roles; + +[JsonConverter(typeof(RoleReferenceConverter))] +public sealed class RoleReference +{ + + public Guid GUID { get; private set; } + + + public RoleReference(UserRole initalValue) + { + + GUID = initalValue.GUID; + + } + + internal RoleReference(Guid directGuid) + { + + GUID = directGuid; + + } + + public bool TryGetRole(IRoleIndexable indexable, [NotNullWhen(true)] out UserRole? role) + { + + return indexable.TryGetRole(GUID, out role); + + } + +} diff --git a/PMDDesktop.Server.Users/Roles/RoleReferenceConverter.cs b/PMDDesktop.Server.Users/Roles/RoleReferenceConverter.cs new file mode 100644 index 0000000..f36b0a5 --- /dev/null +++ b/PMDDesktop.Server.Users/Roles/RoleReferenceConverter.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users.Roles; + + +internal class RoleReferenceConverter : JsonConverter +{ + public override RoleReference Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + + if (reader.TokenType != JsonTokenType.String) + throw new JsonException($"{Type} was expecting a string token to deserialize."); + + string guidString = reader.GetString() ?? throw new JsonException($"We read a null string while trying to deserialize {Type}."); + + Guid guid = Guid.Parse(guidString); + + return new RoleReference(guid); + + } + + public override void Write(Utf8JsonWriter writer, RoleReference value, JsonSerializerOptions options) + { + + writer.WriteStringValue(value.GUID.ToString()); + + } +} From 477299961413dedc2f70ead02c23008d8f35e4ff Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:53:05 -0500 Subject: [PATCH 29/55] add role adding in User and tests --- PMDDesktop.Server.Users.Tests/UserTests.cs | 68 +++++++++++++++++++++- PMDDesktop.Server.Users/User.cs | 65 +++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs index b865828..87f0341 100644 --- a/PMDDesktop.Server.Users.Tests/UserTests.cs +++ b/PMDDesktop.Server.Users.Tests/UserTests.cs @@ -1,4 +1,6 @@ -namespace PMDDesktop.Server.Users.Tests; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.Users.Tests; public class UserTests { @@ -175,4 +177,68 @@ public async Task DeleteUserTest() } + // I'm not sure if there's a better place for these role/permission/user tests. They test way more than just the User.cs file, but they start and end there. + [Fact] + public async Task UserGetsPermission() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + UserRoleManager roleManager = manager.RoleManager; + UserRole? role = await roleManager.TryCreateRole(); + Assert.NotNull(role); + await role.SetPermission(UserPermission.MANAGE_ROLES, true); + + Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES)); + + Assert.True(await user.TryAddRole(role)); + + Assert.True(user.HasPermission(UserPermission.MANAGE_ROLES)); + + Assert.True(await user.TryRemoveRole(role)); + + Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES)); + + } + + [Fact] + public async Task UserPermissionsRespectHierarchy() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + UserRoleManager roleManager = manager.RoleManager; + UserRole? givingRole = await roleManager.TryCreateRole(); + Assert.NotNull(givingRole); + await givingRole.SetPermission(UserPermission.MANAGE_ROLES, true); + await user.TryAddRole(givingRole); + + UserRole? takingRole = await roleManager.TryCreateRole(); + Assert.NotNull(takingRole); + await takingRole.SetPermission(UserPermission.MANAGE_ROLES, false); + await user.TryAddRole(takingRole); + + UserRole? nothingRole = await roleManager.TryCreateRole(); + Assert.NotNull(nothingRole); + await nothingRole.SetPermission(UserPermission.MANAGE_ROLES, null); + await user.TryAddRole(nothingRole); + + await roleManager.SetOrder([givingRole.GUID, takingRole.GUID, nothingRole.GUID]); + Assert.True(user.HasPermission(UserPermission.MANAGE_ROLES)); + + await roleManager.SetOrder([takingRole.GUID, givingRole.GUID, nothingRole.GUID]); + Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES)); + + await roleManager.SetOrder([nothingRole.GUID, givingRole.GUID, takingRole.GUID]); + Assert.True(user.HasPermission(UserPermission.MANAGE_ROLES)); + + await roleManager.SetOrder([nothingRole.GUID, takingRole.GUID, givingRole.GUID]); + Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES)); + + } + } diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index 05e8558..dd23f3d 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Identity; +using PMDDesktop.Server.Users.Roles; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; @@ -74,6 +75,12 @@ internal User() internal List accessTokens = []; + /// + /// Collection of s assigned to this user by their s. Order does not matter, use for order instead. + /// Also, is not included in this. It is implied to always be assigned. + /// + internal HashSet roles = []; + #region Name public async Task SetName(string newName) @@ -436,4 +443,62 @@ public async Task TryRemoveUser() #endregion + #region Role/Permission Management + + public async Task TryAddRole(UserRole role) + { + + if (!roles.Add(role.GUID)) + return false; + + await WriteNewData(); + + return true; + + } + + public async Task TryRemoveRole(UserRole role) + { + + if (!roles.Remove(role.GUID)) + return false; + + await WriteNewData(); + + return true; + + } + + public bool HasRole(UserRole role) + { + + return roles.Contains(role.GUID); + + } + + public bool HasPermission(UserPermission permission) + { + + if (Manager is null) + throw new NullReferenceException($"Can't check if {this} has a permission if {nameof(Manager)} is null!"); + + foreach (UserRole role in Manager.RoleManager) + { + + if (!HasRole(role)) + continue; + + bool? newState = role.GetPermission(permission); + + if (newState is bool final) + return final; + + } + + return false; + + } + + #endregion + } From 0629f475c7b5688224488a350225b17806e576b4 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:59:20 -0500 Subject: [PATCH 30/55] Add test for changing user names. --- PMDDesktop.Server.Users.Tests/UserTests.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs index 87f0341..7ec9ff5 100644 --- a/PMDDesktop.Server.Users.Tests/UserTests.cs +++ b/PMDDesktop.Server.Users.Tests/UserTests.cs @@ -177,6 +177,24 @@ public async Task DeleteUserTest() } + [Fact] + public async Task CanChangeName() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + string originalName = user.Name; + + await user.SetName("Waffles"); + + Assert.Equal("Waffles", user.Name); + + Assert.NotEqual(originalName, user.Name); + + } + // I'm not sure if there's a better place for these role/permission/user tests. They test way more than just the User.cs file, but they start and end there. [Fact] public async Task UserGetsPermission() From 90388bad766e7305393414ad41b08817110396a2 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:05:13 -0500 Subject: [PATCH 31/55] remove check for null non-nullable Manager This happened in WriteNewRoleOrder() because of a copy-pase oversight. Whoops! --- PMDDesktop.Server.Users/Roles/UserRoleManager.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs index 909694c..b97568a 100644 --- a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs +++ b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs @@ -181,9 +181,6 @@ public async Task SetOrder(IEnumerable guidOrder) public async Task WriteNewRoleOrder() { - if (Manager is null) // ????? This state doesn't make any sense. - throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(Manager)} is null!"); - if (!WritingEnabled) return; From ad67cd26b46308758e97be8017ca84f39e721105 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:11:11 -0500 Subject: [PATCH 32/55] Split RevokeAccessToken into an internal and public function --- PMDDesktop.Server.Users/User.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index dd23f3d..79e3f02 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -330,7 +330,7 @@ private void AttachAccessToken(UserAccessToken token) } - public async Task RevokeAccessToken(UserAccessToken token, bool writeAfterwards = true) + internal async Task RevokeAccessToken(UserAccessToken token, bool writeAfterwards) { accessTokens.Remove(token); @@ -342,6 +342,13 @@ public async Task RevokeAccessToken(UserAccessToken token, bool writeAfterwards } + public async Task RevokeAccessToken(UserAccessToken token) + { + + await RevokeAccessToken(token, true); + + } + private void DetachAccessToken(UserAccessToken token) { From ddce3bb23da04680274efc3b6a50581682b9678c Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:54:24 -0500 Subject: [PATCH 33/55] Document everything in Server.Users --- PMDDesktop.Server.Users/IUserIndexable.cs | 6 + .../Roles/IRoleIndexable.cs | 6 + .../Roles/RoleReference.cs | 20 ++- .../Roles/UserPermission.cs | 18 ++- PMDDesktop.Server.Users/Roles/UserRole.cs | 74 +++++++++++ .../Roles/UserRoleConverter.cs | 27 ++++ .../Roles/UserRoleManager.cs | 40 +++++- PMDDesktop.Server.Users/User.cs | 120 ++++++++++++++++-- PMDDesktop.Server.Users/UserAccessToken.cs | 46 +++++++ PMDDesktop.Server.Users/UserConverter.cs | 27 ++++ .../UserCreationOptions.cs | 19 ++- PMDDesktop.Server.Users/UserManager.cs | 12 ++ PMDDesktop.Server.Users/UserReference.cs | 22 +++- 13 files changed, 412 insertions(+), 25 deletions(-) diff --git a/PMDDesktop.Server.Users/IUserIndexable.cs b/PMDDesktop.Server.Users/IUserIndexable.cs index 78474b5..ad4f65c 100644 --- a/PMDDesktop.Server.Users/IUserIndexable.cs +++ b/PMDDesktop.Server.Users/IUserIndexable.cs @@ -8,6 +8,12 @@ namespace PMDDesktop.Server.Users; public interface IUserIndexable { + /// + /// Try to get a by their . Will return false and will be null if not found. + /// + /// The of the you're looking for. + /// The found , if any was found. Will be null if the return was false. + /// True if the is found, and will not be null. bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user); } diff --git a/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs b/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs index 3613b50..a487434 100644 --- a/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs +++ b/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs @@ -8,6 +8,12 @@ namespace PMDDesktop.Server.Users.Roles; public interface IRoleIndexable { + /// + /// Try to get a by its . Will return false and will be null if not found. + /// + /// The of the you're looking for. + /// The found , if any was found. Will be null if the return was false. + /// True if the is found, and will not be null. bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role); } diff --git a/PMDDesktop.Server.Users/Roles/RoleReference.cs b/PMDDesktop.Server.Users/Roles/RoleReference.cs index da0235f..677d98c 100644 --- a/PMDDesktop.Server.Users/Roles/RoleReference.cs +++ b/PMDDesktop.Server.Users/Roles/RoleReference.cs @@ -7,9 +7,16 @@ namespace PMDDesktop.Server.Users.Roles; public sealed class RoleReference { + /// + /// Stored corresponding to . + /// public Guid GUID { get; private set; } - + /// + /// Create a new pointing to 's . + /// This allows you to find the again after serialization and deserialization. + /// + /// The you'd like to keep a reference to via . public RoleReference(UserRole initalValue) { @@ -17,6 +24,11 @@ public RoleReference(UserRole initalValue) } + /// + /// Create a new pointing exactly to + /// The existance of any with this is not checked until is called. + /// + /// The you'd like to point to. internal RoleReference(Guid directGuid) { @@ -24,6 +36,12 @@ internal RoleReference(Guid directGuid) } + /// + /// Try to get a from its . + /// + /// The to use to find . + /// The result of the search. Will be null if false is returned, and contain a if true. + /// True if the was found and has a value, otherwise false. public bool TryGetRole(IRoleIndexable indexable, [NotNullWhen(true)] out UserRole? role) { diff --git a/PMDDesktop.Server.Users/Roles/UserPermission.cs b/PMDDesktop.Server.Users/Roles/UserPermission.cs index 0c8519e..83d9bb8 100644 --- a/PMDDesktop.Server.Users/Roles/UserPermission.cs +++ b/PMDDesktop.Server.Users/Roles/UserPermission.cs @@ -14,13 +14,16 @@ static UserPermission() IEnumerable permissionFields = fields.Where(field => field.FieldType.IsAssignableTo(typeof(UserPermission))); IEnumerable permissionObjects = permissionFields.Select(field => - (UserPermission?) field.GetValue(ALL) + (UserPermission?)field.GetValue(ALL) ?? throw new NullReferenceException($"Unable to convert {field} to {nameof(UserPermission)}")); ALL = ImmutableArray.Create([.. permissionObjects]); } + /// + /// Array containing all objects. (Should not change after initial creation.) + /// public static readonly ImmutableArray ALL; public static readonly UserPermission MANAGE_USERS = new() @@ -44,24 +47,27 @@ static UserPermission() /// /// Name that is stored and compared to track permission in a . /// - public required string DataName {get; init;} + public required string DataName { get; init; } /// /// User-facing name that is displayed to users when viewing permissions. /// - public required string FriendlyName {get; init;} + public required string FriendlyName { get; init; } /// /// User-facing description for what a permission does. /// - public required string FriendlyDescription {get; init;} + public required string FriendlyDescription { get; init; } /// /// Weather to always grant this permission for admin users, to prevent losing ability to regain the permission. /// - public required bool AlwaysForAdmin {get; init;} + public required bool AlwaysForAdmin { get; init; } - public required bool EnabledForDefault {get; init;} + /// + /// Weather to enable this permission for the default role when first created. (Can still be disabled for that role afterwards.) + /// + public required bool EnabledForDefault { get; init; } private UserPermission() { } diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs index 26c2ee0..6333e77 100644 --- a/PMDDesktop.Server.Users/Roles/UserRole.cs +++ b/PMDDesktop.Server.Users/Roles/UserRole.cs @@ -10,13 +10,47 @@ public sealed class UserRole internal UserRole() { } + /// + /// A containing granted/revoked s. + /// An entry is true if this grants that , and false if it revokes the . + /// A non-existant entry states that the is unaffected. + /// internal Dictionary permissionSettings = []; + + /// + /// A string representing a 's name. + /// public string Name { get; internal set; } = "Unnamed Role"; + + /// + /// A unique for this . + /// This should be used for long-term references to a specific . + /// public Guid GUID { get; internal set; } = Guid.NewGuid(); + + /// + /// Weather this is the "default role." + /// + /// + /// Currently just checks if matches . + /// public bool IsDefaultRole { get => GUID == Guid.Empty; } + + /// + /// Attached that controls this . + /// public UserRoleManager? Manager { get; private set; } + + /// + /// Is/Was on the last used or currently active . + /// public bool? WritingEnabled { get; private set; } + /// + /// Set the of this . + /// + /// The new name of this . + /// Completes the task once the has saved. public async Task SetName(string newName) { @@ -26,6 +60,13 @@ public async Task SetName(string newName) } + /// + /// Set the state of in this . + /// Pass true to permit , false to revoke it, and null to not affect it. + /// + /// The to modify. + /// True if permission should be granted, False is permission should be revoked, null if permission should be unaffected. + /// Completes the task once the has saved. public async Task SetPermission(UserPermission permission, bool? preference) { @@ -39,6 +80,11 @@ public async Task SetPermission(UserPermission permission, bool? preference) } + /// + /// Get the state of in this . + /// + /// The to check. + /// True if permission is granted, False is permission is revoked, null if permission is unaffected. public bool? GetPermission(UserPermission permission) { @@ -49,6 +95,10 @@ public async Task SetPermission(UserPermission permission, bool? preference) } + /// + /// Create a new with and the s marked as . + /// + /// A new configured to be the default role. internal static UserRole CreateDefaultRole() { @@ -65,6 +115,11 @@ internal static UserRole CreateDefaultRole() } + /// + /// Attach to a to provide this 's data to the . + /// + /// The to attach to. + /// Throws if already attached to a . internal void AttachToManager(UserRoleManager newManager) { @@ -81,6 +136,13 @@ internal void AttachToManager(UserRoleManager newManager) } + /// + /// Detach from the attached to remove this 's data from the attached . + /// + /// Throws if not currently attached to a . + /// + /// This doesn't really need to be called on server shutdown. It is called internally when deleting a . + /// internal void DetachFromManager() { @@ -110,6 +172,11 @@ internal string GetRoleJSONPath() } + /// + /// Saves all data relating to this object. + /// + /// Task completes once data is written. + /// If the or if is null. [ExcludeFromCodeCoverage] internal async Task WriteNewData() { @@ -150,6 +217,13 @@ private async Task DeleteRoleFolder() } + /// + /// Try to remove this from its . + /// If the assigned has , then this function will also attempt to delete the role's file from the filesystem, so that it will not be restored in future sessions. + /// + /// + /// If is enabled, this function may still throw if an IO-related error occurs. + /// public async Task TryRemoveRole() { diff --git a/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs b/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs index e42d2c7..76ad4ab 100644 --- a/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs +++ b/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs @@ -6,7 +6,14 @@ namespace PMDDesktop.Server.Users.Roles; public class UserRoleConverter : JsonConverter { + /// + /// The property that the role's name will be stored in. + /// internal static readonly string NAME_PROPERTY = "Name"; + + /// + /// The property that the role's permissions will be stored in. + /// internal static readonly string PERMISSIONS_PROPERTY = "Permissions"; public override UserRole? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -49,6 +56,12 @@ public override void Write(Utf8JsonWriter writer, UserRole value, JsonSerializer } + /// + /// Write true/false/null for 's to . + /// + /// The to write this boolean/null to; expecting to be in an open object. + /// The to write about. + /// The coming from . private static void QuickWritePermission(Utf8JsonWriter writer, UserPermission permission, UserRole value) { @@ -61,6 +74,14 @@ private static void QuickWritePermission(Utf8JsonWriter writer, UserPermission p } + /// + /// Quickly read (string) from and remove from . + /// + /// The to read from. + /// The to remove from. + /// The to read from and to remove from . + /// Returns the read string. + /// If name is null. private static string QuickReadString(JsonDocument document, List propertyList, string name) { @@ -71,6 +92,12 @@ private static string QuickReadString(JsonDocument document, List proper } + /// + /// Read the permissions from and turn them into a . + /// + /// The to read from. + /// A containing modified permissions. + /// If a value wasn't true, false, or null. private static Dictionary ReadPermissions(JsonDocument document) { diff --git a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs index b97568a..efef5d4 100644 --- a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs +++ b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs @@ -5,9 +5,17 @@ namespace PMDDesktop.Server.Users.Roles; +/// +/// Manages s. Allows the creation of new objects and handles the saving/loading of them, as well as maintaining the order of roles. +/// public sealed class UserRoleManager : IRoleIndexable, IEnumerable { + /// + /// Creates a new that's paied with . + /// This will be tightly linked to the given + /// + /// The to pair with. internal UserRoleManager(UserManager userManager) { @@ -17,6 +25,9 @@ internal UserRoleManager(UserManager userManager) } + /// + /// The that's paired with this . + /// public UserManager Manager { get; private init; } /// @@ -49,6 +60,10 @@ internal UserRoleManager(UserManager userManager) /// internal Guid[] roleOrder = []; + /// + /// Loads from the "roles" directory and "roleOrder.json" file to retrieve saved role data. + /// + /// Task completes when loading is finished. internal async Task LoadFromFiles() { @@ -111,9 +126,9 @@ internal async Task LoadAndAddRoleJson(Stream stream, Guid guid) } /// - /// Gets the primary JSON that contains this 's data, located inside the folder. + /// Returns the path to the "roleOrder.json" file, intended to be used to save/load the order of roles. /// - /// A path pointing to where the 's json data is/should be stored. + /// A path poiting to the "roleOrder.json" file. internal static string GetOrderJSONPath() { @@ -121,6 +136,13 @@ internal static string GetOrderJSONPath() } + /// + /// Loads the role order from roleOrder.json. Skips if the file doesn't exist; otherwise, the file is also checked for validity and may throw. + /// + /// Task completes when role order is set or doesn't load. + /// + /// Calls to read the stream. + /// [ExcludeFromCodeCoverage] private async Task LoadRoleOrder() { @@ -136,6 +158,10 @@ private async Task LoadRoleOrder() } + /// + /// Loads the role order from the provided stream in Guid[] JSON format. JSON and order is checked for validity and may throw. + /// + /// Task completes when role order is set. internal async Task LoadRoleOrder(Stream stream) { @@ -153,7 +179,7 @@ internal async Task LoadRoleOrder(Stream stream) /// Will fail if duplicated GUIDs are provided, or GUIDs that do not correspond to s in this . /// /// - /// Task resolves once order is set and changes are saved. + /// Task completes once order is set and changes are saved. public async Task SetOrder(IEnumerable guidOrder) { @@ -178,6 +204,10 @@ public async Task SetOrder(IEnumerable guidOrder) } + /// + /// If is true, will write the current to "roleOrder.json". + /// + /// public async Task WriteNewRoleOrder() { @@ -195,6 +225,10 @@ public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role) return guids.TryGetValue(GUID, out role); } + /// + /// Try to create a new , will return null if it could not be done. It will have default settings and no permissions set. + /// + /// The created , or null if it failed. public async Task TryCreateRole() { diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index 79e3f02..068177a 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -10,6 +10,9 @@ namespace PMDDesktop.Server.Users; public sealed class User { + /// + /// to use to hash passwords. + /// private static PasswordHasher PASSWORD_HASHER = new(); /// @@ -59,20 +62,27 @@ internal User() internal string? HashedPassword { get; set; } /// - /// A unique for each . Stays consistant even when the 's handle changes. + /// A unique for this . Stays consistant even when the 's handle changes. /// This should be used for long-term references to a specific . /// [JsonIgnore] public Guid GUID { get; internal set; } = Guid.NewGuid(); /// - /// + /// Attached that controls this . /// [JsonIgnore] public UserManager? Manager { get; private set; } + /// + /// Is/Was on the last used or currently active . + /// public bool? WritingEnabled { get; private set; } + /// + /// List of s currently belonging to this . + /// May include expired s. + /// internal List accessTokens = []; /// @@ -83,6 +93,11 @@ internal User() #region Name + /// + /// Set the of this . + /// + /// The new name of the . + /// Completes task once changes are saved. public async Task SetName(string newName) { @@ -100,8 +115,8 @@ public async Task SetName(string newName) /// /// Salt and hash the user's password, then store it in HashedPassword. /// - /// - /// + /// The plaintext password to be hashed. + /// Completes task after saving completes. public async Task SetPassword(string plainText) { @@ -113,10 +128,10 @@ public async Task SetPassword(string plainText) } /// - /// + /// Try to set to the input. Will fail if it is not unique. /// - /// - /// + /// The string to change the new handle to. + /// True if the handle was changed and saved. public async Task TrySetLoginHandle(string? newHandle) { @@ -141,6 +156,9 @@ public async Task TrySetLoginHandle(string? newHandle) } + /// + /// Add the current to . + /// private void AttachLoginHandle() { @@ -151,6 +169,9 @@ private void AttachLoginHandle() } + /// + /// Remove the current from . + /// private void DetachLoginHandle() { @@ -217,6 +238,11 @@ public string GetUserJSONPath() } + /// + /// Saves all data relating to this object. + /// + /// Task completes once data is written. + /// If the or if is null. [ExcludeFromCodeCoverage] internal async Task WriteNewData() { @@ -243,7 +269,7 @@ internal async Task WriteNewData() /// /// Deletes the user folder and all *explicitly written* data inside it. /// - /// + /// Completes task once folder is deleted... or after it fails to delete due to having unmanaged filesystem entries. /// /// This function does not recursive delete. Instead it has a list of items to delete. This approach is taken to minimize data loss in the event that something goes wrong. /// @@ -301,6 +327,13 @@ public override int GetHashCode() #region Access Tokens + /// + /// Create a new for this user. + /// + /// Returns the new . + /// + /// Will remove existing access tokens if is exceeded. + /// public async Task CreateAccessToken() { @@ -320,6 +353,13 @@ public async Task CreateAccessToken() } + /// + /// Attaches to the attached . + /// + /// The to attach to . + /// + /// Attaching will be skipped if is null, so this is safe to call when detached. + /// private void AttachAccessToken(UserAccessToken token) { @@ -330,6 +370,12 @@ private void AttachAccessToken(UserAccessToken token) } + /// + /// Revokes , removes it from the , then (optionally) saves. + /// + /// The to revoke. + /// Should save afterwards? Always skipped if is null. + /// Completes task when saving completes. internal async Task RevokeAccessToken(UserAccessToken token, bool writeAfterwards) { @@ -342,6 +388,11 @@ internal async Task RevokeAccessToken(UserAccessToken token, bool writeAfterward } + /// + /// Revokes , removes it from the , then saves. + /// + /// The to revoke. + /// Completes task when saving completes. public async Task RevokeAccessToken(UserAccessToken token) { @@ -349,6 +400,13 @@ public async Task RevokeAccessToken(UserAccessToken token) } + /// + /// Detaches from the attached . + /// + /// The to detach to . + /// + /// Detaching will be skipped if is null, so this is safe to call when detached. + /// private void DetachAccessToken(UserAccessToken token) { @@ -356,6 +414,11 @@ private void DetachAccessToken(UserAccessToken token) } + /// + /// Attempt to revoke the oldest managed by this . Can optionally save afterwards. + /// + /// Should save afterwards? + /// Returns true if an access token was revoked. private async Task TryRevokeOldestAccessToken(bool writeAfterwards = true) { @@ -383,6 +446,11 @@ private async Task TryRevokeOldestAccessToken(bool writeAfterwards = true) #region Manager Attaching + /// + /// Attach to a to provide this 's data to the . + /// + /// The to attach to. + /// Throws if already attached to a . internal void AttachToManager(UserManager manager) { @@ -401,6 +469,13 @@ internal void AttachToManager(UserManager manager) } + /// + /// Detach from the attached to remove this 's data from the attached . + /// + /// Throws if not currently attached to a . + /// + /// This doesn't really need to be called on server shutdown. It is called internally when deleting a user. + /// internal void DetachFromManager() { @@ -452,6 +527,15 @@ public async Task TryRemoveUser() #region Role/Permission Management + /// + /// Try to add a to this . Allows the to be influenced by the 's s. + /// + /// The to add to the . + /// Returns true if it was added successfully. + /// + /// Primarily fails when the is already on the . + /// This also saves the user. + /// public async Task TryAddRole(UserRole role) { @@ -464,6 +548,15 @@ public async Task TryAddRole(UserRole role) } + /// + /// Try to remove a from this . Stops the from being influenced by the 's s. + /// + /// The to remove from the . + /// Returns true if it was removed successfully. + /// + /// Primarily fails when the does not have the . + /// This also saves the user. + /// public async Task TryRemoveRole(UserRole role) { @@ -476,6 +569,11 @@ public async Task TryRemoveRole(UserRole role) } + /// + /// Check if the has assigned to it. + /// + /// The to check for. + /// True if this user is assigned this role. public bool HasRole(UserRole role) { @@ -483,6 +581,12 @@ public bool HasRole(UserRole role) } + /// + /// Does this , looking at the assigned s with respect to 's role order, have ? + /// + /// + /// Returns true if this user has the . + /// Throws if not currently attached to a . public bool HasPermission(UserPermission permission) { diff --git a/PMDDesktop.Server.Users/UserAccessToken.cs b/PMDDesktop.Server.Users/UserAccessToken.cs index ff4f71f..5fa64fb 100644 --- a/PMDDesktop.Server.Users/UserAccessToken.cs +++ b/PMDDesktop.Server.Users/UserAccessToken.cs @@ -5,9 +5,19 @@ namespace PMDDesktop.Server.Users; public sealed class UserAccessToken { + /// + /// The time until a expires. + /// public static readonly TimeSpan TIME_UNTIL_EXPIRE = new(8, 0, 0, 0); // 8 days. + + /// + /// The time until the that at which point the token should be refreshed. + /// public static readonly TimeSpan REFRESH_THRESHOLD = new(7, 0, 0, 0); // 7 Days. // Ideally if you log in once a week, you'll stay logged in. + /// + /// How many random characters should be in our + /// public static readonly int RANDOM_CHAR_COUNT = 64; public static readonly char[] RANDOM_CHARS = [ @@ -17,6 +27,12 @@ public sealed class UserAccessToken '-', '_' ]; + /// + /// Create a new by manually specifying the , , and . + /// + /// The of this . + /// The of this . + /// The of this . internal UserAccessToken(string token, User forUser, DateTime expires) { @@ -26,6 +42,11 @@ internal UserAccessToken(string token, User forUser, DateTime expires) } + /// + /// Create a new, randomized for . + /// + /// The that this is intended for. + /// internal static UserAccessToken CreateNewToken(User forUser) { @@ -35,12 +56,25 @@ internal static UserAccessToken CreateNewToken(User forUser) } + /// + /// The that this expires. + /// public DateTime ExpiryDate { get; private set; } + /// + /// The unique used to access this . + /// public string TokenString { get; private set; } + /// + /// The that this points to. + /// public User User { get; internal set; } + /// + /// Check if this needs a refresh according to . + /// + /// Returns true if this tokens needs a refresh. public bool NeedsRefresh() { @@ -48,6 +82,10 @@ public bool NeedsRefresh() } + /// + /// Refresh and tell the to save. + /// + /// Completes once the saving is finished. public async Task RefreshToken() { @@ -57,6 +95,10 @@ public async Task RefreshToken() } + /// + /// Check if this is currently valid. (Not expired) + /// + /// True if this token can be used. public bool IsTokenValid() { @@ -66,6 +108,10 @@ public bool IsTokenValid() } + /// + /// Tell to revoke this . + /// + /// Completes task once the token is revoked. public async Task RevokeToken() { diff --git a/PMDDesktop.Server.Users/UserConverter.cs b/PMDDesktop.Server.Users/UserConverter.cs index ae021f0..a09f887 100644 --- a/PMDDesktop.Server.Users/UserConverter.cs +++ b/PMDDesktop.Server.Users/UserConverter.cs @@ -57,6 +57,11 @@ public override void Write(Utf8JsonWriter writer, User value, JsonSerializerOpti } + /// + /// Writes a single JSON object. + /// + /// The to write to. + /// The to write. private static void WriteToken(Utf8JsonWriter writer, UserAccessToken token) { @@ -69,6 +74,14 @@ private static void WriteToken(Utf8JsonWriter writer, UserAccessToken token) } + /// + /// Quickly read (non-nullable string) from and remove from . + /// + /// The to read from. + /// The to remove from. + /// The to read from and to remove from . + /// Returns the read string. + /// If name is null. private static string QuickReadString(JsonDocument document, List propertyList, string name) { @@ -79,6 +92,13 @@ private static string QuickReadString(JsonDocument document, List proper } + /// + /// Quickly read (nullable string) from and remove from . + /// + /// The to read from. + /// The to remove from. + /// The to read from and to remove from . + /// Returns the read string, or null if it is null. private static string? QuickReadNullableString(JsonDocument document, List propertyList, string name) { @@ -88,6 +108,13 @@ private static string QuickReadString(JsonDocument document, List proper } + /// + /// Reads the array of JSON objects, and returns a based on it. + /// + /// The of a . + /// The object to assign the created s to. + /// Returns a of read s. + /// Throws if any read token string was null. private static List ReadTokenArray(JsonDocument document, User user) { diff --git a/PMDDesktop.Server.Users/UserCreationOptions.cs b/PMDDesktop.Server.Users/UserCreationOptions.cs index 05b4968..dd769d4 100644 --- a/PMDDesktop.Server.Users/UserCreationOptions.cs +++ b/PMDDesktop.Server.Users/UserCreationOptions.cs @@ -1,10 +1,21 @@ -namespace PMDDesktop.Server.Users; +namespace PMDDesktop.Server.Users; public sealed record class UserCreationOptions { - public required string DisplayName {get; set;} - public string? PlainTextPassword {get; set;} - public string? LoginHandle {get; set;} + /// + /// The initial value to set . + /// + public required string DisplayName { get; set; } + + /// + /// The value to hash to create . + /// + public string? PlainTextPassword { get; set; } + + /// + /// The initial value to set . Must be unique. + /// + public string? LoginHandle { get; set; } } diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs index 62c767e..e2264e5 100644 --- a/PMDDesktop.Server.Users/UserManager.cs +++ b/PMDDesktop.Server.Users/UserManager.cs @@ -181,6 +181,12 @@ public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user) } + /// + /// Try to get a by their . Will return false and will be null if not found. + /// + /// The of the you're looking for. + /// The found , if any was found. Will be null if the return was false. + /// True if the is found, and will not be null. public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User? user) { @@ -188,6 +194,12 @@ public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User? } + /// + /// Try to create a new , will return null if it could not be done. + /// + /// to specify how the should be created. + /// The created , or null if it failed. + /// Primary failing condition is if the is not unique. public async Task TryCreateUser(UserCreationOptions options) { diff --git a/PMDDesktop.Server.Users/UserReference.cs b/PMDDesktop.Server.Users/UserReference.cs index 86011b9..d974a17 100644 --- a/PMDDesktop.Server.Users/UserReference.cs +++ b/PMDDesktop.Server.Users/UserReference.cs @@ -7,9 +7,16 @@ namespace PMDDesktop.Server.Users; public sealed class UserReference { + /// + /// Stored corresponding to . + /// public Guid GUID { get; private set; } - + /// + /// Create a new pointing to 's . + /// This allows you to find the again after serialization and deserialization. + /// + /// The you'd like to keep a reference to via . public UserReference(User initalValue) { @@ -17,7 +24,11 @@ public UserReference(User initalValue) } - + /// + /// Create a new pointing exactly to + /// The existance of any with this is not checked until is called. + /// + /// The you'd like to point to. internal UserReference(Guid directGuid) { @@ -25,7 +36,12 @@ internal UserReference(Guid directGuid) } - + /// + /// Try to get a from its . + /// + /// The to use to find . + /// The result of the search. Will be null if false is returned, and contain a if true. + /// True if the was found and has a value, otherwise false. public bool TryGetUser(IUserIndexable indexable, [NotNullWhen(true)] out User? user) { From 4667d875179bfd8c38c7879949bb36a521c4a45f Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:56:04 -0500 Subject: [PATCH 34/55] Check if token needs refresh before doing it --- PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs index ee6cecf..9dfdd1a 100644 --- a/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs @@ -160,7 +160,8 @@ public async Task HandleRequest(HttpContext context, GameServer game) } if (IRequestHandler.TryGetUserFromContext(game.State.Users, context, out User? user, out UserAccessToken? token)) - await token.RefreshToken(); + if (token.NeedsRefresh()) + await token.RefreshToken(); try { From 5afdd30b9673bc5feb4d269ea5541128a6ae6814 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:15:11 -0500 Subject: [PATCH 35/55] Extract creation of UserInfoResponse into function --- PMDDesktop.Server.Users/User.cs | 7 +++++ .../GenericResponseCreators.cs | 27 +++++++++++++++++++ .../Users/GetUserInfoRequestHandler.cs | 13 +++++---- .../Requests/Users/GetUserInfoRequest.cs | 2 +- ...serInfoResponse.cs => UserInfoResponse.cs} | 4 ++- 5 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs rename PMDDesktop/Requests/Users/{GetUserInfoResponse.cs => UserInfoResponse.cs} (50%) diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index 068177a..0561fef 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -211,6 +211,13 @@ public async Task VerifyPassword(string plainText) } + public bool HasPassword() + { + + return HashedPassword is not null; + + } + #endregion #region Saving diff --git a/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs b/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs new file mode 100644 index 0000000..7004d7f --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs @@ -0,0 +1,27 @@ +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers; + +internal static class GenericResponseCreators +{ + + internal static UserInfoResponse CreateUserInfoResponse(User fromPOV, User ofUser) + { + + bool canSeeLoginInfo = fromPOV.HasPermission(UserPermission.MANAGE_USERS); + + return new() + { + + GUID = ofUser.GUID, + DisplayName = ofUser.Name, + HasPassword = canSeeLoginInfo ? ofUser.HasPassword() : null, + LoginHandle = canSeeLoginInfo ? ofUser.LoginHandle : null + + }; + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs index b2ccf27..d164118 100644 --- a/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs @@ -4,20 +4,19 @@ namespace PMDDesktop.Server.RequestHandlers.Users; -public class GetUserInfoRequestHandler : JsonRequestHandler +public class GetUserInfoRequestHandler : JsonRequestHandler { - protected override async Task CreateResponse(GetUserInfoRequest request, GameServer game, User? requestingUser) + protected override async Task CreateResponse(GetUserInfoRequest request, GameServer game, User? requestingUser) { + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + if (!game.TryGetUser(request.GUID, out User? user)) throw new UserRequestException($"{request.GUID} does not match a user."); - return new() - { - DisplayName = user.Name, - GUID = user.GUID - }; + return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user); } diff --git a/PMDDesktop/Requests/Users/GetUserInfoRequest.cs b/PMDDesktop/Requests/Users/GetUserInfoRequest.cs index 78d431d..80c4596 100644 --- a/PMDDesktop/Requests/Users/GetUserInfoRequest.cs +++ b/PMDDesktop/Requests/Users/GetUserInfoRequest.cs @@ -1,7 +1,7 @@ namespace PMDDesktop.Requests.Users; [ServerRequest("/api/users/get-info")] -public sealed class GetUserInfoRequest : ServerRequest +public sealed class GetUserInfoRequest : ServerRequest { [QueryParameter] diff --git a/PMDDesktop/Requests/Users/GetUserInfoResponse.cs b/PMDDesktop/Requests/Users/UserInfoResponse.cs similarity index 50% rename from PMDDesktop/Requests/Users/GetUserInfoResponse.cs rename to PMDDesktop/Requests/Users/UserInfoResponse.cs index 7583ea9..a143260 100644 --- a/PMDDesktop/Requests/Users/GetUserInfoResponse.cs +++ b/PMDDesktop/Requests/Users/UserInfoResponse.cs @@ -1,9 +1,11 @@ namespace PMDDesktop.Requests.Users; -public sealed class GetUserInfoResponse : ServerResponse +public sealed class UserInfoResponse : ServerResponse { public required string DisplayName { get; set; } public required Guid GUID { get; set; } + public string? LoginHandle { get; set; } + public bool? HasPassword { get; set; } } From d3bff4b32f74c6995a6c7f07649f7a9cb7939d11 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:50:26 -0500 Subject: [PATCH 36/55] Add IRoleIndexable to GameServer and GameState --- PMDDesktop.Server/Game/GameServer.cs | 9 ++++++++- PMDDesktop.Server/Game/GameState.cs | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/PMDDesktop.Server/Game/GameServer.cs b/PMDDesktop.Server/Game/GameServer.cs index 5e29772..df122b2 100644 --- a/PMDDesktop.Server/Game/GameServer.cs +++ b/PMDDesktop.Server/Game/GameServer.cs @@ -1,11 +1,12 @@ using PMDDesktop.Server.Assets; using PMDDesktop.Server.Saving; using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; using System.Diagnostics.CodeAnalysis; namespace PMDDesktop.Server.Game; -public class GameServer : IAssetIndexable, ISaveDataIndexable, IUserIndexable +public class GameServer : IAssetIndexable, ISaveDataIndexable, IUserIndexable, IRoleIndexable { public GameServer() @@ -79,4 +80,10 @@ public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user) { return State.TryGetUser(GUID, out user); } + + public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role) + { + return ((IRoleIndexable)State).TryGetRole(GUID, out role); + } + } diff --git a/PMDDesktop.Server/Game/GameState.cs b/PMDDesktop.Server/Game/GameState.cs index 1aa0c65..0250dba 100644 --- a/PMDDesktop.Server/Game/GameState.cs +++ b/PMDDesktop.Server/Game/GameState.cs @@ -6,7 +6,7 @@ namespace PMDDesktop.Server.Game; -public class GameState : IAssetIndexable, ISaveDataIndexable, IUserIndexable +public class GameState : IAssetIndexable, ISaveDataIndexable, IUserIndexable, IRoleIndexable { public GameState() @@ -66,6 +66,11 @@ public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user) return Users.TryGetUser(GUID, out user); } + public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role) + { + return Roles.TryGetRole(GUID, out role); + } + public SaveDataManager Saves { get; } public AssetManager Assets { get; } public UserManager Users { get; } From a9c7a0f22c8ee50725850abbbb79a534cc657b43 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:51:22 -0500 Subject: [PATCH 37/55] Split UserTests between UserManagerTests --- .../UserManagerTests.cs | 33 +++++++++++++++++++ PMDDesktop.Server.Users.Tests/UserTests.cs | 18 ---------- 2 files changed, 33 insertions(+), 18 deletions(-) create mode 100644 PMDDesktop.Server.Users.Tests/UserManagerTests.cs diff --git a/PMDDesktop.Server.Users.Tests/UserManagerTests.cs b/PMDDesktop.Server.Users.Tests/UserManagerTests.cs new file mode 100644 index 0000000..1345b05 --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/UserManagerTests.cs @@ -0,0 +1,33 @@ +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.Users.Tests; + +public class UserManagerTests +{ + + public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new() + { + DisplayName = "Test User", + LoginHandle = "test", + PlainTextPassword = "Test123" + }; + + [Fact] + public async Task GetViaGUID() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + Guid guid = user.GUID; + + Assert.True(manager.TryGetUser(guid, out User? guidUser)); + Assert.NotNull(guidUser); + + Assert.False(manager.TryGetUser(Guid.Empty, out User? notGuidUser)); + Assert.Null(notGuidUser); + + } + +} diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs index 7ec9ff5..5963ea5 100644 --- a/PMDDesktop.Server.Users.Tests/UserTests.cs +++ b/PMDDesktop.Server.Users.Tests/UserTests.cs @@ -88,24 +88,6 @@ public async Task LoginHandleChanges() } - [Fact] - public async Task GetViaGUID() - { - - UserManager manager = new(); - User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); - Assert.NotNull(user); - - Guid guid = user.GUID; - - Assert.True(manager.TryGetUser(guid, out User? guidUser)); - Assert.NotNull(guidUser); - - Assert.False(manager.TryGetUser(Guid.Empty, out User? notGuidUser)); - Assert.Null(notGuidUser); - - } - [Fact] public async Task IsTokenLimitEnforced() { From e7b30c3830936a5e04b802a3467d547bca9e0226 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:00:15 -0500 Subject: [PATCH 38/55] Add Admin users and Tests --- PMDDesktop.Server.Users.Tests/UserTests.cs | 23 +++++++++++++++++ PMDDesktop.Server.Users/User.cs | 25 +++++++++++++++++++ PMDDesktop.Server.Users/UserConverter.cs | 20 ++++++++++++++- .../UserCreationOptions.cs | 2 ++ PMDDesktop.Server.Users/UserManager.cs | 3 ++- 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs index 5963ea5..e83ecb7 100644 --- a/PMDDesktop.Server.Users.Tests/UserTests.cs +++ b/PMDDesktop.Server.Users.Tests/UserTests.cs @@ -241,4 +241,27 @@ public async Task UserPermissionsRespectHierarchy() } + [Fact] + public async Task AdminUserGetsPermissions() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + // This test assumes that MANAGE_ROLE has AlwaysForAdmin enabled. + Assert.True(UserPermission.MANAGE_ROLES.AlwaysForAdmin); + + Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES)); + + await user.SetAdmin(true); + + Assert.True(user.HasPermission(UserPermission.MANAGE_ROLES)); + + await user.SetAdmin(false); + + Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES)); + + } + } diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index 0561fef..f49963d 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -79,6 +79,11 @@ internal User() /// public bool? WritingEnabled { get; private set; } + /// + /// Specifies if this user is affected by . + /// + public bool IsAdmin { get; internal set; } + /// /// List of s currently belonging to this . /// May include expired s. @@ -600,6 +605,11 @@ public bool HasPermission(UserPermission permission) if (Manager is null) throw new NullReferenceException($"Can't check if {this} has a permission if {nameof(Manager)} is null!"); + // Is this an admin permission? + // Checked after Manager because we shouldn't be checking for permissions on a detached user anyways. + if (permission.AlwaysForAdmin && IsAdmin) + return true; + foreach (UserRole role in Manager.RoleManager) { @@ -617,6 +627,21 @@ public bool HasPermission(UserPermission permission) } + /// + /// Set whether this is an admin and is affected by . + /// + /// Whether or not this should be an admin. + /// Completes task once changes are saved. + public async Task SetAdmin(bool newState) + { + + IsAdmin = newState; + + if (Manager is not null) + await WriteNewData(); + + } + #endregion } diff --git a/PMDDesktop.Server.Users/UserConverter.cs b/PMDDesktop.Server.Users/UserConverter.cs index a09f887..8b22e8f 100644 --- a/PMDDesktop.Server.Users/UserConverter.cs +++ b/PMDDesktop.Server.Users/UserConverter.cs @@ -23,7 +23,8 @@ public override User Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSer { Name = QuickReadString(json, propertyList, nameof(User.Name)), HashedPassword = QuickReadNullableString(json, propertyList, nameof(User.HashedPassword)), - LoginHandle = QuickReadNullableString(json, propertyList, nameof(User.LoginHandle)) + LoginHandle = QuickReadNullableString(json, propertyList, nameof(User.LoginHandle)), + IsAdmin = QuickReadBoolean(json, propertyList, nameof(User.IsAdmin)) }; propertyList.Remove(TOKENS_PROPERTY_NAME); @@ -47,6 +48,7 @@ public override void Write(Utf8JsonWriter writer, User value, JsonSerializerOpti writer.WriteString(nameof(User.Name), value.Name); writer.WriteString(nameof(User.HashedPassword), value.HashedPassword); writer.WriteString(nameof(User.LoginHandle), value.LoginHandle); + writer.WriteBoolean(nameof(User.IsAdmin), value.IsAdmin); writer.WriteStartArray(TOKENS_PROPERTY_NAME); foreach (UserAccessToken token in value.accessTokens) @@ -108,6 +110,22 @@ private static string QuickReadString(JsonDocument document, List proper } + /// + /// Quickly read (boolean) from and remove from . + /// + /// The to read from. + /// The to remove from. + /// The to read from and to remove from . + /// Returns the read boolean. + private static bool QuickReadBoolean(JsonDocument document, List propertyList, string name) + { + + propertyList.Remove(name); + + return document.RootElement.GetProperty(name).GetBoolean(); + + } + /// /// Reads the array of JSON objects, and returns a based on it. /// diff --git a/PMDDesktop.Server.Users/UserCreationOptions.cs b/PMDDesktop.Server.Users/UserCreationOptions.cs index dd769d4..c270313 100644 --- a/PMDDesktop.Server.Users/UserCreationOptions.cs +++ b/PMDDesktop.Server.Users/UserCreationOptions.cs @@ -18,4 +18,6 @@ public sealed record class UserCreationOptions /// public string? LoginHandle { get; set; } + public bool IsAdmin { get; set; } = false; + } diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs index e2264e5..af7470b 100644 --- a/PMDDesktop.Server.Users/UserManager.cs +++ b/PMDDesktop.Server.Users/UserManager.cs @@ -205,7 +205,8 @@ public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User? User user = new() { - Name = options.DisplayName + Name = options.DisplayName, + IsAdmin = options.IsAdmin }; if (options.LoginHandle is not null) From 7570b09bd84dab94fb7876dd249f5005c82bfb2a Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:04:10 -0500 Subject: [PATCH 39/55] Add default admin user. (For when files loaded with no users) --- .../UserManagerTests.cs | 13 ++++++++++ PMDDesktop.Server.Users/UserManager.cs | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/PMDDesktop.Server.Users.Tests/UserManagerTests.cs b/PMDDesktop.Server.Users.Tests/UserManagerTests.cs index 1345b05..9eddc48 100644 --- a/PMDDesktop.Server.Users.Tests/UserManagerTests.cs +++ b/PMDDesktop.Server.Users.Tests/UserManagerTests.cs @@ -30,4 +30,17 @@ public async Task GetViaGUID() } + [Fact] + public async Task DefaultAdminUserIsAdmin() + { + + UserManager manager = new(); + + User? user = await manager.TryCreateDefaultAdminUser(); + Assert.NotNull(user); + + Assert.True(user.IsAdmin); + + } + } diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs index af7470b..1cfdff9 100644 --- a/PMDDesktop.Server.Users/UserManager.cs +++ b/PMDDesktop.Server.Users/UserManager.cs @@ -8,6 +8,16 @@ namespace PMDDesktop.Server.Users; public sealed class UserManager : IEnumerable, IUserIndexable, IRoleIndexable { + private static readonly UserCreationOptions DEFAULT_ADMIN_OPTIONS = new() + { + + DisplayName = "Default Admin User", + LoginHandle = "admin", + PlainTextPassword = "admin", + IsAdmin = true + + }; + public UserManager() { @@ -102,6 +112,10 @@ private async Task LoadAllUserData() } + // If no users were loaded, make a default Admin user to ensure there's a way to log in. + if (guids.Count == 0) + await TryCreateDefaultAdminUser(); + } /// @@ -221,6 +235,18 @@ public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User? } + /// + /// Create a new User account with handle "admin" and password "admin" and admin permissions. + /// Useful when starting the server for the first time without any users. + /// + /// + internal async Task TryCreateDefaultAdminUser() + { + + return await TryCreateUser(DEFAULT_ADMIN_OPTIONS); + + } + public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role) { return RoleManager.TryGetRole(GUID, out role); From f25ae0fa0ee8ffc0125691ccaa9ce4ec198b3ac6 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:37:10 -0500 Subject: [PATCH 40/55] Create directories before writing files. --- PMDDesktop.Server.Users/Roles/UserRole.cs | 10 +++++++++- PMDDesktop.Server.Users/User.cs | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs index 6333e77..805db67 100644 --- a/PMDDesktop.Server.Users/Roles/UserRole.cs +++ b/PMDDesktop.Server.Users/Roles/UserRole.cs @@ -168,7 +168,14 @@ internal void DetachFromManager() internal string GetRoleJSONPath() { - return Path.Combine(AppContext.BaseDirectory, "roles", $"{GUID}.json"); + return Path.Combine(GetRoleFolder(), $"{GUID}.json"); + + } + + static internal string GetRoleFolder() + { + + return Path.Combine(AppContext.BaseDirectory, "roles"); } @@ -190,6 +197,7 @@ internal async Task WriteNewData() if (WritingEnabled != true) return; + Directory.CreateDirectory(GetRoleFolder()); using FileStream jsonFile = File.Create(GetRoleJSONPath()); await JsonSerializer.SerializeAsync(jsonFile, this, AppInfo.JSON_OPTIONS); diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index f49963d..89a21e5 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -272,6 +272,7 @@ internal async Task WriteNewData() if (WritingEnabled != true) return; + Directory.CreateDirectory(GetUserFolder()); using FileStream jsonFile = File.Create(GetUserJSONPath()); await JsonSerializer.SerializeAsync(jsonFile, this, AppInfo.JSON_OPTIONS); From ce22554fa8b3c0928f08f9a987bf4e327eb56219 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:00:21 -0500 Subject: [PATCH 41/55] Add endpoint to setting user names. --- .../Users/SetUserNameRequestHandler.cs | 31 +++++++++++++++++++ .../Requests/Users/SetUserNameRequest.cs | 11 +++++++ 2 files changed, 42 insertions(+) create mode 100644 PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs create mode 100644 PMDDesktop/Requests/Users/SetUserNameRequest.cs diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs new file mode 100644 index 0000000..c0b961a --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs @@ -0,0 +1,31 @@ +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Users; + +public class SetUserNameRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(SetUserNameRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + if (!game.TryGetUser(request.GUID, out User? user)) + throw new UserRequestException($"{request.GUID} does not match a user."); + + // To change the username of someone who isn't yourself, you need the MANAGE_USERS permission. + if (user != requestingUser) + if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS)) + throw new UserRequestException($"You need permission to manage users to access this endpoint."); + + await user.SetName(request.NewName); + + return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user); + + } + +} diff --git a/PMDDesktop/Requests/Users/SetUserNameRequest.cs b/PMDDesktop/Requests/Users/SetUserNameRequest.cs new file mode 100644 index 0000000..0fc1737 --- /dev/null +++ b/PMDDesktop/Requests/Users/SetUserNameRequest.cs @@ -0,0 +1,11 @@ +namespace PMDDesktop.Requests.Users; + +[ServerRequest("/api/users/set-name")] +public sealed class SetUserNameRequest : ServerRequest +{ + + public required Guid GUID { get; set; } + + public required string NewName { get; set; } + +} From d8c9a0f3ad1b37060a9f3c098d67dfe3e9a297cc Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:23:03 -0500 Subject: [PATCH 42/55] Allow SetPassword(string?) to take null. --- PMDDesktop.Server.Users/User.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index 89a21e5..cf379bc 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -120,12 +120,15 @@ public async Task SetName(string newName) /// /// Salt and hash the user's password, then store it in HashedPassword. /// - /// The plaintext password to be hashed. + /// The plaintext password to be hashed. /// Completes task after saving completes. - public async Task SetPassword(string plainText) + public async Task SetPassword(string? newPassword) { - HashedPassword = PASSWORD_HASHER.HashPassword(this, plainText); + if (newPassword is string passwordText) + HashedPassword = PASSWORD_HASHER.HashPassword(this, passwordText); + else + HashedPassword = null; if (Manager is not null) await WriteNewData(); From 82f0fef31f42c3c1b19409655353b74b8d2ca666 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:30:07 -0500 Subject: [PATCH 43/55] Add endpoints for changing password & handle. --- .../Users/SetUserLoginHandleRequestHandler.cs | 39 +++++++++++++++++++ .../Users/SetUserPasswordRequestHandler.cs | 31 +++++++++++++++ .../Users/SetUserLoginHandleRequest.cs | 19 +++++++++ .../Requests/Users/SetUserPasswordRequest.cs | 18 +++++++++ 4 files changed, 107 insertions(+) create mode 100644 PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs create mode 100644 PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs create mode 100644 PMDDesktop/Requests/Users/SetUserLoginHandleRequest.cs create mode 100644 PMDDesktop/Requests/Users/SetUserPasswordRequest.cs diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs new file mode 100644 index 0000000..7ab3bd9 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs @@ -0,0 +1,39 @@ +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Users; + +public class SetUserLoginHandleRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(SetUserLoginHandleRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + if (!game.TryGetUser(request.GUID, out User? user)) + throw new UserRequestException($"{request.GUID} does not match a user."); + + // To change the login handle of someone who isn't yourself, you need the MANAGE_USERS permission. + if (user != requestingUser) + if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS)) + throw new UserRequestException($"You need permission to manage users to access this endpoint."); + + // TEMPORARY SOLUTION + // TODO: Replace this with a more elegant solution. I think we should make a new class or struct to hold these types of super normalized strings. + bool validHandle = request.NewLoginHandle is null || request.NewLoginHandle.All(letter => char.IsAsciiLetterLower(letter) || char.IsAsciiDigit(letter) || letter == '-'); + + if (!validHandle) + throw new UserRequestException($"The handle is invalid. Please make sure you use lowercase letters, numbers, or hyphens."); + + if (!await user.TrySetLoginHandle(request.NewLoginHandle)) + throw new UserRequestException($"Unable to set the login handle to '{request.NewLoginHandle}'. It may not be unique."); + + return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user); + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs new file mode 100644 index 0000000..3e4fd48 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs @@ -0,0 +1,31 @@ +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Users; + +public class SetUserPasswordRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(SetUserPasswordRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + if (!game.TryGetUser(request.GUID, out User? user)) + throw new UserRequestException($"{request.GUID} does not match a user."); + + // To change the login handle of someone who isn't yourself, you need the MANAGE_USERS permission. + if (user != requestingUser) + if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS)) + throw new UserRequestException($"You need permission to manage users to access this endpoint."); + + await user.SetPassword(request.NewPassword); + + return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user); + + } + +} diff --git a/PMDDesktop/Requests/Users/SetUserLoginHandleRequest.cs b/PMDDesktop/Requests/Users/SetUserLoginHandleRequest.cs new file mode 100644 index 0000000..e0a55d9 --- /dev/null +++ b/PMDDesktop/Requests/Users/SetUserLoginHandleRequest.cs @@ -0,0 +1,19 @@ +namespace PMDDesktop.Requests.Users; + +[ServerRequest("/api/users/set-handle")] +public sealed class SetUserLoginHandleRequest : ServerRequest +{ + + /// + /// The of the user you're trying to set the login handle of. + /// + public required Guid GUID { get; set; } + + /// + /// The login handle for the account. + /// Can be null to unset the handle (to prevent logins with handle/password). + /// TEMP: handles must be lowercase, 0-9, or hyphen (-). + /// + public required string? NewLoginHandle { get; set; } + +} diff --git a/PMDDesktop/Requests/Users/SetUserPasswordRequest.cs b/PMDDesktop/Requests/Users/SetUserPasswordRequest.cs new file mode 100644 index 0000000..f8b061c --- /dev/null +++ b/PMDDesktop/Requests/Users/SetUserPasswordRequest.cs @@ -0,0 +1,18 @@ +namespace PMDDesktop.Requests.Users; + +[ServerRequest("/api/users/set-password")] +public sealed class SetUserPasswordRequest : ServerRequest +{ + + /// + /// The of the user you're trying to set the password of. + /// + public required Guid GUID { get; set; } + + /// + /// The new password for the account (plain text; hashed server-side). + /// Can be null to unset the password (to prevent logins with handle/password). + /// + public required string? NewPassword { get; set; } + +} From e700a88f932ac5b8738491b4068089eae848f5f5 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:49:34 -0500 Subject: [PATCH 44/55] Add endpoint to create users. --- .../Users/CreateUserRequestHandler.cs | 45 +++++++++++++++++++ .../Requests/Users/CreateUserRequest.cs | 13 ++++++ 2 files changed, 58 insertions(+) create mode 100644 PMDDesktop.Server/RequestHandlers/Users/CreateUserRequestHandler.cs create mode 100644 PMDDesktop/Requests/Users/CreateUserRequest.cs diff --git a/PMDDesktop.Server/RequestHandlers/Users/CreateUserRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/CreateUserRequestHandler.cs new file mode 100644 index 0000000..7246c19 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Users/CreateUserRequestHandler.cs @@ -0,0 +1,45 @@ +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Users; + +public class CreateUserRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(CreateUserRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + // To create users, you need the MANAGE_USERS permission. + if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS)) + throw new UserRequestException($"You need permission to manage users to access this endpoint."); + + // TEMPORARY SOLUTION + // TODO: Replace this with a more elegant solution. I think we should make a new class or struct to hold these types of super normalized strings. + bool validHandle = request.NewLoginHandle is null || request.NewLoginHandle.All(letter => char.IsAsciiLetterLower(letter) || char.IsAsciiDigit(letter) || letter == '-'); + + if (!validHandle) + throw new UserRequestException($"The handle is invalid. Please make sure you use lowercase letters, numbers, or hyphens."); + + if (request.NewLoginHandle is not null && game.State.Users.TryGetUserByLoginHandle(request.NewLoginHandle, out _)) + throw new UserRequestException($"Provided login handle '{request.NewLoginHandle}' is already in use."); + + UserCreationOptions options = new() + { + DisplayName = request.NewName, + LoginHandle = request.NewLoginHandle, + PlainTextPassword = request.NewPassword + }; + + User user = await game.State.Users.TryCreateUser(options) + ?? throw new Exception($"Unable to create user."); + + return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user); + + } + +} diff --git a/PMDDesktop/Requests/Users/CreateUserRequest.cs b/PMDDesktop/Requests/Users/CreateUserRequest.cs new file mode 100644 index 0000000..f91ea13 --- /dev/null +++ b/PMDDesktop/Requests/Users/CreateUserRequest.cs @@ -0,0 +1,13 @@ +namespace PMDDesktop.Requests.Users; + +[ServerRequest("/api/users/create")] +public sealed class CreateUserRequest : ServerRequest +{ + + public required string NewName { get; set; } + + public required string? NewLoginHandle { get; set; } + + public required string? NewPassword { get; set; } + +} From 2e354fff19c5c7c3c682766a467e98e80ad5cdeb Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:11:39 -0500 Subject: [PATCH 45/55] Add endpoint to list users. --- .../Users/ListUsersRequestHandler.cs | 23 +++++++++++++++++++ PMDDesktop/Requests/Users/ListUsersRequest.cs | 7 ++++++ .../Requests/Users/ListUsersResponse.cs | 11 +++++++++ 3 files changed, 41 insertions(+) create mode 100644 PMDDesktop.Server/RequestHandlers/Users/ListUsersRequestHandler.cs create mode 100644 PMDDesktop/Requests/Users/ListUsersRequest.cs create mode 100644 PMDDesktop/Requests/Users/ListUsersResponse.cs diff --git a/PMDDesktop.Server/RequestHandlers/Users/ListUsersRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/ListUsersRequestHandler.cs new file mode 100644 index 0000000..db728de --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Users/ListUsersRequestHandler.cs @@ -0,0 +1,23 @@ +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; + +namespace PMDDesktop.Server.RequestHandlers.Users; + +public class ListUsersRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(ListUsersRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + return new() + { + Users = [.. game.State.Users.Select(user => GenericResponseCreators.CreateUserInfoResponse(requestingUser, user))] + }; + + } + +} diff --git a/PMDDesktop/Requests/Users/ListUsersRequest.cs b/PMDDesktop/Requests/Users/ListUsersRequest.cs new file mode 100644 index 0000000..2e3e7d3 --- /dev/null +++ b/PMDDesktop/Requests/Users/ListUsersRequest.cs @@ -0,0 +1,7 @@ +namespace PMDDesktop.Requests.Users; + +[ServerRequest("/api/users/list")] +public sealed class ListUsersRequest : ServerRequest +{ + +} diff --git a/PMDDesktop/Requests/Users/ListUsersResponse.cs b/PMDDesktop/Requests/Users/ListUsersResponse.cs new file mode 100644 index 0000000..fa84038 --- /dev/null +++ b/PMDDesktop/Requests/Users/ListUsersResponse.cs @@ -0,0 +1,11 @@ +namespace PMDDesktop.Requests.Users; + +public sealed class ListUsersResponse : ServerResponse +{ + + /// + /// All currently existing users, presented in an arbitrary order. + /// + public required UserInfoResponse[] Users { get; set; } + +} From f331b3f2578e43dd2f6e179359bafd3f573cbf92 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:45:37 -0500 Subject: [PATCH 46/55] Add endpoints to get/list roles --- PMDDesktop.Server.Users/Roles/UserRole.cs | 21 +++++++++- .../GenericResponseCreators.cs | 38 ++++++++++++++++++- .../Roles/GetRoleInfoRequestHandler.cs | 24 ++++++++++++ .../Roles/ListUsersRequestHandler.cs | 23 +++++++++++ .../Requests/Roles/GetRoleInfoRequest.cs | 10 +++++ PMDDesktop/Requests/Roles/ListRolesRequest.cs | 7 ++++ .../Requests/Roles/ListRolesResponse.cs | 11 ++++++ PMDDesktop/Requests/Roles/RoleInfoResponse.cs | 32 ++++++++++++++++ 8 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 PMDDesktop.Server/RequestHandlers/Roles/GetRoleInfoRequestHandler.cs create mode 100644 PMDDesktop.Server/RequestHandlers/Roles/ListUsersRequestHandler.cs create mode 100644 PMDDesktop/Requests/Roles/GetRoleInfoRequest.cs create mode 100644 PMDDesktop/Requests/Roles/ListRolesRequest.cs create mode 100644 PMDDesktop/Requests/Roles/ListRolesResponse.cs create mode 100644 PMDDesktop/Requests/Roles/RoleInfoResponse.cs diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs index 805db67..bbfcc9d 100644 --- a/PMDDesktop.Server.Users/Roles/UserRole.cs +++ b/PMDDesktop.Server.Users/Roles/UserRole.cs @@ -172,7 +172,7 @@ internal string GetRoleJSONPath() } - static internal string GetRoleFolder() + internal static string GetRoleFolder() { return Path.Combine(AppContext.BaseDirectory, "roles"); @@ -253,4 +253,23 @@ public async Task TryRemoveRole() } + /// + /// Returns the current index that this role is slotted in the role order. + /// Starts at 0, and ends at the current role count minus 1. + /// Numbers closer to 0 are first and have a higher priority in permission evaluation. + /// + /// The index this role is located at in the role order. + public int GetCurrentOrderIndex() + { + + if (Manager is null) + throw new InvalidOperationException($"{this} can't check its order index when {nameof(Manager)} is null!"); + + if (IsDefaultRole) // We can just assume this is the last role. It's faster + it's not even in the role order. + return Manager.roleOrder.Length; + + return Manager.roleOrder.IndexOf(GUID); + + } + } diff --git a/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs b/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs index 7004d7f..b54f458 100644 --- a/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs +++ b/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs @@ -1,4 +1,5 @@ -using PMDDesktop.Requests.Users; +using PMDDesktop.Requests.Roles; +using PMDDesktop.Requests.Users; using PMDDesktop.Server.Users; using PMDDesktop.Server.Users.Roles; @@ -24,4 +25,39 @@ internal static UserInfoResponse CreateUserInfoResponse(User fromPOV, User ofUse } + internal static RoleInfoResponse CreateRoleInfoResponse(User fromPOV, UserRole ofRole) + { + + bool canSeePermissionInfo = fromPOV.HasPermission(UserPermission.MANAGE_USERS); + + Dictionary? permissions; + + if (canSeePermissionInfo) + { + + permissions = []; + foreach (UserPermission permission in UserPermission.ALL) + permissions.Add(permission.DataName, ofRole.GetPermission(permission)); + + } + else + { + + permissions = null; + + } + + return new() + { + + DisplayName = ofRole.Name, + GUID = ofRole.GUID, + Permissions = permissions, + IndexInRoleOrder = ofRole.GetCurrentOrderIndex(), + IsDefaultRole = ofRole.IsDefaultRole + + }; + + } + } diff --git a/PMDDesktop.Server/RequestHandlers/Roles/GetRoleInfoRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/GetRoleInfoRequestHandler.cs new file mode 100644 index 0000000..ac72557 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Roles/GetRoleInfoRequestHandler.cs @@ -0,0 +1,24 @@ +using PMDDesktop.Requests.Roles; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Roles; + +public class GetRoleInfoRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(GetRoleInfoRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + if (!game.TryGetRole(request.GUID, out UserRole? role)) + throw new UserRequestException($"{request.GUID} does not match a role."); + + return GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role); + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Roles/ListUsersRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/ListUsersRequestHandler.cs new file mode 100644 index 0000000..6d2de5a --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Roles/ListUsersRequestHandler.cs @@ -0,0 +1,23 @@ +using PMDDesktop.Requests.Roles; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; + +namespace PMDDesktop.Server.RequestHandlers.Roles; + +public class ListRolesRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(ListRolesRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + return new() + { + Roles = [.. game.State.Roles.Select(role => GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role))] + }; + + } + +} diff --git a/PMDDesktop/Requests/Roles/GetRoleInfoRequest.cs b/PMDDesktop/Requests/Roles/GetRoleInfoRequest.cs new file mode 100644 index 0000000..e853c37 --- /dev/null +++ b/PMDDesktop/Requests/Roles/GetRoleInfoRequest.cs @@ -0,0 +1,10 @@ +namespace PMDDesktop.Requests.Roles; + +[ServerRequest("/api/roles/get-info")] +public sealed class GetRoleInfoRequest : ServerRequest +{ + + [QueryParameter] + public required Guid GUID { get; set; } + +} diff --git a/PMDDesktop/Requests/Roles/ListRolesRequest.cs b/PMDDesktop/Requests/Roles/ListRolesRequest.cs new file mode 100644 index 0000000..168a3bf --- /dev/null +++ b/PMDDesktop/Requests/Roles/ListRolesRequest.cs @@ -0,0 +1,7 @@ +namespace PMDDesktop.Requests.Roles; + +[ServerRequest("/api/roles/list")] +public sealed class ListRolesRequest : ServerRequest +{ + +} diff --git a/PMDDesktop/Requests/Roles/ListRolesResponse.cs b/PMDDesktop/Requests/Roles/ListRolesResponse.cs new file mode 100644 index 0000000..89f7247 --- /dev/null +++ b/PMDDesktop/Requests/Roles/ListRolesResponse.cs @@ -0,0 +1,11 @@ +namespace PMDDesktop.Requests.Roles; + +public sealed class ListRolesResponse : ServerResponse +{ + + /// + /// All currently existing roles, presented in order. + /// + public required RoleInfoResponse[] Roles { get; set; } + +} diff --git a/PMDDesktop/Requests/Roles/RoleInfoResponse.cs b/PMDDesktop/Requests/Roles/RoleInfoResponse.cs new file mode 100644 index 0000000..205436b --- /dev/null +++ b/PMDDesktop/Requests/Roles/RoleInfoResponse.cs @@ -0,0 +1,32 @@ +namespace PMDDesktop.Requests.Roles; + +public sealed class RoleInfoResponse : ServerResponse +{ + + /// + /// The displayed name of the role. + /// + public required string DisplayName { get; set; } + + /// + /// The of the role. + /// + public required Guid GUID { get; set; } + + /// + /// Dictionary of Permission "data names" (IDs) and whether they're enabled, disabled, or neither. + /// + public required Dictionary? Permissions { get; set; } + + /// + /// The index in which this role is evaluated in the overall role order. + /// Starting at 0, a number closer to 0 is evaluated before numbers further from 0, thus a higher-priority. + /// + public required int IndexInRoleOrder { get; set; } + + /// + /// Is this the "default" role that everyone is assigned and can't be assigned or unassigned? + /// + public required bool IsDefaultRole { get; set; } + +} From e6d7f100291b408cd5479ee92e79d4986577b847 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:29:17 -0500 Subject: [PATCH 47/55] Add endpoint to create a role. --- .../Roles/CreateRoleRequestHandler.cs | 31 +++++++++++++++++++ .../Requests/Roles/CreateRoleRequest.cs | 14 +++++++++ 2 files changed, 45 insertions(+) create mode 100644 PMDDesktop.Server/RequestHandlers/Roles/CreateRoleRequestHandler.cs create mode 100644 PMDDesktop/Requests/Roles/CreateRoleRequest.cs diff --git a/PMDDesktop.Server/RequestHandlers/Roles/CreateRoleRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/CreateRoleRequestHandler.cs new file mode 100644 index 0000000..1bcab1f --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Roles/CreateRoleRequestHandler.cs @@ -0,0 +1,31 @@ +using PMDDesktop.Requests.Roles; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Roles; + +public class CreateRoleRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(CreateRoleRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + // To create roles, you need the MANAGE_ROLES permission. + if (!requestingUser.HasPermission(UserPermission.MANAGE_ROLES)) + throw new UserRequestException($"You need permission to manage roles to access this endpoint."); + + UserRole role = await game.State.Roles.TryCreateRole() + ?? throw new Exception($"Unable to create role."); + + if (request.NewName is not null) + await role.SetName(request.NewName); + + return GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role); + + } + +} diff --git a/PMDDesktop/Requests/Roles/CreateRoleRequest.cs b/PMDDesktop/Requests/Roles/CreateRoleRequest.cs new file mode 100644 index 0000000..532a6f3 --- /dev/null +++ b/PMDDesktop/Requests/Roles/CreateRoleRequest.cs @@ -0,0 +1,14 @@ +namespace PMDDesktop.Requests.Roles; + + +[ServerRequest("/api/roles/create")] +public sealed class CreateRoleRequest : ServerRequest +{ + + /// + /// Optional name for the new role. + /// It will be assigned a default name otherwise. + /// + public string? NewName { get; set; } + +} From 93e49cc753ea1dc1ab0783684d647243f83e08a1 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:03:18 -0500 Subject: [PATCH 48/55] Add IUserHierarchyComparable This lets us restrict editing operations to users that have a higher role than another role. This prevents, say, a moderator from changing an administrator's roles. --- .../IUserHierarchyComparable.cs | 18 +++++++++ PMDDesktop.Server.Users/Roles/UserRole.cs | 4 +- PMDDesktop.Server.Users/User.cs | 38 ++++++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 PMDDesktop.Server.Users/IUserHierarchyComparable.cs diff --git a/PMDDesktop.Server.Users/IUserHierarchyComparable.cs b/PMDDesktop.Server.Users/IUserHierarchyComparable.cs new file mode 100644 index 0000000..c600a1b --- /dev/null +++ b/PMDDesktop.Server.Users/IUserHierarchyComparable.cs @@ -0,0 +1,18 @@ +namespace PMDDesktop.Server.Users; + +/// +/// Interface to compare if a user has access to certain data. +/// Lower numeric-values are higher permissions; a user must have a higher permission to edit other users' data. +/// +public interface IUserHierarchyComparable +{ + + /// + /// Returns a number indicating the highest place in the hierarchy this user has. + /// 0 should be the highest value obtainable by a normal user. -1 is obtainable to admins only. + /// Lower numeric values are higher permissions; this should usually be calculated based on role order. + /// + /// Number indicating the highest place in the hierarchy this user has. + int GetHierarchyPosition(); + +} diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs index bbfcc9d..e78df7c 100644 --- a/PMDDesktop.Server.Users/Roles/UserRole.cs +++ b/PMDDesktop.Server.Users/Roles/UserRole.cs @@ -5,7 +5,7 @@ namespace PMDDesktop.Server.Users.Roles; [JsonConverter(typeof(UserRoleConverter))] -public sealed class UserRole +public sealed class UserRole : IUserHierarchyComparable { internal UserRole() { } @@ -272,4 +272,6 @@ public int GetCurrentOrderIndex() } + public int GetHierarchyPosition() => GetCurrentOrderIndex(); + } diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index cf379bc..a8c99cf 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -7,7 +7,7 @@ namespace PMDDesktop.Server.Users; [JsonConverter(typeof(UserConverter))] -public sealed class User +public sealed class User : IUserHierarchyComparable { /// @@ -646,6 +646,42 @@ public async Task SetAdmin(bool newState) } + /// + /// Returns true if this could edit based on , given that it has the correct permissions. + /// Before you let an edit happen, ensure the user has both the correct permission and a higher hierarchy. + /// + /// True if this user is higher than . + public bool HigherThan(IUserHierarchyComparable target) + { + + return GetHierarchyPosition() > target.GetHierarchyPosition(); + + } + + public int GetHierarchyPosition() + { + + if (Manager is null) + throw new NullReferenceException($"Can't check where {this} is in the hierarchy if {nameof(Manager)} is null!"); + + // Checked after Manager because we shouldn't be checking for hierarchy on a detached user anyways. + if (IsAdmin) + return -1; + + foreach (UserRole role in Manager.RoleManager) + { + + if (!HasRole(role)) + continue; + + return role.GetCurrentOrderIndex(); + + } + + throw new InvalidOperationException($"Not sure how this happened, but we didn't find a role that {this} matched with. Not even the default role."); + + } + #endregion } From 87580b09cd8fcb0dcba08e6df621b98db78fe30b Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:46:03 -0500 Subject: [PATCH 49/55] Block lower users from editing higher users' info. --- .../RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs | 4 ++++ .../RequestHandlers/Users/SetUserNameRequestHandler.cs | 4 ++++ .../RequestHandlers/Users/SetUserPasswordRequestHandler.cs | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs index 7ab3bd9..1767f9f 100644 --- a/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs @@ -19,8 +19,12 @@ protected override async Task CreateResponse(SetUserLoginHandl // To change the login handle of someone who isn't yourself, you need the MANAGE_USERS permission. if (user != requestingUser) + { if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS)) throw new UserRequestException($"You need permission to manage users to access this endpoint."); + if (!requestingUser.HigherThan(user)) + throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation."); + } // TEMPORARY SOLUTION // TODO: Replace this with a more elegant solution. I think we should make a new class or struct to hold these types of super normalized strings. diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs index c0b961a..4e657ab 100644 --- a/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs @@ -19,8 +19,12 @@ protected override async Task CreateResponse(SetUserNameReques // To change the username of someone who isn't yourself, you need the MANAGE_USERS permission. if (user != requestingUser) + { if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS)) throw new UserRequestException($"You need permission to manage users to access this endpoint."); + if (!requestingUser.HigherThan(user)) + throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation."); + } await user.SetName(request.NewName); diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs index 3e4fd48..4a174c9 100644 --- a/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs @@ -19,8 +19,12 @@ protected override async Task CreateResponse(SetUserPasswordRe // To change the login handle of someone who isn't yourself, you need the MANAGE_USERS permission. if (user != requestingUser) + { if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS)) throw new UserRequestException($"You need permission to manage users to access this endpoint."); + if (!requestingUser.HigherThan(user)) + throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation."); + } await user.SetPassword(request.NewPassword); From fe42a2e8bb0ef5f4c6385652c2d1a3e3a7e6d438 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:43:42 -0500 Subject: [PATCH 50/55] Add set role name endpoint --- .../Roles/SetRoleNameRequestHandler.cs | 33 +++++++++++++++++++ .../Requests/Roles/SetRoleNameRequest.cs | 18 ++++++++++ 2 files changed, 51 insertions(+) create mode 100644 PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs create mode 100644 PMDDesktop/Requests/Roles/SetRoleNameRequest.cs diff --git a/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs new file mode 100644 index 0000000..3bd5c06 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs @@ -0,0 +1,33 @@ +using PMDDesktop.Requests.Roles; +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Roles; + +public class SetRoleNameRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(SetRoleNameRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + if (!requestingUser.HasPermission(UserPermission.MANAGE_ROLES)) + throw new UserRequestException($"You need permission to manage roles to access this endpoint."); + + if (!game.TryGetRole(request.GUID, out UserRole? role)) + throw new UserRequestException($"{request.GUID} does not match a user."); + + if (!requestingUser.HigherThan(role)) + throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation."); + + await role.SetName(request.NewName); + + return GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role); + + } + +} diff --git a/PMDDesktop/Requests/Roles/SetRoleNameRequest.cs b/PMDDesktop/Requests/Roles/SetRoleNameRequest.cs new file mode 100644 index 0000000..4dcd5a5 --- /dev/null +++ b/PMDDesktop/Requests/Roles/SetRoleNameRequest.cs @@ -0,0 +1,18 @@ +namespace PMDDesktop.Requests.Roles; + + +[ServerRequest("/api/roles/set-name")] +public sealed class SetRoleNameRequest : ServerRequest +{ + + /// + /// The of the role you're trying to set the name of. + /// + public required Guid GUID { get; set; } + + /// + /// New name for the new role. + /// + public required string NewName { get; set; } + +} From 71f0dba9ccd02be8bc025e7ba39cbae3b321cf25 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:21:14 -0500 Subject: [PATCH 51/55] Fix embarrassing loading issues --- PMDDesktop.Server.Users/Roles/UserRoleManager.cs | 4 +--- PMDDesktop.Server.Users/User.cs | 4 ++-- PMDDesktop.Server.Users/UserManager.cs | 6 +++--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs index efef5d4..75dfa14 100644 --- a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs +++ b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs @@ -93,9 +93,7 @@ internal async Task LoadAllRoleData() if (!Guid.TryParse(guidParsable, out Guid loadedGUID)) throw new Exception($"Couldn't parse {guidParsable} as a GUID at {filePath}"); - string userFilePath = Path.Join(filePath, User.USER_FILE_NAME); - - using FileStream readStream = File.OpenRead(userFilePath); + using FileStream readStream = File.OpenRead(filePath); await LoadAndAddRoleJson(readStream, loadedGUID); diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index a8c99cf..45692b4 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -77,7 +77,7 @@ internal User() /// /// Is/Was on the last used or currently active . /// - public bool? WritingEnabled { get; private set; } + public bool? WritingEnabled { get => Manager is not null ? Manager.WritingEnabled : field; private set; } /// /// Specifies if this user is affected by . @@ -474,7 +474,6 @@ internal void AttachToManager(UserManager manager) throw new InvalidOperationException($"This existing value of {nameof(Manager)} is not null. Cannot attach while already attached. Does this call to {nameof(AttachToManager)} need to be skipped, or does {nameof(DetachFromManager)} need to be called first?"); Manager = manager; - WritingEnabled = manager.WritingEnabled; Manager.guids.Add(GUID, this); @@ -508,6 +507,7 @@ internal void DetachFromManager() foreach (UserAccessToken token in accessTokens) DetachAccessToken(token); + WritingEnabled = Manager.WritingEnabled; Manager = null; } diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs index 1cfdff9..35a370d 100644 --- a/PMDDesktop.Server.Users/UserManager.cs +++ b/PMDDesktop.Server.Users/UserManager.cs @@ -74,13 +74,13 @@ public UserManager() public async Task LoadFromFilesAndEnableWriting() { - // Since Users check the WritingEnabled value, we need to set this before loading. - WritingEnabled = true; - await LoadAllUserData(); await RoleManager.LoadFromFiles(); + // This needs to be set after loading to prevent premature writing while loading. + WritingEnabled = true; + } /// From aa162f6f9174b04db71d03b7bcc00e9c7d3a25fc Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:03:59 -0500 Subject: [PATCH 52/55] Change direction of Hierarchy comparison --- PMDDesktop.Server.Users/User.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs index 45692b4..917a83e 100644 --- a/PMDDesktop.Server.Users/User.cs +++ b/PMDDesktop.Server.Users/User.cs @@ -654,7 +654,7 @@ public async Task SetAdmin(bool newState) public bool HigherThan(IUserHierarchyComparable target) { - return GetHierarchyPosition() > target.GetHierarchyPosition(); + return GetHierarchyPosition() < target.GetHierarchyPosition(); } From 8b775a8144c8dff1d706ead9bdc2931e591d8cf8 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:09:36 -0500 Subject: [PATCH 53/55] Change typo of 'user' to 'role.' --- .../RequestHandlers/Roles/SetRoleNameRequestHandler.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs index 3bd5c06..e6d72bd 100644 --- a/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs @@ -1,5 +1,4 @@ using PMDDesktop.Requests.Roles; -using PMDDesktop.Requests.Users; using PMDDesktop.Server.Game; using PMDDesktop.Server.Users; using PMDDesktop.Server.Users.Roles; @@ -19,7 +18,7 @@ protected override async Task CreateResponse(SetRoleNameReques throw new UserRequestException($"You need permission to manage roles to access this endpoint."); if (!game.TryGetRole(request.GUID, out UserRole? role)) - throw new UserRequestException($"{request.GUID} does not match a user."); + throw new UserRequestException($"{request.GUID} does not match a role."); if (!requestingUser.HigherThan(role)) throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation."); From aa3ffe40b9f845c731b7c052845a70e601e4d708 Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:10:01 -0500 Subject: [PATCH 54/55] Add endpoint to set role permissions. --- .../Roles/UserPermission.cs | 20 +++++++++++ .../Roles/SetRolePermissionRequestHandler.cs | 35 +++++++++++++++++++ .../Roles/SetRolePermissionRequest.cs | 24 +++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 PMDDesktop.Server/RequestHandlers/Roles/SetRolePermissionRequestHandler.cs create mode 100644 PMDDesktop/Requests/Roles/SetRolePermissionRequest.cs diff --git a/PMDDesktop.Server.Users/Roles/UserPermission.cs b/PMDDesktop.Server.Users/Roles/UserPermission.cs index 83d9bb8..4469f61 100644 --- a/PMDDesktop.Server.Users/Roles/UserPermission.cs +++ b/PMDDesktop.Server.Users/Roles/UserPermission.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Reflection; namespace PMDDesktop.Server.Users.Roles; @@ -21,6 +22,25 @@ static UserPermission() } + public static bool TryGetPermissionByDataName(string dataName, [NotNullWhen(true)] out UserPermission? permission) + { + + foreach (UserPermission checking in ALL) + { + + if (checking.DataName != dataName) + continue; + + permission = checking; + return true; + + } + + permission = null; + return false; + + } + /// /// Array containing all objects. (Should not change after initial creation.) /// diff --git a/PMDDesktop.Server/RequestHandlers/Roles/SetRolePermissionRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/SetRolePermissionRequestHandler.cs new file mode 100644 index 0000000..da5412b --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Roles/SetRolePermissionRequestHandler.cs @@ -0,0 +1,35 @@ +using PMDDesktop.Requests.Roles; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Roles; + +public class SetRolePermissionRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(SetRolePermissionRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + if (!requestingUser.HasPermission(UserPermission.MANAGE_ROLES)) + throw new UserRequestException($"You need permission to manage roles to access this endpoint."); + + if (!game.TryGetRole(request.GUID, out UserRole? role)) + throw new UserRequestException($"{request.GUID} does not match a role."); + + if (!requestingUser.HigherThan(role)) + throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation."); + + if (!UserPermission.TryGetPermissionByDataName(request.PermissionDataName, out UserPermission? permission)) + throw new UserRequestException($"Could not find a permission with the name '{request.PermissionDataName}' to set."); + + await role.SetPermission(permission, request.NewPermissionValue); + + return GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role); + + } + +} diff --git a/PMDDesktop/Requests/Roles/SetRolePermissionRequest.cs b/PMDDesktop/Requests/Roles/SetRolePermissionRequest.cs new file mode 100644 index 0000000..65deb6a --- /dev/null +++ b/PMDDesktop/Requests/Roles/SetRolePermissionRequest.cs @@ -0,0 +1,24 @@ +namespace PMDDesktop.Requests.Roles; + +[ServerRequest("/api/roles/set-permission")] +public sealed class SetRolePermissionRequest : ServerRequest +{ + + /// + /// The of the role you're trying to edit. + /// + public required Guid GUID { get; set; } + + /// + /// The "data name" of the permission you're trying to edit. + /// + public required string PermissionDataName { get; set; } + + /// + /// The value you'd like to set the permission to. + /// True allows this permission for this role, false denies it, and null makes the role not affect this permission. + /// When evaluating permissions for a user, they check the highest role they have assigned with a non-null permission value for that permission. + /// + public required bool? NewPermissionValue { get; set; } + +} From e061b4d2d25f7c6d87833070ed749e8000b98eac Mon Sep 17 00:00:00 2001 From: Weffuls <111707520+Weffuls@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:31:23 -0500 Subject: [PATCH 55/55] Add endpoint to list all user permissions --- .../GenericResponseCreators.cs | 15 ++++++++++++- .../ListPermissionsRequestHandler.cs | 21 +++++++++++++++++++ .../Permissions/ListPermissionsRequest.cs | 7 +++++++ .../Permissions/ListPermissionsResponse.cs | 11 ++++++++++ .../Permissions/PermissionInfoResponse.cs | 21 +++++++++++++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 PMDDesktop.Server/RequestHandlers/Permissions/ListPermissionsRequestHandler.cs create mode 100644 PMDDesktop/Requests/Permissions/ListPermissionsRequest.cs create mode 100644 PMDDesktop/Requests/Permissions/ListPermissionsResponse.cs create mode 100644 PMDDesktop/Requests/Permissions/PermissionInfoResponse.cs diff --git a/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs b/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs index b54f458..d7f712f 100644 --- a/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs +++ b/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs @@ -1,4 +1,5 @@ -using PMDDesktop.Requests.Roles; +using PMDDesktop.Requests.Permissions; +using PMDDesktop.Requests.Roles; using PMDDesktop.Requests.Users; using PMDDesktop.Server.Users; using PMDDesktop.Server.Users.Roles; @@ -60,4 +61,16 @@ internal static RoleInfoResponse CreateRoleInfoResponse(User fromPOV, UserRole o } + internal static PermissionInfoResponse CreatePermissionInfoResponse(UserPermission ofPermission) + { + + return new() + { + DataName = ofPermission.DataName, + FriendlyDescription = ofPermission.FriendlyDescription, + FriendlyName = ofPermission.FriendlyName + }; + + } + } diff --git a/PMDDesktop.Server/RequestHandlers/Permissions/ListPermissionsRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Permissions/ListPermissionsRequestHandler.cs new file mode 100644 index 0000000..fafd792 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Permissions/ListPermissionsRequestHandler.cs @@ -0,0 +1,21 @@ +using PMDDesktop.Requests.Permissions; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Permissions; + +public class ListPermissionsRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(ListPermissionsRequest request, GameServer game, User? requestingUser) + { + + return new() + { + Permissions = [.. UserPermission.ALL.Select(permission => GenericResponseCreators.CreatePermissionInfoResponse(permission))] + }; + + } + +} diff --git a/PMDDesktop/Requests/Permissions/ListPermissionsRequest.cs b/PMDDesktop/Requests/Permissions/ListPermissionsRequest.cs new file mode 100644 index 0000000..bce7379 --- /dev/null +++ b/PMDDesktop/Requests/Permissions/ListPermissionsRequest.cs @@ -0,0 +1,7 @@ +namespace PMDDesktop.Requests.Permissions; + +[ServerRequest("/api/permissions/list")] +public sealed class ListPermissionsRequest : ServerRequest +{ + +} diff --git a/PMDDesktop/Requests/Permissions/ListPermissionsResponse.cs b/PMDDesktop/Requests/Permissions/ListPermissionsResponse.cs new file mode 100644 index 0000000..c88d2a8 --- /dev/null +++ b/PMDDesktop/Requests/Permissions/ListPermissionsResponse.cs @@ -0,0 +1,11 @@ +namespace PMDDesktop.Requests.Permissions; + +public sealed class ListPermissionsResponse : ServerResponse +{ + + /// + /// All currently existing roles, presented in order. + /// + public required PermissionInfoResponse[] Permissions { get; set; } + +} diff --git a/PMDDesktop/Requests/Permissions/PermissionInfoResponse.cs b/PMDDesktop/Requests/Permissions/PermissionInfoResponse.cs new file mode 100644 index 0000000..eafbdae --- /dev/null +++ b/PMDDesktop/Requests/Permissions/PermissionInfoResponse.cs @@ -0,0 +1,21 @@ +namespace PMDDesktop.Requests.Permissions; + +public sealed class PermissionInfoResponse : ServerResponse +{ + + /// + /// This is the name used to identify this permission. Should be sent back exactly when talking about this permission with the server. + /// + public required string DataName { get; set; } + + /// + /// The name that's best shown to users. + /// + public required string FriendlyName { get; set; } + + /// + /// A description explaining what the permission does. + /// + public required string FriendlyDescription { get; set; } + +}