diff --git a/Guestlist/Components/Pages/Admin.razor b/Guestlist/Components/Pages/Admin.razor index a991763..4c306cc 100644 --- a/Guestlist/Components/Pages/Admin.razor +++ b/Guestlist/Components/Pages/Admin.razor @@ -121,7 +121,7 @@

Reset setup

-

Move existing users to backup and mark setup as incomplete.

+

Copy the current database to a timestamped backup database and start setup with a fresh database.

-@if (IsSetupComplete is null || HasAnyUsers is null || HasAnyUserBackups is null) +@if (IsSetupComplete is null || HasAnyUsers is null || HasAnyDatabaseBackups is null) {
@@ -40,9 +40,9 @@ else if (HasAnyUsers == true) } else { - +
-
+

First admin

@@ -59,11 +59,7 @@ else
-
-
-
-

Guest access

Set the first code guests use to view the list.

@@ -80,20 +76,22 @@ else { } -
+
- @if (HasAnyUserBackups == true) + @if (HasAnyDatabaseBackups == true) {

Restore backup

-

Restore the latest backed-up users.

- +

Restore the latest database backup created by setup reset.

+
+ +
} - +
} diff --git a/Guestlist/Components/Pages/Setup.razor.cs b/Guestlist/Components/Pages/Setup.razor.cs index 4476791..db3b488 100644 --- a/Guestlist/Components/Pages/Setup.razor.cs +++ b/Guestlist/Components/Pages/Setup.razor.cs @@ -12,6 +12,12 @@ public partial class Setup : ComponentBase [Inject] protected AccessCodeService AccessCodeService { get; set; } = default!; + + [Inject] + protected GuestEntryService GuestEntryService { get; set; } = default!; + + [Inject] + protected DatabaseBackupService DatabaseBackupService { get; set; } = default!; protected string Username { get; set; } = ""; protected string DisplayName { get; set; } = ""; @@ -19,7 +25,7 @@ public partial class Setup : ComponentBase protected string ErrorMessage { get; set; } = ""; protected bool? HasAnyUsers { get; set; } - protected bool? HasAnyUserBackups { get; set; } + protected bool? HasAnyDatabaseBackups { get; set; } protected string GuestAccessCode { get; set; } = ""; @@ -28,6 +34,15 @@ public partial class Setup : ComponentBase [SupplyParameterFromQuery(Name = "error")] public string? SetupError { get; set; } + [SupplyParameterFromQuery(Name = "backupDatabase")] + public string? BackupDatabase { get; set; } + + [SupplyParameterFromQuery(Name = "backedUpCollections")] + public int? BackedUpCollections { get; set; } + + [SupplyParameterFromQuery(Name = "backedUpDocuments")] + public long? BackedUpDocuments { get; set; } + protected override async Task OnInitializedAsync() { if (!string.IsNullOrWhiteSpace(SetupError)) @@ -36,6 +51,9 @@ public partial class Setup : ComponentBase { "existing-user" => "Setup cannot create another initial admin user.", "setup-failed" => "Setup could not be completed. No partial admin user was kept.", + "setup-reset" => $"Setup has been reset. The old database was copied to {BackupDatabase ?? "a backup database"}. {BackedUpCollections ?? 0} collections and {BackedUpDocuments ?? 0} documents were backed up.", + "restore-users-exist" => "Cannot restore backup while users already exist.", + "restore-failed" => "Could not restore latest database backup.", _ => SetupError }; } @@ -44,14 +62,14 @@ public partial class Setup : ComponentBase { IsSetupComplete = await SetupStatusService.IsSetupCompleteAsync(); HasAnyUsers = await UserService.HasAnyUsersAsync(); - HasAnyUserBackups = await UserService.HasAnyUserBackupsAsync(); + HasAnyDatabaseBackups = await DatabaseBackupService.HasAnyDatabaseBackupsAsync(); } catch { ErrorMessage = "Setup status could not be loaded. Please try again."; IsSetupComplete = false; HasAnyUsers = false; - HasAnyUserBackups = false; + HasAnyDatabaseBackups = false; } } @@ -137,33 +155,36 @@ public partial class Setup : ComponentBase return; } - if (HasAnyUserBackups != true) + if (HasAnyDatabaseBackups != true) { - ErrorMessage = "No user backups found."; + ErrorMessage = "No database backups found."; return; } - bool restored; + DatabaseBackupResult? restored; try { - restored = await UserService.RestoreLatestUserBackupAsync(); + restored = await DatabaseBackupService.RestoreLatestDatabaseBackupAsync(); } catch { - ErrorMessage = "Could not restore latest user backup."; + ErrorMessage = "Could not restore latest database backup."; return; } - if (!restored) + if (restored is null) { - ErrorMessage = "Could not restore latest user backup."; + ErrorMessage = "Could not restore latest database backup."; return; } try { await SetupStatusService.MarkSetupCompleteAsync(); + await UserService.EnsureIndexesAsync(); + await GuestEntryService.EnsureIndexesAsync(); + await AccessCodeService.EnsureIndexesAsync(); } catch { @@ -173,8 +194,8 @@ public partial class Setup : ComponentBase HasAnyUsers = true; IsSetupComplete = true; - HasAnyUserBackups = await UserService.HasAnyUserBackupsAsync(); + HasAnyDatabaseBackups = await DatabaseBackupService.HasAnyDatabaseBackupsAsync(); - ErrorMessage = "Latest user backup has been restored."; + ErrorMessage = "Latest database backup has been restored."; } } diff --git a/Guestlist/MongoWrapper.cs b/Guestlist/MongoWrapper.cs index 8006ee7..c324e03 100644 --- a/Guestlist/MongoWrapper.cs +++ b/Guestlist/MongoWrapper.cs @@ -11,12 +11,14 @@ public sealed class MongoOptions public sealed class MongoDbContext { + public IMongoClient Client { get; } public IMongoDatabase Database { get; } + public string DatabaseName { get; } public MongoDbContext(IOptions options) { - var client = new MongoClient(options.Value.ConnectionString); - Database = client.GetDatabase(options.Value.DatabaseName); + Client = new MongoClient(options.Value.ConnectionString); + DatabaseName = options.Value.DatabaseName; + Database = Client.GetDatabase(DatabaseName); } } - diff --git a/Guestlist/Program.cs b/Guestlist/Program.cs index a413f28..2945012 100644 --- a/Guestlist/Program.cs +++ b/Guestlist/Program.cs @@ -21,6 +21,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) @@ -233,6 +234,40 @@ app.MapPost("/setup/complete", async ( return Results.Redirect("/login"); }); +app.MapPost("/setup/restore", async ( + DatabaseBackupService databaseBackupService, + UserService userService, + GuestEntryService guestEntryService, + AccessCodeService accessCodeService, + SetupStatusService setupStatusService) => +{ + try + { + if (await userService.HasAnyUsersAsync()) + { + return Results.Redirect("/setup?error=restore-users-exist"); + } + + var restored = await databaseBackupService.RestoreLatestDatabaseBackupAsync(); + + if (restored is null) + { + return Results.Redirect("/setup?error=restore-failed"); + } + + await setupStatusService.MarkSetupCompleteAsync(); + await userService.EnsureIndexesAsync(); + await guestEntryService.EnsureIndexesAsync(); + await accessCodeService.EnsureIndexesAsync(); + } + catch + { + return Results.Redirect("/setup?error=restore-failed"); + } + + return Results.Redirect("/login"); +}); + app.MapPost("/guests/add", async ( HttpContext httpContext, GuestEntryService guestEntryService) => @@ -406,20 +441,40 @@ app.MapPost("/admin/maintainers/delete", async ( }).RequireAuthorization("AdminOnly"); app.MapPost("/admin/reset-setup", async ( + HttpContext httpContext, + DatabaseBackupService databaseBackupService, UserService userService, + GuestEntryService guestEntryService, + AccessCodeService accessCodeService, SetupStatusService setupStatusService) => { + DatabaseBackupResult backupResult; + try { - await userService.BackupAndClearUsersAsync("Setup reset from admin panel"); + backupResult = await databaseBackupService.BackupAndDropActiveDatabaseAsync("Setup reset from admin panel"); await setupStatusService.ResetSetupAsync(); + await userService.EnsureIndexesAsync(); + await guestEntryService.EnsureIndexesAsync(); + await accessCodeService.EnsureIndexesAsync(); + await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + httpContext.Session.Clear(); } - catch + catch (InvalidOperationException exception) { - return Results.Redirect("/admin?message=Setup could not be reset."); + var error = Uri.EscapeDataString(exception.Message); + return Results.Redirect($"/admin?message={error}"); + } + catch (Exception exception) + { + var error = Uri.EscapeDataString($"Setup could not be reset: {exception.Message}"); + return Results.Redirect($"/admin?message={error}"); } - return Results.Redirect("/admin?message=Setup has been reset. Existing users were moved to backup."); + var backupDatabase = Uri.EscapeDataString(backupResult.BackupDatabaseName); + + return Results.Redirect( + $"/setup?error=setup-reset&backupDatabase={backupDatabase}&backedUpCollections={backupResult.CollectionCount}&backedUpDocuments={backupResult.DocumentCount}"); }).RequireAuthorization("AdminOnly"); app.MapPost("/admin/setup-recovery", async ( @@ -480,6 +535,7 @@ app.Use(async (context, next) => isStaticFile || path.StartsWithSegments("/setup") || path.StartsWithSegments("/setup/complete") || + path.StartsWithSegments("/setup/restore") || path.StartsWithSegments("/auth/logout") || path.StartsWithSegments("/guest/logout") || path.StartsWithSegments("/_framework") || diff --git a/Guestlist/UserService.cs b/Guestlist/UserService.cs index a207702..d7acc60 100644 --- a/Guestlist/UserService.cs +++ b/Guestlist/UserService.cs @@ -70,7 +70,7 @@ public sealed class UserService(MongoDbContext db) public async Task HasAnyUsersAsync() => await _users.CountDocumentsAsync(FilterDefinition.Empty) > 0; - public async Task BackupAndClearUsersAsync(string reason) + public async Task BackupAndClearUsersAsync(string reason) { var users = await _users .Find(FilterDefinition.Empty) @@ -92,7 +92,21 @@ public sealed class UserService(MongoDbContext db) await _userBackups.InsertManyAsync(backups); } - await _users.DeleteManyAsync(FilterDefinition.Empty); + var deleteResult = await _users.DeleteManyAsync(FilterDefinition.Empty); + + if (!deleteResult.IsAcknowledged) + { + throw new InvalidOperationException("MongoDB did not acknowledge user deletion."); + } + + var remainingUsers = await _users.CountDocumentsAsync(FilterDefinition.Empty); + + if (remainingUsers > 0) + { + throw new InvalidOperationException("Users could not be cleared."); + } + + return users.Count; } public async Task HasAnyUserBackupsAsync() diff --git a/README.md b/README.md index a2adb90..0b495cb 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,5 @@ ASPNETCORE_URLS: http://+:8080 ``` MongoDB-Daten liegen im Compose-Volume `mongo_data`. + +Beim Reset im Admin-Bereich kopiert die App die aktuelle Datenbank zuerst in eine neue Backup-Datenbank mit dem Muster `guestlist_backup_`. Danach wird die aktive Datenbank frisch angelegt und das Setup ist wieder offen. Auf der Setup-Seite kann das letzte Datenbank-Backup wiederhergestellt werden.