Add admin database download

This commit is contained in:
2026-04-01 22:25:43 +02:00
parent 637a2ef7ac
commit b062ad1adf
10 changed files with 132 additions and 7 deletions

View File

@@ -52,6 +52,7 @@ Gameplay capabilities now include:
- GM-driven character owner transfer within campaign management flows - GM-driven character owner transfer within campaign management flows
- Character owner selection in edit modal backed by existing-username dropdown data - Character owner selection in edit modal backed by existing-username dropdown data
- Role-aware authorization with admin role support (including admin user/role management) - 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) - 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 - 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 - Play screen visibility is owner-scoped: only owned characters are listed, and private log entries are visible only to the roller

View File

@@ -1,3 +1,5 @@
using System.Text;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class CampaignApiTests : ApiTestBase public sealed class CampaignApiTests : ApiTestBase
@@ -160,6 +162,40 @@ public sealed class CampaignApiTests : ApiTestBase
Assert.Contains(usersAfterDelete, user => user.Id == gm.Id); 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] [Fact]
public async Task CampaignOptionsEndpoint_ReturnsCampaignsBeyondVisibleCampaignList() public async Task CampaignOptionsEndpoint_ReturnsCampaignsBeyondVisibleCampaignList()
{ {

View File

@@ -23,6 +23,23 @@ public sealed class HostingCoverageTests
Assert.Contains(services, d => d.ServiceType == typeof(IDiceRoller)); 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<string, string?> { ["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<SqliteDatabaseFile>();
Assert.Equal(Path.Combine(contentRoot, "App_Data", "rpgroller.db"), databaseFile.Path);
Assert.True(Directory.Exists(Path.Combine(contentRoot, "App_Data")));
}
[Fact] [Fact]
public void SqliteSchemaUpgrader_MigratesLegacySchema() public void SqliteSchemaUpgrader_MigratesLegacySchema()
{ {

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Hosting;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
@@ -38,8 +39,10 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
services.RemoveAll<DbContextOptions<RpgRollerDbContext>>(); services.RemoveAll<DbContextOptions<RpgRollerDbContext>>();
services.RemoveAll<IDbContextFactory<RpgRollerDbContext>>(); services.RemoveAll<IDbContextFactory<RpgRollerDbContext>>();
services.RemoveAll<RpgRollerDbContext>(); services.RemoveAll<RpgRollerDbContext>();
services.RemoveAll<SqliteDatabaseFile>();
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db"); var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db");
services.AddSingleton(new SqliteDatabaseFile(dbPath));
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite($"Data Source={dbPath}")); services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite($"Data Source={dbPath}"));
})); }));
} }

View File

@@ -1,4 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Domain;
using RpgRoller.Hosting;
using RpgRoller.Services; using RpgRoller.Services;
namespace RpgRoller.Api; namespace RpgRoller.Api;
@@ -25,6 +28,23 @@ internal static class AdminEndpoints
return ApiResultMapper.ToApiResult(result); return ApiResultMapper.ToApiResult(result);
}); });
group.MapGet("/admin/database", Results<FileStreamHttpResult, BadRequest<ApiError>, 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; return group;
} }
} }

View File

@@ -98,6 +98,18 @@
else if (IsAdminScreen) else if (IsAdminScreen)
{ {
<main class="management-screen"> <main class="management-screen">
@if (IsCurrentUserAdmin)
{
<section class="card">
<div class="section-head">
<h2>Database</h2>
</div>
<p class="muted">Download the current SQLite file for backup or offline inspection.</p>
<div class="management-actions">
<a class="action-link" href="@AdminDatabaseDownloadUrl" download>Download SQLite database</a>
</div>
</section>
}
<section class="card"> <section class="card">
<div class="section-head"> <div class="section-head">
<h2>User Management</h2> <h2>User Management</h2>

View File

@@ -928,6 +928,9 @@ public partial class Workspace : IAsyncDisposable
[Inject] [Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!; private RpgRollerApiClient ApiClient { get; set; } = null!;
[Inject]
private NavigationManager Navigation { get; set; } = null!;
private UserSummary? User { get; set; } private UserSummary? User { get; set; }
private Guid? ActiveCharacterId { get; set; } private Guid? ActiveCharacterId { get; set; }
private Guid? SelectedCampaignId { 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 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 ScreenPlay = "play";
private const string ScreenManagement = "management"; private const string ScreenManagement = "management";

View File

@@ -12,9 +12,11 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddRpgRollerCore(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment environment) public static IServiceCollection AddRpgRollerCore(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment environment)
{ {
var sqliteConnectionString = configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db"; 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<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>(); services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
services.AddSingleton(new SqliteDatabaseFile(sqliteDatabasePath));
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString)); services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString));
services.AddSingleton<IDiceRoller, RandomDiceRoller>(); services.AddSingleton<IDiceRoller, RandomDiceRoller>();
services.AddSingleton<IGameService, GameService>(); services.AddSingleton<IGameService, GameService>();
@@ -22,15 +24,19 @@ public static class ServiceCollectionExtensions
return services; return services;
} }
private static void EnsureSqliteDataDirectory(string connectionString, string contentRootPath) private static string? ResolveSqliteDatabasePath(string connectionString, string contentRootPath)
{ {
var builder = new SqliteConnectionStringBuilder(connectionString); var builder = new SqliteConnectionStringBuilder(connectionString);
if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource == ":memory:") 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)) if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
} }

View File

@@ -0,0 +1,3 @@
namespace RpgRoller.Hosting;
public sealed record SqliteDatabaseFile(string? Path);

View File

@@ -711,6 +711,29 @@ select:focus-visible {
gap: 0.35rem; 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 { .add-row-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;