This commit is contained in:
2026-03-29 23:47:31 +02:00
commit 216d5d2280
75 changed files with 5702 additions and 0 deletions

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

View 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());
}
}

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

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

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

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

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

View 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 }

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

View 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);

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