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

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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";

View File

@@ -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,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);
}
}
}

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;
}
.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;