Initial
This commit is contained in:
64
LehrerApp.Sync/ConflictResolver.cs
Normal file
64
LehrerApp.Sync/ConflictResolver.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Entscheidet bei kollidierenden Events welcher gewinnt.
|
||||
/// Desktop hat Vorrang vor Companion. Bei Gleichstand gewinnt der spätere Timestamp.
|
||||
/// </summary>
|
||||
public class ConflictResolver
|
||||
{
|
||||
private readonly EventQueue _queue;
|
||||
|
||||
public ConflictResolver(EventQueue queue)
|
||||
{
|
||||
_queue = queue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft ob ein Remote-Event mit einem lokalen Event kollidiert.
|
||||
/// Gibt null zurück wenn kein Konflikt besteht.
|
||||
/// </summary>
|
||||
public ConflictEntry? TryResolve(SyncEvent remoteEvent, string localDeviceId)
|
||||
{
|
||||
// Lokalen Event für dieselbe Entity suchen
|
||||
var localEvent = _queue.GetPending()
|
||||
.FirstOrDefault(e =>
|
||||
e.EntityType == remoteEvent.EntityType &&
|
||||
e.EntityId == remoteEvent.EntityId &&
|
||||
e.DeviceId != remoteEvent.DeviceId);
|
||||
|
||||
if (localEvent is null)
|
||||
return null; // kein Konflikt – Remote-Event einfach anwenden
|
||||
|
||||
// Konflikt aufgelöst – Gewinner bestimmen
|
||||
var winner = DetermineWinner(localEvent, remoteEvent);
|
||||
var resolution = winner == localEvent ? "LocalWon" : "RemoteWon";
|
||||
|
||||
// Wenn Remote gewinnt: lokalen Event aus Queue entfernen
|
||||
if (winner == remoteEvent)
|
||||
_queue.Acknowledge([localEvent.EventId]);
|
||||
|
||||
return new ConflictEntry
|
||||
{
|
||||
LocalEvent = localEvent,
|
||||
RemoteEvent = remoteEvent,
|
||||
Resolution = resolution,
|
||||
};
|
||||
}
|
||||
|
||||
private static SyncEvent DetermineWinner(SyncEvent local, SyncEvent remote)
|
||||
{
|
||||
// Regel 1: Desktop schlägt Companion
|
||||
if (local.DeviceType == DeviceType.Desktop &&
|
||||
remote.DeviceType == DeviceType.Companion)
|
||||
return local;
|
||||
|
||||
if (remote.DeviceType == DeviceType.Desktop &&
|
||||
local.DeviceType == DeviceType.Companion)
|
||||
return remote;
|
||||
|
||||
// Regel 2: Späterer Timestamp gewinnt
|
||||
return local.Timestamp >= remote.Timestamp ? local : remote;
|
||||
}
|
||||
}
|
||||
163
LehrerApp.Sync/Crypto/SyncCrypto.cs
Normal file
163
LehrerApp.Sync/Crypto/SyncCrypto.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Sync.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// AES-256-GCM Verschlüsselung für Sync-Payloads.
|
||||
/// Der Schlüssel verlässt nie den Client – der Server sieht nur Ciphertext.
|
||||
/// </summary>
|
||||
public class SyncCrypto
|
||||
{
|
||||
private const int KeySize = 32; // 256 bit
|
||||
private const int NonceSize = 12; // 96 bit – GCM Standard
|
||||
private const int TagSize = 16; // 128 bit Auth-Tag
|
||||
|
||||
// PBKDF2-Parameter für Schlüsselableitung aus Einmal-Code
|
||||
private const int Pbkdf2Iterations = 100_000;
|
||||
private const int Pbkdf2KeySize = 32; // 256 bit
|
||||
|
||||
// ── Schlüsselverwaltung ───────────────────────────────────────────────────
|
||||
|
||||
public static byte[] GenerateKey()
|
||||
{
|
||||
var key = new byte[KeySize];
|
||||
RandomNumberGenerator.Fill(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
public static string KeyToBase64(byte[] key) =>
|
||||
Convert.ToBase64String(key);
|
||||
|
||||
public static byte[] KeyFromBase64(string base64) =>
|
||||
Convert.FromBase64String(base64);
|
||||
|
||||
// ── Schlüssel aus Einmal-Code ableiten (PBKDF2) ───────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Leitet einen symmetrischen Schlüssel aus dem Einmal-Code ab.
|
||||
/// PBKDF2 macht Brute-Force bei kurzem Code teuer.
|
||||
///
|
||||
/// Salt ist fest definiert (kein Geheimnis, nur Domänentrennung).
|
||||
/// Der Code selbst ist das Geheimnis – 24h TTL schützt vor Angriffsfenstern.
|
||||
/// </summary>
|
||||
public static byte[] DeriveKeyFromCode(string code)
|
||||
{
|
||||
// Normalisieren: Groß, Leerzeichen entfernen
|
||||
var normalized = code.Trim().ToUpperInvariant();
|
||||
var codeBytes = Encoding.UTF8.GetBytes(normalized);
|
||||
|
||||
// Salt: App-spezifisch, öffentlich bekannt – verhindert
|
||||
// Rainbow-Tables gegen andere Anwendungen
|
||||
var salt = Encoding.UTF8.GetBytes("LehrerApp-PairingCode-v1");
|
||||
|
||||
return Rfc2898DeriveBytes.Pbkdf2(
|
||||
codeBytes,
|
||||
salt,
|
||||
Pbkdf2Iterations,
|
||||
HashAlgorithmName.SHA256,
|
||||
Pbkdf2KeySize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verschlüsselt den Sync-Schlüssel mit dem Code-Key.
|
||||
/// Ergebnis wird als Base64 auf dem Server hinterlegt.
|
||||
/// </summary>
|
||||
public static string EncryptKeyWithCode(byte[] syncKey, string code)
|
||||
{
|
||||
var codeKey = DeriveKeyFromCode(code);
|
||||
var encrypted = Encrypt(syncKey, codeKey);
|
||||
return Convert.ToBase64String(encrypted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entschlüsselt den Sync-Schlüssel mit dem Code.
|
||||
/// Wirft CryptographicException wenn der Code falsch ist.
|
||||
/// </summary>
|
||||
public static byte[] DecryptKeyWithCode(string encryptedKeyBase64, string code)
|
||||
{
|
||||
var codeKey = DeriveKeyFromCode(code);
|
||||
var encrypted = Convert.FromBase64String(encryptedKeyBase64);
|
||||
return Decrypt(encrypted, codeKey);
|
||||
}
|
||||
|
||||
// ── Ver-/Entschlüsselung ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Verschlüsselt mit AES-256-GCM.
|
||||
/// Format: [Nonce 12 Bytes][Ciphertext][Auth-Tag 16 Bytes]
|
||||
/// </summary>
|
||||
public static byte[] Encrypt(byte[] plaintext, byte[] key)
|
||||
{
|
||||
var nonce = new byte[NonceSize];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
var tag = new byte[TagSize];
|
||||
|
||||
using var aes = new AesGcm(key, TagSize);
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||
|
||||
var result = new byte[NonceSize + ciphertext.Length + TagSize];
|
||||
nonce.CopyTo(result, 0);
|
||||
ciphertext.CopyTo(result, NonceSize);
|
||||
tag.CopyTo(result, NonceSize + ciphertext.Length);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static byte[] Decrypt(byte[] encrypted, byte[] key)
|
||||
{
|
||||
if (encrypted.Length < NonceSize + TagSize)
|
||||
throw new CryptographicException("Ungültiges verschlüsseltes Format.");
|
||||
|
||||
var nonce = encrypted[..NonceSize];
|
||||
var tag = encrypted[^TagSize..];
|
||||
var ciphertext = encrypted[NonceSize..^TagSize];
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
|
||||
using var aes = new AesGcm(key, TagSize);
|
||||
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
// ── Komfort-Methoden für JSON-Payloads ────────────────────────────────────
|
||||
|
||||
public static string EncryptObject<T>(T obj, byte[] key)
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(obj);
|
||||
var encrypted = Encrypt(json, key);
|
||||
return Convert.ToBase64String(encrypted);
|
||||
}
|
||||
|
||||
public static T? DecryptObject<T>(string base64, byte[] key)
|
||||
{
|
||||
var encrypted = Convert.FromBase64String(base64);
|
||||
var json = Decrypt(encrypted, key);
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
|
||||
// ── Schlüsselspeicherung ──────────────────────────────────────────────────
|
||||
|
||||
public static void SaveKey(byte[] key, string keyFilePath)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(keyFilePath);
|
||||
if (dir != null) Directory.CreateDirectory(dir);
|
||||
|
||||
File.WriteAllText(keyFilePath, KeyToBase64(key));
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetUnixFileMode(keyFilePath,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? LoadKey(string keyFilePath)
|
||||
{
|
||||
if (!File.Exists(keyFilePath)) return null;
|
||||
return KeyFromBase64(File.ReadAllText(keyFilePath).Trim());
|
||||
}
|
||||
}
|
||||
214
LehrerApp.Sync/EventApplier.cs
Normal file
214
LehrerApp.Sync/EventApplier.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Wendet vom Server gezogene Events auf die lokale LiteDB an.
|
||||
///
|
||||
/// Unterscheidet zwischen:
|
||||
/// - Verschlüsselten Desktop-Events → erst entschlüsseln, dann anwenden
|
||||
/// - Klartext-Events von WebApp/Companion → direkt anwenden
|
||||
/// </summary>
|
||||
public class EventApplier
|
||||
{
|
||||
private readonly byte[] _key;
|
||||
|
||||
// Repositories für alle Entity-Typen
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly IEnrollmentRepository _enrollments;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _examResults;
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IUnitRepository _units;
|
||||
private readonly ILessonRepository _lessons;
|
||||
private readonly IDocumentationRepository _docs;
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
private readonly ITimeEntryRepository _timeEntries;
|
||||
|
||||
public EventApplier(
|
||||
byte[] key,
|
||||
IStudentRepository students,
|
||||
IGroupRepository groups,
|
||||
IEnrollmentRepository enrollments,
|
||||
IExamRepository exams,
|
||||
IExamResultRepository examResults,
|
||||
IGradeRepository grades,
|
||||
IUnitRepository units,
|
||||
ILessonRepository lessons,
|
||||
IDocumentationRepository docs,
|
||||
IWorkTaskRepository tasks,
|
||||
ITimeEntryRepository timeEntries)
|
||||
{
|
||||
_key = key;
|
||||
_students = students;
|
||||
_groups = groups;
|
||||
_enrollments = enrollments;
|
||||
_exams = exams;
|
||||
_examResults = examResults;
|
||||
_grades = grades;
|
||||
_units = units;
|
||||
_lessons = lessons;
|
||||
_docs = docs;
|
||||
_tasks = tasks;
|
||||
_timeEntries = timeEntries;
|
||||
}
|
||||
|
||||
// ── Hauptmethode ──────────────────────────────────────────────────────────
|
||||
|
||||
public void Apply(SyncEvent evt)
|
||||
{
|
||||
var json = ResolvePayload(evt);
|
||||
if (string.IsNullOrWhiteSpace(json)) return;
|
||||
|
||||
try
|
||||
{
|
||||
ApplyJson(evt.EntityType, evt.Operation, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Einzelne fehlerhafte Events nicht die ganze Sync-Session abbrechen
|
||||
Console.Error.WriteLine(
|
||||
$"EventApplier: Fehler bei {evt.EntityType}/{evt.Operation} " +
|
||||
$"({evt.EventId}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyAll(IEnumerable<SyncEvent> events)
|
||||
{
|
||||
// Chronologisch anwenden
|
||||
foreach (var evt in events.OrderBy(e => e.Timestamp))
|
||||
Apply(evt);
|
||||
}
|
||||
|
||||
// ── Payload auflösen ──────────────────────────────────────────────────────
|
||||
|
||||
private string ResolvePayload(SyncEvent evt)
|
||||
{
|
||||
// Klartext-Events von WebApp/Companion
|
||||
if (evt.DeviceType == DeviceType.Companion)
|
||||
return evt.Payload;
|
||||
|
||||
// Verschlüsselte Events vom Desktop entschlüsseln
|
||||
try
|
||||
{
|
||||
var encrypted = Convert.FromBase64String(evt.Payload);
|
||||
var decrypted = SyncCrypto.Decrypt(encrypted, _key);
|
||||
return Encoding.UTF8.GetString(decrypted);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"EventApplier: Entschlüsselung fehlgeschlagen " +
|
||||
$"({evt.EventId}): {ex.Message}");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entity-spezifische Anwendung ──────────────────────────────────────────
|
||||
|
||||
private void ApplyJson(string entityType, string operation, string json)
|
||||
{
|
||||
switch (entityType)
|
||||
{
|
||||
case "Student":
|
||||
ApplyEntity<Student>(operation, json,
|
||||
e => _students.Save(e),
|
||||
id => _students.Delete(id));
|
||||
break;
|
||||
|
||||
case "LearningGroup":
|
||||
ApplyEntity<LearningGroup>(operation, json,
|
||||
e => _groups.Save(e),
|
||||
id => _groups.Delete(id));
|
||||
break;
|
||||
|
||||
case "Enrollment":
|
||||
ApplyEntity<Enrollment>(operation, json,
|
||||
e => _enrollments.Save(e),
|
||||
id => _enrollments.Delete(id));
|
||||
break;
|
||||
|
||||
case "Exam":
|
||||
ApplyEntity<Exam>(operation, json,
|
||||
e => _exams.Save(e),
|
||||
id => _exams.Delete(id));
|
||||
break;
|
||||
|
||||
case "ExamResult":
|
||||
ApplyEntity<ExamResult>(operation, json,
|
||||
e => _examResults.Save(e),
|
||||
_ => { }); // ExamResults werden nicht einzeln gelöscht
|
||||
break;
|
||||
|
||||
case "Grade":
|
||||
ApplyEntity<Grade>(operation, json,
|
||||
e => _grades.Save(e),
|
||||
id => _grades.Delete(id));
|
||||
break;
|
||||
|
||||
case "Unit":
|
||||
ApplyEntity<Unit>(operation, json,
|
||||
e => _units.Save(e),
|
||||
id => _units.Delete(id));
|
||||
break;
|
||||
|
||||
case "Lesson":
|
||||
ApplyEntity<Lesson>(operation, json,
|
||||
e => _lessons.Save(e),
|
||||
id => _lessons.Delete(id));
|
||||
break;
|
||||
|
||||
case "Documentation":
|
||||
ApplyEntity<Documentation>(operation, json,
|
||||
e => _docs.Save(e),
|
||||
id => _docs.Delete(id));
|
||||
break;
|
||||
|
||||
case "WorkTask":
|
||||
ApplyEntity<WorkTask>(operation, json,
|
||||
e => _tasks.Save(e),
|
||||
id => _tasks.Delete(id));
|
||||
break;
|
||||
|
||||
case "TimeEntry":
|
||||
ApplyEntity<TimeEntry>(operation, json,
|
||||
e => _timeEntries.Save(e),
|
||||
id => _timeEntries.Delete(id));
|
||||
break;
|
||||
|
||||
default:
|
||||
Console.Error.WriteLine(
|
||||
$"EventApplier: Unbekannter EntityType '{entityType}'");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyEntity<T>(
|
||||
string operation,
|
||||
string json,
|
||||
Action<T> save,
|
||||
Action<Guid> delete) where T : class
|
||||
{
|
||||
switch (operation)
|
||||
{
|
||||
case "Upsert":
|
||||
case "Create":
|
||||
case "Update":
|
||||
var entity = JsonSerializer.Deserialize<T>(json);
|
||||
if (entity is not null) save(entity);
|
||||
break;
|
||||
|
||||
case "Delete":
|
||||
// Payload ist bei Delete nur die ID
|
||||
if (Guid.TryParse(json.Trim('"'), out var id))
|
||||
delete(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
LehrerApp.Sync/EventQueue.cs
Normal file
129
LehrerApp.Sync/EventQueue.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using LiteDB;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Lokale Event-Queue in LiteDB.
|
||||
/// Jede Datenänderung erzeugt einen Event – dieser wird hier gepuffert
|
||||
/// bis er erfolgreich zum Server gepusht wurde.
|
||||
/// </summary>
|
||||
public class EventQueue : IDisposable
|
||||
{
|
||||
private readonly LiteDatabase _db;
|
||||
private readonly ILiteCollection<SyncEvent> _queue;
|
||||
private readonly ILiteCollection<SyncMeta> _meta;
|
||||
private readonly ILiteCollection<ConflictEntry> _conflicts;
|
||||
private long _currentSequenceNr;
|
||||
|
||||
public EventQueue(string queueDbPath)
|
||||
{
|
||||
_db = new LiteDatabase(queueDbPath);
|
||||
_queue = _db.GetCollection<SyncEvent>("queue");
|
||||
_meta = _db.GetCollection<SyncMeta>("meta");
|
||||
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
|
||||
|
||||
_queue.EnsureIndex(x => x.SequenceNr);
|
||||
_queue.EnsureIndex(x => x.Timestamp);
|
||||
|
||||
// Letzte SequenceNr wiederherstellen
|
||||
var meta = _meta.FindById("seq");
|
||||
_currentSequenceNr = meta?.Value ?? 0;
|
||||
}
|
||||
|
||||
// ── Events einreihen ──────────────────────────────────────────────────────
|
||||
|
||||
public SyncEvent Enqueue(
|
||||
string entityType,
|
||||
string entityId,
|
||||
string operation,
|
||||
string encryptedPayload,
|
||||
string deviceId,
|
||||
DeviceType deviceType)
|
||||
{
|
||||
var evt = new SyncEvent
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
DeviceType = deviceType,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
SequenceNr = ++_currentSequenceNr,
|
||||
EntityType = entityType,
|
||||
EntityId = entityId,
|
||||
Operation = operation,
|
||||
Payload = encryptedPayload,
|
||||
};
|
||||
|
||||
_queue.Insert(evt);
|
||||
_meta.Upsert(new SyncMeta { Id = "seq", Value = _currentSequenceNr });
|
||||
|
||||
return evt;
|
||||
}
|
||||
|
||||
// ── Ausstehende Events ────────────────────────────────────────────────────
|
||||
|
||||
public List<SyncEvent> GetPending(int maxBatch = 100) =>
|
||||
_queue.Find(Query.All(nameof(SyncEvent.SequenceNr)))
|
||||
.Take(maxBatch)
|
||||
.ToList();
|
||||
|
||||
public int PendingCount() => _queue.Count();
|
||||
|
||||
// ── Bestätigung nach erfolgreichem Push ───────────────────────────────────
|
||||
|
||||
public void Acknowledge(IEnumerable<Guid> eventIds)
|
||||
{
|
||||
foreach (var id in eventIds)
|
||||
_queue.Delete(id);
|
||||
}
|
||||
|
||||
// ── Letzte Sync-Metadaten ─────────────────────────────────────────────────
|
||||
|
||||
public long GetLastServerSequenceNr() =>
|
||||
_meta.FindById("serverSeq")?.Value ?? 0;
|
||||
|
||||
public void SetLastServerSequenceNr(long nr) =>
|
||||
_meta.Upsert(new SyncMeta { Id = "serverSeq", Value = nr });
|
||||
|
||||
public DateTime? GetLastSyncAt()
|
||||
{
|
||||
var meta = _meta.FindById("lastSync");
|
||||
return meta?.Timestamp;
|
||||
}
|
||||
|
||||
public void SetLastSyncAt(DateTime timestamp) =>
|
||||
_meta.Upsert(new SyncMeta
|
||||
{
|
||||
Id = "lastSync",
|
||||
Value = 0,
|
||||
Timestamp = timestamp
|
||||
});
|
||||
|
||||
// ── Konflikte ─────────────────────────────────────────────────────────────
|
||||
|
||||
public void AddConflict(ConflictEntry conflict) =>
|
||||
_conflicts.Insert(conflict);
|
||||
|
||||
public List<ConflictEntry> GetUnreviewedConflicts() =>
|
||||
_conflicts.Find(c => !c.Reviewed).ToList();
|
||||
|
||||
public int ConflictCount() =>
|
||||
_conflicts.Count(c => !c.Reviewed);
|
||||
|
||||
public void MarkConflictReviewed(Guid id)
|
||||
{
|
||||
var conflict = _conflicts.FindById(id);
|
||||
if (conflict is null) return;
|
||||
conflict.Reviewed = true;
|
||||
_conflicts.Update(conflict);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
|
||||
// Internes Hilfsdokument für Metadaten
|
||||
internal class SyncMeta
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public long Value { get; set; }
|
||||
public DateTime? Timestamp { get; set; }
|
||||
}
|
||||
18
LehrerApp.Sync/LehrerApp.Sync.csproj
Normal file
18
LehrerApp.Sync/LehrerApp.Sync.csproj
Normal file
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LiteDB" />
|
||||
<!-- System.Text.Json ist in .NET 9 bereits im SDK enthalten,
|
||||
explizite Referenz nur für Versionspin nötig -->
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
30
LehrerApp.Sync/Models/PlainSyncModels.cs
Normal file
30
LehrerApp.Sync/Models/PlainSyncModels.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace LehrerApp.Sync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Event das die WebApp oder Companion-App erzeugt.
|
||||
/// Payload ist NICHT verschlüsselt – JWT schützt den Transport.
|
||||
/// Der Desktop-Client erkennt PlainEvents am DeviceType
|
||||
/// und wendet sie ohne Entschlüsselung an.
|
||||
/// </summary>
|
||||
public class PlainSyncEvent
|
||||
{
|
||||
public Guid EventId { get; init; } = Guid.NewGuid();
|
||||
public string DeviceId { get; init; } = "";
|
||||
public DeviceType DeviceType { get; init; } = DeviceType.Companion;
|
||||
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
|
||||
public string EntityType { get; init; } = ""; // "Grade", "Exam", ...
|
||||
public string EntityId { get; init; } = "";
|
||||
public string Operation { get; init; } = ""; // "Upsert", "Delete"
|
||||
public string Payload { get; init; } = ""; // JSON, Klartext
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Antwort auf einen Plain-Push.
|
||||
/// </summary>
|
||||
public class PlainPushResponse
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public long ServerSequenceNr { get; init; }
|
||||
public List<Guid> RejectedEventIds { get; init; } = [];
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
61
LehrerApp.Sync/Models/SnapshotModels.cs
Normal file
61
LehrerApp.Sync/Models/SnapshotModels.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
namespace LehrerApp.Sync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Anfrage zum Upload eines initialen Snapshots.
|
||||
/// Enthält sowohl den verschlüsselten DB-Snapshot
|
||||
/// als auch den mit dem Code verschlüsselten Sync-Schlüssel.
|
||||
/// </summary>
|
||||
public class SnapshotUploadRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// AES-256-GCM verschlüsselter Dump der lokalen LiteDB.
|
||||
/// Verschlüsselt mit dem Sync-Schlüssel des Nutzers.
|
||||
/// Der Server versteht den Inhalt nicht.
|
||||
/// </summary>
|
||||
public string EncryptedPayload { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Der Sync-Schlüssel, verschlüsselt mit dem aus dem
|
||||
/// Einmal-Code abgeleiteten Key (PBKDF2).
|
||||
/// Ermöglicht dem Empfänger den Schlüssel ohne Vorabübertragung
|
||||
/// zu rekonstruieren – nur der Code wird benötigt.
|
||||
/// </summary>
|
||||
public string EncryptedSyncKey { get; init; } = "";
|
||||
|
||||
public DeviceType DeviceType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Antwort nach erfolgreichem Snapshot-Upload.
|
||||
/// </summary>
|
||||
public class SnapshotUploadResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Menschenlesbarer Einmal-Code.
|
||||
/// Format: WORT-ZZ-WORT, z.B. "TIGER-42-BLAU"
|
||||
/// Dient gleichzeitig zum Abrufen UND zum Entschlüsseln des Sync-Schlüssels.
|
||||
/// </summary>
|
||||
public string Code { get; init; } = "";
|
||||
|
||||
public DateTime ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Antwort beim Abrufen eines Snapshots per Code.
|
||||
/// Nach dem ersten Abruf wird der Snapshot vom Server gelöscht.
|
||||
/// </summary>
|
||||
public class SnapshotDownloadResponse
|
||||
{
|
||||
/// <summary>Der verschlüsselte DB-Snapshot.</summary>
|
||||
public string EncryptedPayload { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Der mit dem Code verschlüsselte Sync-Schlüssel.
|
||||
/// Empfänger: Code → PBKDF2 → Code-Key → EncryptedSyncKey entschlüsseln
|
||||
/// → Sync-Key → EncryptedPayload entschlüsseln.
|
||||
/// </summary>
|
||||
public string EncryptedSyncKey { get; init; } = "";
|
||||
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DeviceType SourceDeviceType { get; init; }
|
||||
}
|
||||
66
LehrerApp.Sync/Models/SyncModels.cs
Normal file
66
LehrerApp.Sync/Models/SyncModels.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
namespace LehrerApp.Sync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Ein einzelnes Ereignis im Event-Log.
|
||||
/// Wird verschlüsselt zum Server übertragen – der Server versteht den Payload nicht.
|
||||
/// </summary>
|
||||
public class SyncEvent
|
||||
{
|
||||
public Guid EventId { get; init; } = Guid.NewGuid();
|
||||
public string DeviceId { get; init; } = "";
|
||||
public DeviceType DeviceType { get; init; }
|
||||
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
|
||||
public long SequenceNr { get; init; } // monoton steigend pro Gerät
|
||||
public string EntityType { get; init; } = ""; // "Student", "Exam", "Grade" ...
|
||||
public string EntityId { get; init; } = "";
|
||||
public string Operation { get; init; } = ""; // "Create", "Update", "Delete"
|
||||
public string Payload { get; init; } = ""; // JSON, AES-256 verschlüsselt
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eintrag im lokalen Konflikt-Log – für UI-Anzeige.
|
||||
/// </summary>
|
||||
public class ConflictEntry
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
public DateTime DetectedAt { get; init; } = DateTime.UtcNow;
|
||||
public SyncEvent LocalEvent { get; init; } = null!;
|
||||
public SyncEvent RemoteEvent { get; init; } = null!;
|
||||
public string Resolution { get; init; } = ""; // "LocalWon", "RemoteWon", "Pending"
|
||||
public bool Reviewed { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Antwort des Servers auf einen Pull-Request.
|
||||
/// </summary>
|
||||
public class PullResponse
|
||||
{
|
||||
public List<SyncEvent> Events { get; init; } = [];
|
||||
public long ServerSequenceNr { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Antwort des Servers auf einen Push-Request.
|
||||
/// </summary>
|
||||
public class PushResponse
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public long ServerSequenceNr { get; init; }
|
||||
public List<Guid> ConflictingEventIds { get; init; } = [];
|
||||
}
|
||||
|
||||
public enum DeviceType { Desktop, Companion }
|
||||
|
||||
/// <summary>
|
||||
/// Sync-Status für UI-Anzeige.
|
||||
/// </summary>
|
||||
public class SyncStatus
|
||||
{
|
||||
public SyncState State { get; set; } = SyncState.Idle;
|
||||
public DateTime? LastSyncAt { get; set; }
|
||||
public int PendingEvents { get; set; }
|
||||
public int ConflictCount { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public enum SyncState { Idle, Syncing, Error, Offline }
|
||||
155
LehrerApp.Sync/ReadableSnapshotService.cs
Normal file
155
LehrerApp.Sync/ReadableSnapshotService.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using System.Net.Http.Json;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Exportiert einen lesbaren Snapshot der lokalen LiteDB
|
||||
/// und pusht ihn zur API – wo er für die WebApp abrufbar ist.
|
||||
///
|
||||
/// Wird automatisch nach jedem Sync-Zyklus ausgeführt
|
||||
/// und kann manuell angestoßen werden.
|
||||
/// </summary>
|
||||
public class ReadableSnapshotService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly IEnrollmentRepository _enrollments;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _examResults;
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IUnitRepository _units;
|
||||
private readonly ILessonRepository _lessons;
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
private readonly string _deviceId;
|
||||
|
||||
public event Action<string>? StatusChanged;
|
||||
|
||||
public ReadableSnapshotService(
|
||||
HttpClient http,
|
||||
IStudentRepository students,
|
||||
IGroupRepository groups,
|
||||
IEnrollmentRepository enrollments,
|
||||
IExamRepository exams,
|
||||
IExamResultRepository examResults,
|
||||
IGradeRepository grades,
|
||||
IUnitRepository units,
|
||||
ILessonRepository lessons,
|
||||
IWorkTaskRepository tasks,
|
||||
SchoolYearService schoolYear,
|
||||
string deviceId)
|
||||
{
|
||||
_http = http;
|
||||
_students = students;
|
||||
_groups = groups;
|
||||
_enrollments = enrollments;
|
||||
_exams = exams;
|
||||
_examResults = examResults;
|
||||
_grades = grades;
|
||||
_units = units;
|
||||
_lessons = lessons;
|
||||
_tasks = tasks;
|
||||
_schoolYear = schoolYear;
|
||||
_deviceId = deviceId;
|
||||
}
|
||||
|
||||
// ── Export ────────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<bool> ExportAndPushAsync(
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
StatusChanged?.Invoke("Snapshot wird erstellt…");
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = BuildSnapshot();
|
||||
|
||||
StatusChanged?.Invoke("Snapshot wird übertragen…");
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
"/api/snapshot/readable", snapshot, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
StatusChanged?.Invoke(
|
||||
$"Übertragung fehlgeschlagen: {response.StatusCode}");
|
||||
return false;
|
||||
}
|
||||
|
||||
StatusChanged?.Invoke(
|
||||
$"Snapshot übertragen – {snapshot.Meta.StudentCount} Schüler, " +
|
||||
$"{snapshot.Meta.GroupCount} Gruppen");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusChanged?.Invoke($"Fehler: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snapshot aufbauen ─────────────────────────────────────────────────────
|
||||
|
||||
private ReadableSnapshot BuildSnapshot()
|
||||
{
|
||||
var schoolYear = _schoolYear.CurrentSchoolYear();
|
||||
var groups = _groups.GetBySchoolYear(schoolYear);
|
||||
var groupIds = groups.Select(g => g.Id).ToHashSet();
|
||||
|
||||
var students = _students.GetAll();
|
||||
var enrollments = groups
|
||||
.SelectMany(g => _enrollments.GetByGroupAndYear(g.Id, schoolYear))
|
||||
.ToList();
|
||||
|
||||
var exams = groupIds
|
||||
.SelectMany(gid => _exams.GetByGroup(gid))
|
||||
.ToList();
|
||||
|
||||
var examResults = exams
|
||||
.SelectMany(e => _examResults.GetByExam(e.Id))
|
||||
.ToList();
|
||||
|
||||
var grades = groupIds
|
||||
.SelectMany(gid => _grades.GetByGroup(gid))
|
||||
.ToList();
|
||||
|
||||
var units = groupIds
|
||||
.SelectMany(gid => _units.GetByGroup(gid))
|
||||
.ToList();
|
||||
|
||||
var lessons = units
|
||||
.SelectMany(u => _lessons.GetByUnit(u.Id))
|
||||
.ToList();
|
||||
|
||||
var tasks = _tasks.GetAll()
|
||||
.Where(t => t.Status != WorkTaskStatus.Done)
|
||||
.ToList();
|
||||
|
||||
var snapshot = new ReadableSnapshot
|
||||
{
|
||||
SchoolYear = schoolYear,
|
||||
Students = students,
|
||||
Groups = groups,
|
||||
Enrollments = enrollments,
|
||||
Exams = exams,
|
||||
ExamResults = examResults,
|
||||
Grades = grades,
|
||||
Units = units,
|
||||
Lessons = lessons,
|
||||
Tasks = tasks,
|
||||
Meta = new SnapshotMeta
|
||||
{
|
||||
StudentCount = students.Count,
|
||||
GroupCount = groups.Count,
|
||||
ExamCount = exams.Count,
|
||||
ExportedByDevice = _deviceId,
|
||||
OldestData = DateTime.UtcNow.AddYears(-1), // vereinfacht
|
||||
},
|
||||
};
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
245
LehrerApp.Sync/SnapshotService.cs
Normal file
245
LehrerApp.Sync/SnapshotService.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
using System.Net.Http.Json;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet den initialen Snapshot-Austausch beim Einrichten eines neuen Geräts.
|
||||
///
|
||||
/// Ablauf Sender (Desktop):
|
||||
/// 1. LiteDB checkpoint + lesen
|
||||
/// 2. Mit Sync-Schlüssel (AES-256) verschlüsseln → EncryptedPayload
|
||||
/// 3. Sync-Schlüssel mit Code-Key (PBKDF2 aus Einmal-Code) verschlüsseln → EncryptedSyncKey
|
||||
/// 4. Beides zum Server pushen → Einmal-Code erhalten
|
||||
/// 5. Code dem Nutzer anzeigen – das ist alles was nötig ist
|
||||
///
|
||||
/// Ablauf Empfänger (neues Gerät):
|
||||
/// 1. Einmal-Code eingeben
|
||||
/// 2. EncryptedPayload + EncryptedSyncKey laden
|
||||
/// 3. Code → PBKDF2 → Code-Key → EncryptedSyncKey entschlüsseln → Sync-Schlüssel
|
||||
/// 4. Sync-Schlüssel lokal speichern (sync.key)
|
||||
/// 5. EncryptedPayload mit Sync-Schlüssel entschlüsseln → LiteDB
|
||||
/// 6. Normaler Event-Sync startet
|
||||
/// </summary>
|
||||
public class SnapshotService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly LiteDbContext _db;
|
||||
private readonly byte[] _syncKey;
|
||||
private readonly DeviceType _deviceType;
|
||||
private readonly string _dbPath;
|
||||
private readonly string _keyPath;
|
||||
|
||||
public event Action<SnapshotProgress>? ProgressChanged;
|
||||
|
||||
public SnapshotService(
|
||||
HttpClient http,
|
||||
LiteDbContext db,
|
||||
byte[] syncKey,
|
||||
DeviceType deviceType,
|
||||
string dbPath,
|
||||
string keyPath)
|
||||
{
|
||||
_http = http;
|
||||
_db = db;
|
||||
_syncKey = syncKey;
|
||||
_deviceType = deviceType;
|
||||
_dbPath = dbPath;
|
||||
_keyPath = keyPath;
|
||||
}
|
||||
|
||||
// ── Sender ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt Snapshot + verschlüsselten Schlüssel und lädt beides hoch.
|
||||
/// Der zurückgegebene Code ist das einzige was der Empfänger braucht.
|
||||
/// </summary>
|
||||
public async Task<SnapshotUploadResponse> CreateAndUploadAsync(
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// 1. DB sauber schreiben
|
||||
Report(SnapshotStep.Checkpointing, "Datenbank wird gesichert…");
|
||||
_db.Checkpoint();
|
||||
|
||||
// 2. DB-Bytes lesen
|
||||
Report(SnapshotStep.Reading, "Datenbank wird gelesen…");
|
||||
var dbBytes = await File.ReadAllBytesAsync(_dbPath, ct);
|
||||
|
||||
// 3. Snapshot mit Sync-Schlüssel verschlüsseln
|
||||
Report(SnapshotStep.Encrypting, "Datenbank wird verschlüsselt…");
|
||||
var encryptedPayload = Convert.ToBase64String(
|
||||
SyncCrypto.Encrypt(dbBytes, _syncKey));
|
||||
|
||||
// 4. Hochladen – Server generiert den Einmal-Code
|
||||
// Der EncryptedSyncKey wird NACH dem Upload erzeugt,
|
||||
// weil wir dafür den Code brauchen den der Server zurückgibt.
|
||||
// Deshalb: zweistufiger Upload.
|
||||
Report(SnapshotStep.Uploading, "Erster Upload – Code wird angefordert…");
|
||||
|
||||
// Schritt 4a: Initialer Upload ohne EncryptedSyncKey → Code erhalten
|
||||
var initRequest = new SnapshotUploadRequest
|
||||
{
|
||||
EncryptedPayload = encryptedPayload,
|
||||
EncryptedSyncKey = "", // noch leer
|
||||
DeviceType = _deviceType,
|
||||
};
|
||||
|
||||
var initResponse = await _http.PostAsJsonAsync(
|
||||
"/api/snapshot/upload", initRequest, ct);
|
||||
initResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var initResult = await initResponse.Content
|
||||
.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
|
||||
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||
|
||||
// Schritt 4b: Sync-Schlüssel mit dem erhaltenen Code verschlüsseln
|
||||
Report(SnapshotStep.Uploading, "Schlüssel wird verschlüsselt und ergänzt…");
|
||||
var encryptedSyncKey = SyncCrypto.EncryptKeyWithCode(
|
||||
_syncKey, initResult.Code);
|
||||
|
||||
// Schritt 4c: Vollständiger Upload mit EncryptedSyncKey
|
||||
var fullRequest = new SnapshotUploadRequest
|
||||
{
|
||||
EncryptedPayload = encryptedPayload,
|
||||
EncryptedSyncKey = encryptedSyncKey,
|
||||
DeviceType = _deviceType,
|
||||
};
|
||||
|
||||
var fullResponse = await _http.PostAsJsonAsync(
|
||||
"/api/snapshot/upload", fullRequest, ct);
|
||||
fullResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await fullResponse.Content
|
||||
.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
|
||||
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||
|
||||
Report(SnapshotStep.Done, $"Bereit. Code: {result.Code}");
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Empfänger ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Lädt Snapshot und Schlüssel anhand des Einmal-Codes.
|
||||
/// Extrahiert den Sync-Schlüssel, speichert ihn lokal,
|
||||
/// und baut die neue LiteDB auf.
|
||||
/// </summary>
|
||||
public async Task<byte[]> RestoreFromCodeAsync(
|
||||
string code,
|
||||
string targetDbPath,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sanitized = code.Trim().ToUpperInvariant();
|
||||
|
||||
// 1. Snapshot + verschlüsselten Schlüssel laden
|
||||
Report(SnapshotStep.Downloading, "Snapshot wird geladen…");
|
||||
var response = await _http.GetAsync(
|
||||
$"/api/snapshot/{sanitized}", ct);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
throw new SnapshotNotFoundException(
|
||||
$"Kein Snapshot für Code '{sanitized}'. " +
|
||||
"Abgelaufen oder bereits verwendet.");
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var download = await response.Content
|
||||
.ReadFromJsonAsync<SnapshotDownloadResponse>(ct)
|
||||
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||
|
||||
// 2. Sync-Schlüssel aus dem Code extrahieren
|
||||
Report(SnapshotStep.Decrypting, "Schlüssel wird entschlüsselt…");
|
||||
byte[] syncKey;
|
||||
try
|
||||
{
|
||||
syncKey = SyncCrypto.DecryptKeyWithCode(
|
||||
download.EncryptedSyncKey, sanitized);
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Schlüssel-Entschlüsselung fehlgeschlagen. " +
|
||||
"Ist der Code korrekt eingegeben?");
|
||||
}
|
||||
|
||||
// 3. Sync-Schlüssel lokal speichern – ab jetzt kann normal gesynct werden
|
||||
SyncCrypto.SaveKey(syncKey, _keyPath);
|
||||
|
||||
// 4. LiteDB entschlüsseln
|
||||
Report(SnapshotStep.Decrypting, "Datenbank wird entschlüsselt…");
|
||||
byte[] dbBytes;
|
||||
try
|
||||
{
|
||||
var encrypted = Convert.FromBase64String(download.EncryptedPayload);
|
||||
dbBytes = SyncCrypto.Decrypt(encrypted, syncKey);
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
// Sollte nicht passieren wenn der Schlüssel korrekt war
|
||||
throw new InvalidOperationException(
|
||||
"Datenbank-Entschlüsselung fehlgeschlagen. " +
|
||||
"Der Snapshot könnte beschädigt sein.");
|
||||
}
|
||||
|
||||
// 5. LiteDB schreiben
|
||||
Report(SnapshotStep.Writing, "Datenbank wird geschrieben…");
|
||||
var dir = Path.GetDirectoryName(targetDbPath);
|
||||
if (dir != null) Directory.CreateDirectory(dir);
|
||||
|
||||
if (File.Exists(targetDbPath))
|
||||
{
|
||||
var backup = targetDbPath +
|
||||
$".backup-{DateTime.Now:yyyyMMdd-HHmmss}";
|
||||
File.Move(targetDbPath, backup);
|
||||
}
|
||||
|
||||
await File.WriteAllBytesAsync(targetDbPath, dbBytes, ct);
|
||||
|
||||
Report(SnapshotStep.Done,
|
||||
$"Wiederhergestellt vom {download.CreatedAt:dd.MM.yyyy HH:mm}. " +
|
||||
"Bitte App neu starten.");
|
||||
|
||||
// Neuen Sync-Schlüssel zurückgeben – AppBootstrapper muss ihn neu laden
|
||||
return syncKey;
|
||||
}
|
||||
|
||||
private void Report(SnapshotStep step, string message) =>
|
||||
ProgressChanged?.Invoke(new SnapshotProgress(step, message));
|
||||
}
|
||||
|
||||
// ── Fortschritts-Modelle ──────────────────────────────────────────────────────
|
||||
|
||||
public record SnapshotProgress(SnapshotStep Step, string Message)
|
||||
{
|
||||
public int PercentComplete => Step switch
|
||||
{
|
||||
SnapshotStep.Checkpointing => 10,
|
||||
SnapshotStep.Reading => 20,
|
||||
SnapshotStep.Encrypting => 35,
|
||||
SnapshotStep.Uploading => 60,
|
||||
SnapshotStep.Downloading => 35,
|
||||
SnapshotStep.Decrypting => 65,
|
||||
SnapshotStep.Writing => 85,
|
||||
SnapshotStep.Done => 100,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
public bool IsComplete => Step == SnapshotStep.Done;
|
||||
}
|
||||
|
||||
public enum SnapshotStep
|
||||
{
|
||||
Idle,
|
||||
Checkpointing,
|
||||
Reading,
|
||||
Encrypting,
|
||||
Uploading,
|
||||
Downloading,
|
||||
Decrypting,
|
||||
Writing,
|
||||
Done,
|
||||
}
|
||||
|
||||
public class SnapshotNotFoundException(string message) : Exception(message);
|
||||
186
LehrerApp.Sync/SyncEngine.cs
Normal file
186
LehrerApp.Sync/SyncEngine.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System.Net.Http.Json;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestriert Push, Pull, EventApply und Snapshot-Export.
|
||||
/// Automatisch alle N Minuten + manuell auslösbar.
|
||||
/// </summary>
|
||||
public class SyncEngine : IDisposable
|
||||
{
|
||||
private readonly EventQueue _queue;
|
||||
private readonly ConflictResolver _resolver;
|
||||
private readonly EventApplier? _applier;
|
||||
private readonly ReadableSnapshotService? _snapshotExport;
|
||||
private readonly HttpClient _http;
|
||||
private readonly SyncConfig _config;
|
||||
private readonly Timer _timer;
|
||||
|
||||
public SyncStatus Status { get; private set; } = new();
|
||||
public event Action<SyncStatus>? StatusChanged;
|
||||
|
||||
public SyncEngine(
|
||||
EventQueue queue,
|
||||
ConflictResolver resolver,
|
||||
HttpClient http,
|
||||
SyncConfig config,
|
||||
EventApplier? applier = null,
|
||||
ReadableSnapshotService? snapshotExport = null)
|
||||
{
|
||||
_queue = queue;
|
||||
_resolver = resolver;
|
||||
_http = http;
|
||||
_config = config;
|
||||
_applier = applier;
|
||||
_snapshotExport = snapshotExport;
|
||||
|
||||
_timer = new Timer(
|
||||
async _ => await SyncNowAsync(isAutomatic: true),
|
||||
null,
|
||||
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes),
|
||||
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes));
|
||||
|
||||
UpdateStatus();
|
||||
}
|
||||
|
||||
// ── Öffentliche API ───────────────────────────────────────────────────────
|
||||
|
||||
public async Task<SyncResult> SyncNowAsync(bool isAutomatic = false)
|
||||
{
|
||||
if (Status.State == SyncState.Syncing)
|
||||
return new SyncResult { Skipped = true, Reason = "Sync bereits aktiv" };
|
||||
|
||||
SetState(SyncState.Syncing);
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Ausstehende Events pushen
|
||||
var pushResult = await PushAsync();
|
||||
|
||||
// 2. Neue Events vom Server holen
|
||||
var pullResult = await PullAsync();
|
||||
|
||||
// 3. Geholte Events auf LiteDB anwenden
|
||||
if (_applier is not null && pullResult.Events.Count > 0)
|
||||
_applier.ApplyAll(pullResult.Events);
|
||||
|
||||
// 4. Nach erfolgreichem Sync: lesbaren Snapshot exportieren
|
||||
// (nur bei automatischem Sync oder explizit – nicht bei jedem
|
||||
// manuellen Push um Traffic zu sparen)
|
||||
if (_snapshotExport is not null && (isAutomatic || pushResult.Pushed > 0))
|
||||
await _snapshotExport.ExportAndPushAsync();
|
||||
|
||||
_queue.SetLastSyncAt(DateTime.UtcNow);
|
||||
SetState(SyncState.Idle);
|
||||
|
||||
return new SyncResult
|
||||
{
|
||||
Success = true,
|
||||
EventsPushed = pushResult.Pushed,
|
||||
EventsPulled = pullResult.Events.Count,
|
||||
Conflicts = pullResult.Conflicts,
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
SetState(SyncState.Offline);
|
||||
return new SyncResult { Success = false, Reason = "Server nicht erreichbar" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetState(SyncState.Error, ex.Message);
|
||||
return new SyncResult { Success = false, Reason = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Push ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(int Pushed, int Conflicts)> PushAsync()
|
||||
{
|
||||
var pending = _queue.GetPending(maxBatch: 200);
|
||||
if (pending.Count == 0) return (0, 0);
|
||||
|
||||
var response = await _http.PostAsJsonAsync("/api/sync/push", pending);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<PushResponse>();
|
||||
if (result is null) return (0, 0);
|
||||
|
||||
var sentIds = pending
|
||||
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
|
||||
.Select(e => e.EventId);
|
||||
_queue.Acknowledge(sentIds);
|
||||
_queue.SetLastServerSequenceNr(result.ServerSequenceNr);
|
||||
|
||||
return (pending.Count - result.ConflictingEventIds.Count,
|
||||
result.ConflictingEventIds.Count);
|
||||
}
|
||||
|
||||
// ── Pull ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(List<SyncEvent> Events, int Conflicts)> PullAsync()
|
||||
{
|
||||
var since = _queue.GetLastServerSequenceNr();
|
||||
var response = await _http.GetFromJsonAsync<PullResponse>(
|
||||
$"/api/sync/pull?since={since}&deviceId={_config.DeviceId}");
|
||||
|
||||
if (response is null || response.Events.Count == 0)
|
||||
return ([], 0);
|
||||
|
||||
var conflicts = 0;
|
||||
foreach (var remoteEvent in response.Events)
|
||||
{
|
||||
var conflict = _resolver.TryResolve(remoteEvent, _config.DeviceId);
|
||||
if (conflict is not null)
|
||||
{
|
||||
_queue.AddConflict(conflict);
|
||||
conflicts++;
|
||||
}
|
||||
}
|
||||
|
||||
_queue.SetLastServerSequenceNr(response.ServerSequenceNr);
|
||||
return (response.Events, conflicts);
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||
|
||||
private void SetState(SyncState state, string? error = null)
|
||||
{
|
||||
Status = new SyncStatus
|
||||
{
|
||||
State = state,
|
||||
LastSyncAt = _queue.GetLastSyncAt(),
|
||||
PendingEvents = _queue.PendingCount(),
|
||||
ConflictCount = _queue.ConflictCount(),
|
||||
ErrorMessage = error,
|
||||
};
|
||||
StatusChanged?.Invoke(Status);
|
||||
}
|
||||
|
||||
private void UpdateStatus() => SetState(Status.State);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_timer.Dispose();
|
||||
_queue.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public class SyncConfig
|
||||
{
|
||||
public string ServerUrl { get; set; } = "";
|
||||
public string DeviceId { get; set; } = "";
|
||||
public DeviceType DeviceType { get; set; } = DeviceType.Desktop;
|
||||
public int AutoSyncIntervalMinutes { get; set; } = 5;
|
||||
}
|
||||
|
||||
public class SyncResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public bool Skipped { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public int EventsPushed { get; set; }
|
||||
public int EventsPulled { get; set; }
|
||||
public int Conflicts { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user