Files
duempelkas/src/Duempelkas.Infrastructure/Services/BackupService.cs
T

134 lines
6.0 KiB
C#

using Duempelkas.App.Services.Models;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Duempelkas.App.Services;
using Microsoft.Data.Sqlite;
namespace Duempelkas.Infrastructure.Services;
public class BackupService : IBackupService
{
private readonly DatabaseLocation location;
private readonly IFileDialogService dialogs;
public BackupService(DatabaseLocation location, IFileDialogService? dialogs = null)
{
this.location = location;
this.dialogs = dialogs ?? new FileDialogService();
}
public async Task<FileOperationResult> CreateBackupAsync()
{
try
{
var path = await dialogs.SaveAsync($"duempelkas-backup-{DateTime.Now:yyyyMMdd-HHmmss}.bak");
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
await CreateBackupToAsync(path);
return new(FileOperationStatus.Success, "Sicherung gespeichert.", path);
}
catch (Exception ex) { return FileOperationResult.Error(ex); }
}
public async Task<FileOperationResult> RestoreBackupAsync()
{
try
{
var path = await dialogs.OpenBackupAsync();
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
var fallback = await RestoreFromAsync(path);
return new(FileOperationStatus.Success, "Sicherung zurückgespielt. Die bisherigen Daten liegen in der Rückfallsicherung:", fallback);
}
catch (Exception ex) { return FileOperationResult.Error(ex); }
}
public async Task CreateBackupToAsync(string path)
{
using var lease = await DatabaseLease.AcquireAsync(location);
RejectActivePath(path);
await Task.Run(() =>
{
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();
}
}