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
|
- 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
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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}"));
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user