Add admin database download
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<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]
|
||||
public void SqliteSchemaUpgrader_MigratesLegacySchema()
|
||||
{
|
||||
|
||||
@@ -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<WebApplicationFactory<Program>
|
||||
services.RemoveAll<DbContextOptions<RpgRollerDbContext>>();
|
||||
services.RemoveAll<IDbContextFactory<RpgRollerDbContext>>();
|
||||
services.RemoveAll<RpgRollerDbContext>();
|
||||
services.RemoveAll<SqliteDatabaseFile>();
|
||||
|
||||
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}"));
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,18 @@
|
||||
else if (IsAdminScreen)
|
||||
{
|
||||
<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">
|
||||
<div class="section-head">
|
||||
<h2>User Management</h2>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
|
||||
services.AddSingleton(new SqliteDatabaseFile(sqliteDatabasePath));
|
||||
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString));
|
||||
services.AddSingleton<IDiceRoller, RandomDiceRoller>();
|
||||
services.AddSingleton<IGameService, GameService>();
|
||||
@@ -22,15 +24,19 @@ 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);
|
||||
}
|
||||
|
||||
3
RpgRoller/Hosting/SqliteDatabaseFile.cs
Normal file
3
RpgRoller/Hosting/SqliteDatabaseFile.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace RpgRoller.Hosting;
|
||||
|
||||
public sealed record SqliteDatabaseFile(string? Path);
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user