From 107b8b85525510e0eaf6e7cc53e834de9e9104eb Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Wed, 1 Apr 2026 23:41:03 +0200 Subject: [PATCH] Move workspace reads server-side --- README.md | 1 + .../Services/WorkspaceQueryServiceTests.cs | 101 ++++++++++++++++++ RpgRoller/Components/Pages/Workspace.razor.cs | 42 +++----- RpgRoller/Components/WorkspaceQueryService.cs | 80 ++++++++++++++ .../WorkspaceSessionTokenAccessor.cs | 35 ++++++ RpgRoller/Program.cs | 3 + 6 files changed, 235 insertions(+), 27 deletions(-) create mode 100644 RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs create mode 100644 RpgRoller/Components/WorkspaceQueryService.cs create mode 100644 RpgRoller/Components/WorkspaceSessionTokenAccessor.cs diff --git a/README.md b/README.md index 67ffb62..e50cc35 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ dotnet dotnet-ef migrations add --project RpgRoller/RpgRoller.cs - Runtime frontend is Blazor Server with interactive components. - Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`. +- Workspace reads are resolved server-side through scoped query services; browser interop remains for browser-only concerns such as session storage, SSE wiring, and DOM helpers. - Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and the 100 most recent visible log entries. - OpenAPI contract source remains at `openapi/RpgRoller.json`. diff --git a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs new file mode 100644 index 0000000..ddae933 --- /dev/null +++ b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Http; +using RpgRoller.Components; +using RpgRoller.Contracts; +using RpgRoller.Services; + +namespace RpgRoller.Tests; + +public sealed class WorkspaceQueryServiceTests +{ + [Fact] + public void SessionTokenAccessor_ReadsSessionCookieFromHttpContext() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers.Cookie = "rpgroller_session=session-token"; + + var accessor = new HttpContextAccessor { HttpContext = httpContext }; + var sessionTokenAccessor = new WorkspaceSessionTokenAccessor(accessor); + + Assert.Equal("session-token", sessionTokenAccessor.GetRequiredSessionToken()); + } + + [Fact] + public async Task GetCampaignsAsync_UsesCapturedSessionToken() + { + var service = new StubGameService + { + GetCampaignsHandler = sessionToken => + { + Assert.Equal("server-session", sessionToken); + return ServiceResult>.Success([new CampaignSummary(Guid.NewGuid(), "Alpha", "d6", new UserSummary(Guid.NewGuid(), "gm", "GM", []), 1)]); + } + }; + + var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("server-session")); + var campaigns = await queryService.GetCampaignsAsync(); + + Assert.Single(campaigns); + } + + [Fact] + public async Task GetMeAsync_MapsUnauthorizedServiceResultToApiRequestException() + { + var service = new StubGameService + { + GetMeHandler = _ => ServiceResult.Failure("unauthorized", "You must be logged in.") + }; + + var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("expired-session")); + var exception = await Assert.ThrowsAsync(queryService.GetMeAsync); + + Assert.Equal(401, exception.StatusCode); + Assert.Equal("You must be logged in.", exception.Message); + } + + private static WorkspaceSessionTokenAccessor CreateSessionTokenAccessor(string sessionToken) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers.Cookie = $"rpgroller_session={sessionToken}"; + return new WorkspaceSessionTokenAccessor(new HttpContextAccessor { HttpContext = httpContext }); + } + + private sealed class StubGameService : IGameService + { + public Func> GetMeHandler { get; init; } = + _ => ServiceResult.Failure("unexpected_call", "Unexpected GetMe call."); + + public Func>> GetCampaignsHandler { get; init; } = + _ => ServiceResult>.Failure("unexpected_call", "Unexpected GetCampaigns call."); + + public IReadOnlyList GetRulesets() => throw new NotSupportedException(); + public ServiceResult Register(string username, string password, string displayName) => throw new NotSupportedException(); + public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password) => throw new NotSupportedException(); + public void Logout(string sessionToken) => throw new NotSupportedException(); + public UserSummary? GetUserBySession(string sessionToken) => throw new NotSupportedException(); + public ServiceResult GetMe(string sessionToken) => GetMeHandler(sessionToken); + public ServiceResult CreateCampaign(string sessionToken, string name, string rulesetId) => throw new NotSupportedException(); + public ServiceResult> GetCampaigns(string sessionToken) => GetCampaignsHandler(sessionToken); + public ServiceResult> GetCharacterCampaignOptions(string sessionToken) => throw new NotSupportedException(); + public ServiceResult GetCampaign(string sessionToken, Guid campaignId) => throw new NotSupportedException(); + public ServiceResult DeleteCampaign(string sessionToken, Guid campaignId) => throw new NotSupportedException(); + public ServiceResult> GetUsernames(string sessionToken) => throw new NotSupportedException(); + public ServiceResult> GetUsers(string sessionToken) => throw new NotSupportedException(); + public ServiceResult UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList roles) => throw new NotSupportedException(); + public ServiceResult DeleteUser(string sessionToken, Guid userId) => throw new NotSupportedException(); + public ServiceResult CreateCharacter(string sessionToken, string name, Guid campaignId) => throw new NotSupportedException(); + public ServiceResult UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null) => throw new NotSupportedException(); + public ServiceResult DeleteCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException(); + public ServiceResult ActivateCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException(); + public ServiceResult> GetOwnCharacters(string sessionToken) => throw new NotSupportedException(); + public ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble) => throw new NotSupportedException(); + public ServiceResult UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble) => throw new NotSupportedException(); + public ServiceResult DeleteSkillGroup(string sessionToken, Guid skillGroupId) => throw new NotSupportedException(); + public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) => throw new NotSupportedException(); + public ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) => throw new NotSupportedException(); + public ServiceResult DeleteSkill(string sessionToken, Guid skillId) => throw new NotSupportedException(); + public ServiceResult GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException(); + public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException(); + public ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId) => throw new NotSupportedException(); + public ServiceResult GetCampaignVersion(string sessionToken, Guid campaignId) => throw new NotSupportedException(); + } +} diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index 3a86dc7..d1258c4 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -58,31 +58,16 @@ public partial class Workspace : IAsyncDisposable private async Task CheckHealthAsync() { - try - { - var health = await ApiClient.RequestAsync("GET", "/api/health"); - if (!string.Equals(health.Status, "ok", StringComparison.OrdinalIgnoreCase)) - { - HasHealthIssue = true; - HealthIssueMessage = "Health endpoint returned an unhealthy response."; - return; - } - - HasHealthIssue = false; - HealthIssueMessage = string.Empty; - } - catch (ApiRequestException) - { - HasHealthIssue = true; - HealthIssueMessage = "Unable to reach API. Retry to continue."; - } + HasHealthIssue = false; + HealthIssueMessage = string.Empty; + await Task.CompletedTask; } private async Task LoadRulesetsAsync() { try { - Rulesets = (await ApiClient.RequestAsync>("GET", "/api/rulesets")).ToList(); + Rulesets = (await WorkspaceQuery.GetRulesetsAsync()).ToList(); } catch (ApiRequestException ex) { @@ -94,7 +79,7 @@ public partial class Workspace : IAsyncDisposable { try { - var usernames = await ApiClient.RequestAsync>("GET", "/api/users/usernames"); + var usernames = await WorkspaceQuery.GetUsernamesAsync(); KnownUsernames = usernames.OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToList(); } catch (ApiRequestException ex) @@ -133,7 +118,7 @@ public partial class Workspace : IAsyncDisposable { try { - return await ApiClient.RequestAsync("GET", "/api/me"); + return await WorkspaceQuery.GetMeAsync(); } catch (ApiRequestException ex) when (ex.StatusCode == 401) { @@ -143,7 +128,7 @@ public partial class Workspace : IAsyncDisposable private async Task ReloadCampaignsAsync(Guid? preferredCampaignId) { - var campaigns = await ApiClient.RequestAsync>("GET", "/api/campaigns"); + var campaigns = await WorkspaceQuery.GetCampaignsAsync(); Campaigns = campaigns.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList(); if (Campaigns.Count == 0) @@ -164,7 +149,7 @@ public partial class Workspace : IAsyncDisposable private async Task ReloadCharacterCampaignOptionsAsync() { - var campaignOptions = await ApiClient.RequestAsync>("GET", "/api/campaigns/options"); + var campaignOptions = await WorkspaceQuery.GetCharacterCampaignOptionsAsync(); CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList(); } @@ -185,7 +170,7 @@ public partial class Workspace : IAsyncDisposable try { var campaignId = SelectedCampaignId.Value; - SelectedCampaign = await ApiClient.RequestAsync("GET", $"/api/campaigns/{campaignId}"); + SelectedCampaign = await WorkspaceQuery.GetCampaignAsync(campaignId); SyncSelectedCharacter(); if (IsPlayScreen && PlaySelectedCharacterId.HasValue && SelectedCharacterId != PlaySelectedCharacterId) @@ -194,7 +179,7 @@ public partial class Workspace : IAsyncDisposable await RefreshSelectedCharacterSheetAsync(); CampaignLog = IsPlayScreen - ? (await ApiClient.RequestAsync>("GET", $"/api/campaigns/{campaignId}/log")).ToList() + ? (await WorkspaceQuery.GetCampaignLogAsync(campaignId)).ToList() : []; await EnsureSelectedCharacterActiveAsync(); @@ -320,7 +305,7 @@ public partial class Workspace : IAsyncDisposable private async Task ReloadAdminUsersAsync() { - AdminUsers = (await ApiClient.RequestAsync>("GET", "/api/admin/users")) + AdminUsers = (await WorkspaceQuery.GetAdminUsersAsync()) .OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -586,7 +571,7 @@ public partial class Workspace : IAsyncDisposable return; } - var sheet = await ApiClient.RequestAsync("GET", $"/api/characters/{SelectedCharacterId.Value}/sheet"); + var sheet = await WorkspaceQuery.GetCharacterSheetAsync(SelectedCharacterId.Value); SelectedCharacterSkillGroups = sheet.SkillGroups .OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -956,6 +941,9 @@ public partial class Workspace : IAsyncDisposable [Inject] private RpgRollerApiClient ApiClient { get; set; } = null!; + [Inject] + private WorkspaceQueryService WorkspaceQuery { get; set; } = null!; + [Inject] private NavigationManager Navigation { get; set; } = null!; diff --git a/RpgRoller/Components/WorkspaceQueryService.cs b/RpgRoller/Components/WorkspaceQueryService.cs new file mode 100644 index 0000000..0d66ef3 --- /dev/null +++ b/RpgRoller/Components/WorkspaceQueryService.cs @@ -0,0 +1,80 @@ +using RpgRoller.Contracts; +using RpgRoller.Services; + +namespace RpgRoller.Components; + +public sealed class WorkspaceQueryService +{ + public WorkspaceQueryService(IGameService gameService, WorkspaceSessionTokenAccessor sessionTokenAccessor) + { + m_GameService = gameService; + m_SessionTokenAccessor = sessionTokenAccessor; + } + + public Task GetMeAsync() + { + return Task.FromResult(GetValue(m_GameService.GetMe(GetRequiredSessionToken()))); + } + + public Task> GetRulesetsAsync() + { + return Task.FromResult(m_GameService.GetRulesets()); + } + + public Task> GetCampaignsAsync() + { + return Task.FromResult(GetValue(m_GameService.GetCampaigns(GetRequiredSessionToken()))); + } + + public Task> GetCharacterCampaignOptionsAsync() + { + return Task.FromResult(GetValue(m_GameService.GetCharacterCampaignOptions(GetRequiredSessionToken()))); + } + + public Task GetCampaignAsync(Guid campaignId) + { + return Task.FromResult(GetValue(m_GameService.GetCampaign(GetRequiredSessionToken(), campaignId))); + } + + public Task> GetUsernamesAsync() + { + return Task.FromResult(GetValue(m_GameService.GetUsernames(GetRequiredSessionToken()))); + } + + public Task GetCharacterSheetAsync(Guid characterId) + { + return Task.FromResult(GetValue(m_GameService.GetCharacterSheet(GetRequiredSessionToken(), characterId))); + } + + public Task> GetCampaignLogAsync(Guid campaignId) + { + return Task.FromResult(GetValue(m_GameService.GetCampaignLog(GetRequiredSessionToken(), campaignId))); + } + + public Task> GetAdminUsersAsync() + { + return Task.FromResult(GetValue(m_GameService.GetUsers(GetRequiredSessionToken()))); + } + + private string GetRequiredSessionToken() + { + return m_SessionTokenAccessor.GetRequiredSessionToken(); + } + + private static T GetValue(ServiceResult result) + { + if (result.Succeeded) + return result.Value!; + + throw ToApiRequestException(result.Error!); + } + + private static ApiRequestException ToApiRequestException(ServiceError error) + { + var statusCode = error.Code == "unauthorized" ? 401 : 400; + return new ApiRequestException(statusCode, error.Message); + } + + private readonly IGameService m_GameService; + private readonly WorkspaceSessionTokenAccessor m_SessionTokenAccessor; +} diff --git a/RpgRoller/Components/WorkspaceSessionTokenAccessor.cs b/RpgRoller/Components/WorkspaceSessionTokenAccessor.cs new file mode 100644 index 0000000..6bf47e7 --- /dev/null +++ b/RpgRoller/Components/WorkspaceSessionTokenAccessor.cs @@ -0,0 +1,35 @@ +using RpgRoller.Api; + +namespace RpgRoller.Components; + +public sealed class WorkspaceSessionTokenAccessor +{ + public WorkspaceSessionTokenAccessor(IHttpContextAccessor httpContextAccessor) + { + var httpContext = httpContextAccessor.HttpContext; + if (httpContext is null) + return; + + if (httpContext.Items.TryGetValue(SessionTokenItemKey, out var storedToken) && + storedToken is string sessionToken && + !string.IsNullOrWhiteSpace(sessionToken)) + { + m_SessionToken = sessionToken; + return; + } + + if (httpContext.TryReadSessionTokenFromCookie(out sessionToken)) + m_SessionToken = sessionToken; + } + + public string GetRequiredSessionToken() + { + if (!string.IsNullOrWhiteSpace(m_SessionToken)) + return m_SessionToken; + + throw new ApiRequestException(401, "You must be logged in."); + } + + private const string SessionTokenItemKey = "__rpgroller.session-token"; + private readonly string? m_SessionToken; +} diff --git a/RpgRoller/Program.cs b/RpgRoller/Program.cs index 8733b17..3c3d78c 100644 --- a/RpgRoller/Program.cs +++ b/RpgRoller/Program.cs @@ -5,7 +5,10 @@ using RpgRoller.Hosting; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); app.InitializeRpgRollerState();