From b062ad1adfbe6f6f0ec438054dbfc39705cbd7f4 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Wed, 1 Apr 2026 22:25:43 +0200 Subject: [PATCH] Add admin database download --- README.md | 1 + RpgRoller.Tests/Api/CampaignApiTests.cs | 36 +++++++++++++++++++ RpgRoller.Tests/HostingCoverageTests.cs | 17 +++++++++ RpgRoller.Tests/Support/ApiTestBase.cs | 5 ++- RpgRoller/Api/AdminEndpoints.cs | 20 +++++++++++ RpgRoller/Components/Pages/Workspace.razor | 12 +++++++ RpgRoller/Components/Pages/Workspace.razor.cs | 4 +++ .../Hosting/ServiceCollectionExtensions.cs | 18 ++++++---- RpgRoller/Hosting/SqliteDatabaseFile.cs | 3 ++ RpgRoller/wwwroot/styles.css | 23 ++++++++++++ 10 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 RpgRoller/Hosting/SqliteDatabaseFile.cs diff --git a/README.md b/README.md index d2d5406..70bf617 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Gameplay capabilities now include: - GM-driven character owner transfer within campaign management flows - Character owner selection in edit modal backed by existing-username dropdown data - Role-aware authorization with admin role support (including admin user/role management) +- Admin workspace tools include direct download of the live SQLite database file - Campaign deletion by campaign owner or admin (unlinks characters from the campaign and clears campaign log entries) - User deletion by admin also deletes campaigns owned by that user and unlinks all characters from those deleted campaigns - Play screen visibility is owner-scoped: only owned characters are listed, and private log entries are visible only to the roller diff --git a/RpgRoller.Tests/Api/CampaignApiTests.cs b/RpgRoller.Tests/Api/CampaignApiTests.cs index 6bae214..cd0fba5 100644 --- a/RpgRoller.Tests/Api/CampaignApiTests.cs +++ b/RpgRoller.Tests/Api/CampaignApiTests.cs @@ -1,3 +1,5 @@ +using System.Text; + namespace RpgRoller.Tests; public sealed class CampaignApiTests : ApiTestBase @@ -160,6 +162,40 @@ public sealed class CampaignApiTests : ApiTestBase Assert.Contains(usersAfterDelete, user => user.Id == gm.Id); } + [Fact] + public async Task AdminDatabaseDownload_RequiresAdminAndReturnsSqliteFile() + { + using var factory = CreateFactory(); + using var anonymousClient = factory.CreateClient(new() { AllowAutoRedirect = false }); + using var adminClient = factory.CreateClient(new() { AllowAutoRedirect = false }); + using var memberClient = factory.CreateClient(new() { AllowAutoRedirect = false }); + + await RegisterAsync(adminClient, "admin-download", "Password123", "Admin Download"); + await LoginAsync(adminClient, "admin-download", "Password123"); + + await RegisterAsync(memberClient, "member-download", "Password123", "Member Download"); + await LoginAsync(memberClient, "member-download", "Password123"); + + var unauthorized = await anonymousClient.GetAsync("/api/admin/database"); + Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode); + + var forbidden = await memberClient.GetAsync("/api/admin/database"); + Assert.Equal(HttpStatusCode.BadRequest, forbidden.StatusCode); + + var response = await adminClient.GetAsync("/api/admin/database"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/octet-stream", response.Content.Headers.ContentType?.MediaType); + + var disposition = response.Content.Headers.ContentDisposition; + Assert.NotNull(disposition); + Assert.Equal("attachment", disposition.DispositionType); + Assert.EndsWith(".db", disposition.FileName?.Trim('"'), StringComparison.OrdinalIgnoreCase); + + var bytes = await response.Content.ReadAsByteArrayAsync(); + Assert.True(bytes.Length >= 16); + Assert.Equal("SQLite format 3\0", Encoding.ASCII.GetString(bytes, 0, 16)); + } + [Fact] public async Task CampaignOptionsEndpoint_ReturnsCampaignsBeyondVisibleCampaignList() { diff --git a/RpgRoller.Tests/HostingCoverageTests.cs b/RpgRoller.Tests/HostingCoverageTests.cs index 67e062c..3e5fa83 100644 --- a/RpgRoller.Tests/HostingCoverageTests.cs +++ b/RpgRoller.Tests/HostingCoverageTests.cs @@ -23,6 +23,23 @@ public sealed class HostingCoverageTests Assert.Contains(services, d => d.ServiceType == typeof(IDiceRoller)); } + [Fact] + public void AddRpgRollerCore_WithFileConnectionString_RegistersResolvedSqliteDatabaseFile() + { + var services = new ServiceCollection(); + var contentRoot = Path.Combine(Path.GetTempPath(), $"rpgroller-hosting-{Guid.NewGuid():N}"); + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["ConnectionStrings:RpgRoller"] = "Data Source=App_Data/rpgroller.db" }).Build(); + var environment = new TestWebHostEnvironment { ContentRootPath = contentRoot }; + + services.AddRpgRollerCore(configuration, environment); + + using var provider = services.BuildServiceProvider(); + var databaseFile = provider.GetRequiredService(); + + Assert.Equal(Path.Combine(contentRoot, "App_Data", "rpgroller.db"), databaseFile.Path); + Assert.True(Directory.Exists(Path.Combine(contentRoot, "App_Data"))); + } + [Fact] public void SqliteSchemaUpgrader_MigratesLegacySchema() { diff --git a/RpgRoller.Tests/Support/ApiTestBase.cs b/RpgRoller.Tests/Support/ApiTestBase.cs index 2923118..742ee98 100644 --- a/RpgRoller.Tests/Support/ApiTestBase.cs +++ b/RpgRoller.Tests/Support/ApiTestBase.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using RpgRoller.Data; +using RpgRoller.Hosting; namespace RpgRoller.Tests; @@ -38,8 +39,10 @@ public abstract class ApiTestBase : IClassFixture services.RemoveAll>(); services.RemoveAll>(); services.RemoveAll(); + services.RemoveAll(); var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db"); + services.AddSingleton(new SqliteDatabaseFile(dbPath)); services.AddDbContextFactory(options => options.UseSqlite($"Data Source={dbPath}")); })); } @@ -83,4 +86,4 @@ public abstract class ApiTestBase : IClassFixture } private readonly WebApplicationFactory m_BaseFactory; -} \ No newline at end of file +} diff --git a/RpgRoller/Api/AdminEndpoints.cs b/RpgRoller/Api/AdminEndpoints.cs index 2cd5cd2..ea19748 100644 --- a/RpgRoller/Api/AdminEndpoints.cs +++ b/RpgRoller/Api/AdminEndpoints.cs @@ -1,4 +1,7 @@ +using Microsoft.AspNetCore.Http.HttpResults; using RpgRoller.Contracts; +using RpgRoller.Domain; +using RpgRoller.Hosting; using RpgRoller.Services; namespace RpgRoller.Api; @@ -25,6 +28,23 @@ internal static class AdminEndpoints return ApiResultMapper.ToApiResult(result); }); + group.MapGet("/admin/database", Results, UnauthorizedHttpResult> (HttpContext context, IGameService game, SqliteDatabaseFile databaseFile) => + { + var sessionToken = context.GetRequiredSessionToken(); + var user = game.GetUserBySession(sessionToken); + if (user is null) + return TypedResults.Unauthorized(); + + if (!user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase)) + return ApiResultMapper.ToBadRequest(new ServiceError("forbidden", "Admin role is required.")); + + if (string.IsNullOrWhiteSpace(databaseFile.Path) || !File.Exists(databaseFile.Path)) + return ApiResultMapper.ToBadRequest(new ServiceError("database_unavailable", "SQLite database file is not available.")); + + var stream = new FileStream(databaseFile.Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + return TypedResults.File(stream, "application/octet-stream", Path.GetFileName(databaseFile.Path)); + }); + return group; } } diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index bf178e5..4e8b2e5 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -98,6 +98,18 @@ else if (IsAdminScreen) {
+ @if (IsCurrentUserAdmin) + { +
+
+

Database

+
+

Download the current SQLite file for backup or offline inspection.

+ +
+ }

User Management

diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index 500c80b..a8dc79b 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -928,6 +928,9 @@ public partial class Workspace : IAsyncDisposable [Inject] private RpgRollerApiClient ApiClient { get; set; } = null!; + [Inject] + private NavigationManager Navigation { get; set; } = null!; + private UserSummary? User { get; set; } private Guid? ActiveCharacterId { get; set; } private Guid? SelectedCampaignId { get; set; } @@ -1117,6 +1120,7 @@ public partial class Workspace : IAsyncDisposable }; private string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app"; + private string AdminDatabaseDownloadUrl => Navigation.ToAbsoluteUri("api/admin/database").ToString(); private const string ScreenPlay = "play"; private const string ScreenManagement = "management"; diff --git a/RpgRoller/Hosting/ServiceCollectionExtensions.cs b/RpgRoller/Hosting/ServiceCollectionExtensions.cs index 46eecf4..d8cd9f0 100644 --- a/RpgRoller/Hosting/ServiceCollectionExtensions.cs +++ b/RpgRoller/Hosting/ServiceCollectionExtensions.cs @@ -12,9 +12,11 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddRpgRollerCore(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment environment) { var sqliteConnectionString = configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db"; - EnsureSqliteDataDirectory(sqliteConnectionString, environment.ContentRootPath); + var sqliteDatabasePath = ResolveSqliteDatabasePath(sqliteConnectionString, environment.ContentRootPath); + EnsureSqliteDataDirectory(sqliteDatabasePath); services.AddSingleton, PasswordHasher>(); + services.AddSingleton(new SqliteDatabaseFile(sqliteDatabasePath)); services.AddDbContextFactory(options => options.UseSqlite(sqliteConnectionString)); services.AddSingleton(); services.AddSingleton(); @@ -22,16 +24,20 @@ public static class ServiceCollectionExtensions return services; } - private static void EnsureSqliteDataDirectory(string connectionString, string contentRootPath) + private static string? ResolveSqliteDatabasePath(string connectionString, string contentRootPath) { var builder = new SqliteConnectionStringBuilder(connectionString); if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource == ":memory:") - return; + return null; - var fullPath = Path.IsPathRooted(builder.DataSource) ? builder.DataSource : Path.Combine(contentRootPath, builder.DataSource); + var databasePath = Path.IsPathRooted(builder.DataSource) ? builder.DataSource : Path.Combine(contentRootPath, builder.DataSource); + return Path.GetFullPath(databasePath); + } - var directory = Path.GetDirectoryName(fullPath); + private static void EnsureSqliteDataDirectory(string? sqliteDatabasePath) + { + var directory = Path.GetDirectoryName(sqliteDatabasePath); if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory); } -} \ No newline at end of file +} diff --git a/RpgRoller/Hosting/SqliteDatabaseFile.cs b/RpgRoller/Hosting/SqliteDatabaseFile.cs new file mode 100644 index 0000000..6ec8aef --- /dev/null +++ b/RpgRoller/Hosting/SqliteDatabaseFile.cs @@ -0,0 +1,3 @@ +namespace RpgRoller.Hosting; + +public sealed record SqliteDatabaseFile(string? Path); diff --git a/RpgRoller/wwwroot/styles.css b/RpgRoller/wwwroot/styles.css index ec916ab..9bf2fb3 100644 --- a/RpgRoller/wwwroot/styles.css +++ b/RpgRoller/wwwroot/styles.css @@ -711,6 +711,29 @@ select:focus-visible { gap: 0.35rem; } +.action-link { + display: inline-flex; + align-items: center; + gap: 0.45rem; + align-self: flex-start; + padding: 0.55rem 0.65rem; + border: 1px solid #b39f79; + border-radius: 0.45rem; + background: #f9f2e2; + color: var(--text); + font-weight: 700; + text-decoration: none; +} + +.action-link:hover { + background: var(--button-hover); +} + +.action-link:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + .add-row-button { display: inline-flex; align-items: center;