fix(backup): use active SQLite database and validate safe restore (U01)

This commit is contained in:
2026-11-02 06:21:24 +01:00
parent 317c696e70
commit a3c1bee09e
9 changed files with 269 additions and 56 deletions
@@ -10,6 +10,7 @@ public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
{
services.AddSingleton(new DatabaseLocation(connectionString));
services.AddDbContext<FinanceDbContext>(options =>
options.UseSqlite(connectionString));
services.AddDbContextFactory<FinanceDbContext>(options =>
@@ -11,7 +11,13 @@ public class AccountService : IAccountService
{
private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
public AccountService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
private readonly DatabaseLocation? _location;
public AccountService(IDbContextFactory<FinanceDbContext> dbFactory, DatabaseLocation? location = null)
{
_dbFactory = dbFactory;
_location = location;
}
public Task<List<AccountSummaryDto>> GetAllAccountsAsync()
{
@@ -20,6 +26,7 @@ public class AccountService : IAccountService
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var accounts = await db.Accounts
@@ -71,6 +78,7 @@ public class AccountService : IAccountService
public async Task<AccountSummaryDto> GetAccountAsync(int accountId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts
@@ -87,6 +95,7 @@ public class AccountService : IAccountService
public async Task<AccountSummaryDto> CreateAccountAsync(string name)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = new Account { Name = name };
@@ -97,6 +106,7 @@ public class AccountService : IAccountService
public async Task RenameAccountAsync(int accountId, string newName)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
@@ -107,6 +117,7 @@ public class AccountService : IAccountService
public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
@@ -123,6 +134,7 @@ public class AccountService : IAccountService
return;
}
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
@@ -138,6 +150,7 @@ public class AccountService : IAccountService
public async Task DeleteAccountAsync(int accountId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts
@@ -7,70 +7,121 @@ namespace Duempelkas.Infrastructure.Services;
public class BackupService : IBackupService
{
private static readonly string DbPath = Path.Combine(
Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory,
"duempelkas.db");
private readonly DatabaseLocation location;
public BackupService(DatabaseLocation location) => this.location = location;
public async Task<string> CreateBackupAsync()
{
if (!File.Exists(DbPath))
{
return "Keine Datenbankdatei gefunden.";
}
var suggestedFileName = $"duempelkas-backup-{DateTime.Now:yyMMdd-HHmm}.bak";
var backupPath = await ShowSaveDialogAsync(suggestedFileName);
if (string.IsNullOrWhiteSpace(backupPath))
{
return "Backup wurde abgebrochen.";
}
backupPath = Path.ChangeExtension(backupPath, ".bak");
try
{
File.Copy(DbPath, backupPath, overwrite: true);
return $"Backup gespeichert: {backupPath}";
}
catch (Exception ex)
{
return $"Backup fehlgeschlagen: {ex.Message}";
var path = await ShowSaveDialogAsync($"duempelkas-backup-{DateTime.Now:yyyyMMdd-HHmmss}.bak");
if (string.IsNullOrWhiteSpace(path)) return "Sicherung wurde abgebrochen.";
await CreateBackupToAsync(path);
return $"Sicherung gespeichert: {path}";
}
catch (Exception ex) { return $"Sicherung fehlgeschlagen: {ex.Message}"; }
}
public async Task<string> RestoreBackupAsync()
{
var backupPath = await ShowOpenDialogAsync();
if (string.IsNullOrWhiteSpace(backupPath))
{
return "Wiederherstellung wurde abgebrochen.";
}
if (!File.Exists(backupPath))
{
return "Die gewählte Backup-Datei wurde nicht gefunden.";
}
try
{
// Ensure no pooled connection keeps stale handles to the previous DB file.
SqliteConnection.ClearAllPools();
var targetDirectory = Path.GetDirectoryName(DbPath);
if (!string.IsNullOrWhiteSpace(targetDirectory))
{
Directory.CreateDirectory(targetDirectory);
}
File.Copy(backupPath, DbPath, overwrite: true);
SqliteConnection.ClearAllPools();
return "Wiederherstellung erfolgreich. Die Datenbank wurde neu geladen.";
var path = await ShowOpenDialogAsync();
if (string.IsNullOrWhiteSpace(path)) return "Wiederherstellung wurde abgebrochen.";
var fallback = await RestoreFromAsync(path);
return $"Wiederherstellung erfolgreich. Rückfallsicherung: {fallback}";
}
catch (Exception ex)
catch (Exception ex) { return $"Wiederherstellung fehlgeschlagen: {ex.Message}"; }
}
public async Task CreateBackupToAsync(string path)
{
using var lease = await DatabaseLease.AcquireAsync(location);
RejectActivePath(path);
await Task.Run(() =>
{
return $"Wiederherstellung fehlgeschlagen: {ex.Message}";
}
var staged = System.IO.Path.GetFullPath(path) + $".{Guid.NewGuid():N}.tmp";
try
{
Snapshot(location.Path, staged);
File.Move(staged, path, overwrite: true);
}
finally { if (File.Exists(staged)) File.Delete(staged); }
});
}
public async Task<string> RestoreFromAsync(string path)
{
using var lease = await DatabaseLease.AcquireAsync(location);
return await Task.Run(() =>
{
RejectActivePath(path);
// Stage the selected database first, so a changing/removable source cannot
// change between validation and restore. BackupDatabase includes WAL data.
var staged = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"duempelkas-{Guid.NewGuid():N}.db");
try
{
Snapshot(path, staged);
Validate(staged);
var fallback = location.Path + $".before-restore-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.bak";
Snapshot(location.Path, fallback);
// SQLite's online backup atomically replaces database contents, retaining
// the active file/handles and coordinating locks. Never copy over an open DB.
Snapshot(staged, location.Path);
return fallback;
}
finally { if (File.Exists(staged)) File.Delete(staged); }
});
}
private void RejectActivePath(string path)
{
if (new[] { location.Path, location.Path + "-wal", location.Path + "-shm", location.Path + "-journal" }
.Contains(System.IO.Path.GetFullPath(path), StringComparer.OrdinalIgnoreCase))
throw new InvalidOperationException("Bitte eine andere Datei als die aktive Datenbank wählen.");
}
private static SqliteConnection Open(string path, SqliteOpenMode mode)
{
var connection = new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = System.IO.Path.GetFullPath(path), Mode = mode, Pooling = false
}.ToString());
try { connection.Open(); return connection; }
catch { connection.Dispose(); throw; }
}
private static void Snapshot(string source, string destination)
{
if (string.Equals(System.IO.Path.GetFullPath(source), System.IO.Path.GetFullPath(destination), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Quelle und Ziel müssen verschiedene Dateien sein.");
using var input = Open(source, SqliteOpenMode.ReadOnly);
using var output = Open(destination, SqliteOpenMode.ReadWriteCreate);
input.BackupDatabase(output);
}
private static void Validate(string path)
{
using var connection = Open(path, SqliteOpenMode.ReadOnly);
using var check = connection.CreateCommand();
check.CommandText = "PRAGMA integrity_check";
if (!string.Equals(check.ExecuteScalar()?.ToString(), "ok", StringComparison.Ordinal))
throw new InvalidOperationException("Die Sicherung ist beschädigt.");
check.CommandText = "PRAGMA foreign_key_check";
using (var reader = check.ExecuteReader())
if (reader.Read()) throw new InvalidOperationException("Die Sicherung enthält ungültige Verknüpfungen.");
var options = new Microsoft.EntityFrameworkCore.DbContextOptionsBuilder<Duempelkas.Infrastructure.Persistence.FinanceDbContext>();
Microsoft.EntityFrameworkCore.SqliteDbContextOptionsBuilderExtensions.UseSqlite(options, connection);
using var db = new Duempelkas.Infrastructure.Persistence.FinanceDbContext(options.Options);
var expected = Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.GetMigrations(db.Database).ToArray();
var applied = Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.GetAppliedMigrations(db.Database).ToArray();
if (!expected.SequenceEqual(applied))
throw new InvalidOperationException("Diese Sicherung passt nicht zu dieser Programmversion.");
// Materialize every mapped column to reject a forged/missing schema before touching active data.
_ = db.Accounts.ToArray();
_ = db.Entries.ToArray();
_ = db.TransferLinks.ToArray();
}
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
@@ -143,4 +194,4 @@ if ($dialog.ShowDialog() -eq 'OK') { $dialog.FileName } else { '' }
return string.IsNullOrWhiteSpace(result) ? null : result;
}
}
}
@@ -0,0 +1,12 @@
namespace Duempelkas.Infrastructure.Services;
internal sealed class DatabaseLease(SemaphoreSlim? gate) : IDisposable
{
public static async Task<DatabaseLease> AcquireAsync(DatabaseLocation? location)
{
if (location != null) await location.Writes.WaitAsync();
return new DatabaseLease(location?.Writes);
}
public void Dispose() => gate?.Release();
}
@@ -0,0 +1,12 @@
using Microsoft.Data.Sqlite;
namespace Duempelkas.Infrastructure.Services;
public sealed class DatabaseLocation(string connectionString)
{
public string Path { get; } = System.IO.Path.GetFullPath(new SqliteConnectionStringBuilder(connectionString).DataSource);
public string ConnectionString { get; } = connectionString;
// All application mutations and restore share this gate. SQLite's backup API
// also coordinates readers and external connections through SQLite locks.
public SemaphoreSlim Writes { get; } = new(1, 1);
}
@@ -11,7 +11,13 @@ public class EntryService : IEntryService
{
private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
public EntryService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
private readonly DatabaseLocation? _location;
public EntryService(IDbContextFactory<FinanceDbContext> dbFactory, DatabaseLocation? location = null)
{
_dbFactory = dbFactory;
_location = location;
}
public Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly)
{
@@ -21,6 +27,7 @@ public class EntryService : IEntryService
public async Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var query = db.Entries.Where(e => e.AccountId == accountId);
@@ -67,6 +74,7 @@ public class EntryService : IEntryService
public async Task<List<int>> GetEntryYearsAsync(int accountId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
return await db.Entries
@@ -79,6 +87,7 @@ public class EntryService : IEntryService
public async Task<List<int>> GetAllEntryYearsAsync()
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
return await db.Entries
@@ -90,6 +99,7 @@ public class EntryService : IEntryService
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var displayId = await GenerateDisplayIdAsync(db, date.Year);
@@ -114,6 +124,7 @@ public class EntryService : IEntryService
if (sourceAccountId == targetAccountId)
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
await using var transaction = await db.Database.BeginTransactionAsync();
@@ -163,6 +174,7 @@ public class EntryService : IEntryService
public async Task DeleteEntryAsync(int entryId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
@@ -185,6 +197,7 @@ public class EntryService : IEntryService
public async Task RestoreEntryAsync(int entryId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
@@ -207,6 +220,7 @@ public class EntryService : IEntryService
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
@@ -236,6 +250,7 @@ public class EntryService : IEntryService
public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)