From f0f8fa25e58cba3b6c0c43ca1f125ffbb78b3ff5 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 17 Aug 2026 11:30:04 +0200 Subject: [PATCH] Baustein 6: Datei-Anhaenge synchronisieren (Kapitel 10) Anhaenge laufen bewusst NICHT ueber den JSON-Ereigniskanal (wuerde ihn fuer Fotos/Scans stark aufblaehen), sondern ueber einen eigenen verschluesselten Binaerkanal - analog zum bereits bestehenden Muster in SnapshotService. - Neue Endpunkte POST/GET /api/sync/attachments/{storageId} in LehrerApp.Api (AttachmentStore, dateibasiert je Nutzer) - EventQueue: neue, vom JSON-Ereignis getrennte Warteliste fuer ausstehende Uploads (SyncEventPublisher traegt Anhaenge einer gespeicherten Documentation dort ein) - AttachmentSyncer laedt ausstehende Anhaenge hoch (in SyncEngine.SyncNowAsync nach dem Event-Push) - EventApplier laedt fehlende Anhaenge nach dem Anwenden eines Documentation-Ereignisses nach - ueber die rohe Collection statt IAttachmentStorage.Upload, da dieses immer eine neue Id vergaebe und hier die Original-StorageId erhalten bleiben muss Round-Trip-Tests belegen byteidentische Uebertragung. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Api.Tests/AttachmentStoreTests.cs | 70 ++++++++++++++++ LehrerApp.Api/AttachmentStore.cs | 29 +++++++ LehrerApp.Api/Endpoints/Endpoints.cs | 24 ++++++ LehrerApp.Api/Program.cs | 2 + LehrerApp.Desktop/AppBootstrapper.cs | 7 +- LehrerApp.Sync.Tests/AssemblyInfo.cs | 6 ++ LehrerApp.Sync.Tests/AttachmentSyncerTests.cs | 76 +++++++++++++++++ LehrerApp.Sync.Tests/EventApplierTests.cs | 82 +++++++++++++++---- .../FakeHttpMessageHandler.cs | 14 ++++ LehrerApp.Sync/AttachmentSyncer.cs | 36 ++++++++ LehrerApp.Sync/EventApplier.cs | 27 +++++- LehrerApp.Sync/EventQueue.cs | 21 +++++ LehrerApp.Sync/SyncEngine.cs | 19 +++-- LehrerApp.Sync/SyncEventPublisher.cs | 7 ++ 14 files changed, 391 insertions(+), 29 deletions(-) create mode 100644 LehrerApp.Api.Tests/AttachmentStoreTests.cs create mode 100644 LehrerApp.Api/AttachmentStore.cs create mode 100644 LehrerApp.Sync.Tests/AssemblyInfo.cs create mode 100644 LehrerApp.Sync.Tests/AttachmentSyncerTests.cs create mode 100644 LehrerApp.Sync.Tests/FakeHttpMessageHandler.cs create mode 100644 LehrerApp.Sync/AttachmentSyncer.cs diff --git a/LehrerApp.Api.Tests/AttachmentStoreTests.cs b/LehrerApp.Api.Tests/AttachmentStoreTests.cs new file mode 100644 index 0000000..85e4b90 --- /dev/null +++ b/LehrerApp.Api.Tests/AttachmentStoreTests.cs @@ -0,0 +1,70 @@ +using Xunit; + +namespace LehrerApp.Api.Tests; + +public sealed class AttachmentStoreTests +{ + [Fact] + public async Task StoreAsync_GefolgtVonOpenRead_LiefertByteidentischeDatei() + { + using var temp = new TempDataPath(); + var store = new AttachmentStore(temp.Path); + byte[] original = [1, 2, 3, 4, 5, 255, 0, 42]; + + await store.StoreAsync("user-1", "abc123", new MemoryStream(original)); + + using var read = store.OpenRead("user-1", "abc123"); + Assert.NotNull(read); + using var ms = new MemoryStream(); + await read!.CopyToAsync(ms); + Assert.Equal(original, ms.ToArray()); + } + + [Fact] + public void OpenRead_UnbekannteStorageId_GibtNullZurueck() + { + using var temp = new TempDataPath(); + var store = new AttachmentStore(temp.Path); + + Assert.Null(store.OpenRead("user-1", "unbekannt")); + } + + [Fact] + public async Task StoreAsync_TrenntAnhaengeVerschiedenerNutzer() + { + using var temp = new TempDataPath(); + var store = new AttachmentStore(temp.Path); + + await store.StoreAsync("user-1", "shared-id", new MemoryStream([1])); + + Assert.Null(store.OpenRead("user-2", "shared-id")); + Assert.NotNull(store.OpenRead("user-1", "shared-id")); + } + + [Fact] + public async Task StoreAsync_BereinigtStorageIdMitPathTraversalZeichen() + { + using var temp = new TempDataPath(); + var store = new AttachmentStore(temp.Path); + + // Darf keinesfalls außerhalb von /attachments/ landen. + await store.StoreAsync("user-1", "../../evil", new MemoryStream([1, 2, 3])); + + Assert.False(File.Exists(Path.Combine(temp.Path, "evil"))); + var withinRoot = Directory.EnumerateFiles(Path.Combine(temp.Path, "attachments"), "*", SearchOption.AllDirectories); + Assert.Contains(withinRoot, f => Path.GetFileName(f) == "evil"); + } + + private sealed class TempDataPath : IDisposable + { + public string Path { get; } = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"lehrerapp-api-tests-attachments-{Guid.NewGuid():N}"); + + public TempDataPath() => Directory.CreateDirectory(Path); + + public void Dispose() + { + if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); + } + } +} diff --git a/LehrerApp.Api/AttachmentStore.cs b/LehrerApp.Api/AttachmentStore.cs new file mode 100644 index 0000000..a95d9d2 --- /dev/null +++ b/LehrerApp.Api/AttachmentStore.cs @@ -0,0 +1,29 @@ +namespace LehrerApp.Api; + +/// +/// Rohdaten-Ablage für verschlüsselte Datei-Anhänge, getrennt vom Ereignis-/Snapshot-Speicher. +/// Server sieht nur verschlüsselte Bytes, kein LiteDB nötig - einfache Dateien pro Nutzer/Id. +/// +public class AttachmentStore(string dataPath) +{ + private readonly string _root = Path.Combine(dataPath, "attachments"); + + public async Task StoreAsync(string userId, string storageId, Stream content) + { + var dir = Path.Combine(_root, Safe(userId)); + Directory.CreateDirectory(dir); + await using var file = File.Create(Path.Combine(dir, Safe(storageId))); + await content.CopyToAsync(file); + } + + public Stream? OpenRead(string userId, string storageId) + { + var path = Path.Combine(_root, Safe(userId), Safe(storageId)); + return File.Exists(path) ? File.OpenRead(path) : null; + } + + // storageId kommt als Routen-Parameter vom Client - nie ungeprüft in einen Dateipfad + // übernehmen (Path-Traversal). + private static string Safe(string value) => + string.Concat(value.Where(c => char.IsLetterOrDigit(c) || c == '-')); +} diff --git a/LehrerApp.Api/Endpoints/Endpoints.cs b/LehrerApp.Api/Endpoints/Endpoints.cs index 2817cae..cf778b6 100644 --- a/LehrerApp.Api/Endpoints/Endpoints.cs +++ b/LehrerApp.Api/Endpoints/Endpoints.cs @@ -48,6 +48,30 @@ public static class Endpoints }); } + // ── Anhänge (eigener Binärkanal, getrennt vom JSON-Ereigniskanal) ────────── + + public static void MapAttachmentEndpoints(this WebApplication app) + { + var g = app.MapGroup("/api/sync/attachments").RequireAuthorization(); + g.MapPost("/{storageId}", async (string storageId, HttpRequest req, + ClaimsPrincipal user, AttachmentStore store) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (uid is null) return Results.Unauthorized(); + if (req.ContentLength is null or > LehrerApp.Core.Interfaces.IAttachmentStorage.MaxSizeBytes) + return Results.BadRequest("Datei zu groß oder Content-Length fehlt."); + await store.StoreAsync(uid, storageId, req.Body); + return Results.Ok(); + }); + g.MapGet("/{storageId}", (string storageId, ClaimsPrincipal user, AttachmentStore store) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (uid is null) return Results.Unauthorized(); + var stream = store.OpenRead(uid, storageId); + return stream is null ? Results.NotFound() : Results.Stream(stream, "application/octet-stream"); + }); + } + // ── Snapshot (Device-Pairing) ───────────────────────────────────────────── public static void MapSnapshotEndpoints(this WebApplication app) diff --git a/LehrerApp.Api/Program.cs b/LehrerApp.Api/Program.cs index 42ad736..b134faa 100644 --- a/LehrerApp.Api/Program.cs +++ b/LehrerApp.Api/Program.cs @@ -30,6 +30,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddAuthorization(); builder.Services.AddSingleton(_ => new UserStore(data)); +builder.Services.AddSingleton(_ => new AttachmentStore(data)); builder.Services.AddSingleton(_ => new EventStore(data)); builder.Services.AddSingleton(_ => new SnapshotStore(data)); builder.Services.AddSingleton(_ => new ReadableSnapshotStore(data)); @@ -41,6 +42,7 @@ app.UseAuthentication(); app.UseAuthorization(); app.MapAuthEndpoints(secret); app.MapSyncEndpoints(); +app.MapAttachmentEndpoints(); app.MapSnapshotEndpoints(); app.MapReadableSnapshotEndpoints(); app.MapPlainSyncEndpoints(); diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 28130ec..b13e076 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -179,14 +179,19 @@ public static class AppBootstrapper if (!string.IsNullOrEmpty(serverUrl)) { services.AddSingleton(sp => new EventApplier( - sp.GetRequiredService(), sp.GetRequiredService())); + sp.GetRequiredService(), sp.GetRequiredService(), + BuildHttp(serverUrl, appData))); services.AddSingleton(sp => new SyncEventPublisher( sp.GetRequiredService(), deviceId, sp.GetRequiredService())); + services.AddSingleton(sp => new AttachmentSyncer( + sp.GetRequiredService(), BuildHttp(serverUrl, appData), + sp.GetRequiredService())); services.AddSingleton(sp => new SyncEngine( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), BuildHttp(serverUrl, appData), new SyncConfig { diff --git a/LehrerApp.Sync.Tests/AssemblyInfo.cs b/LehrerApp.Sync.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..8d68b11 --- /dev/null +++ b/LehrerApp.Sync.Tests/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using Xunit; + +// Gleicher Grund wie in LehrerApp.Data.Tests/AssemblyInfo.cs: LiteDBs geteilter, statischer +// BsonMapper.Global verträgt keine parallele Erstzuordnung von Typ-Metadaten über mehrere +// Testklassen hinweg (EventApplierTests/AttachmentSyncerTests konstruieren beide LiteDbContext). +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/LehrerApp.Sync.Tests/AttachmentSyncerTests.cs b/LehrerApp.Sync.Tests/AttachmentSyncerTests.cs new file mode 100644 index 0000000..6b7d9ca --- /dev/null +++ b/LehrerApp.Sync.Tests/AttachmentSyncerTests.cs @@ -0,0 +1,76 @@ +using LehrerApp.Data; +using LehrerApp.Sync.Crypto; +using Xunit; + +namespace LehrerApp.Sync.Tests; + +public sealed class AttachmentSyncerTests +{ + private static LiteDbContext NewInMemoryContext() => new(new MemoryStream()); + private static readonly byte[] Key = SyncCrypto.GenerateKey(); + + [Fact] + public async Task UploadPendingAsync_LaedtVerschluesselteBytesHochUndMarkiertAlsErledigt() + { + using var temp = new TempEventQueue(); + using var db = NewInMemoryContext(); + var storageId = Guid.NewGuid().ToString("N"); + db.Attachments.Upload(storageId, "brief.pdf", new MemoryStream([1, 2, 3, 4])); + temp.Queue.QueueAttachmentUpload(storageId); + // Bytes MÜSSEN synchron innerhalb des Handler-Callbacks gelesen werden: AttachmentSyncer + // disposed sein ByteArrayContent direkt nach dem PostAsync-Aufruf (eigenes "using"), + // ein Zugriff auf request.Content danach würde ObjectDisposedException werfen. + byte[]? uploadedEncrypted = null; + var handler = new FakeHttpMessageHandler(req => + { + uploadedEncrypted = req.Content!.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + return new HttpResponseMessage(System.Net.HttpStatusCode.OK); + }); + var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") }; + var syncer = new AttachmentSyncer(db, http, Key); + + await syncer.UploadPendingAsync(temp.Queue); + + var request = Assert.Single(handler.Requests); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal($"/api/sync/attachments/{storageId}", request.RequestUri!.AbsolutePath); + Assert.NotNull(uploadedEncrypted); + Assert.Equal([1, 2, 3, 4], SyncCrypto.Decrypt(uploadedEncrypted!, Key)); + Assert.Empty(temp.Queue.GetPendingAttachmentUploads()); + } + + [Fact] + public async Task UploadPendingAsync_LokalNichtMehrVorhandenerAnhang_WirdOhneUploadAlsErledigtMarkiert() + { + using var temp = new TempEventQueue(); + using var db = NewInMemoryContext(); + temp.Queue.QueueAttachmentUpload("laengst-geloescht"); + var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)); + var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") }; + var syncer = new AttachmentSyncer(db, http, Key); + + await syncer.UploadPendingAsync(temp.Queue); + + Assert.Empty(handler.Requests); + Assert.Empty(temp.Queue.GetPendingAttachmentUploads()); + } + + private sealed class TempEventQueue : IDisposable + { + private readonly string _directory = Path.Combine( + Path.GetTempPath(), $"lehrerapp-sync-tests-attachments-{Guid.NewGuid():N}"); + public EventQueue Queue { get; } + + public TempEventQueue() + { + Directory.CreateDirectory(_directory); + Queue = new EventQueue(Path.Combine(_directory, "queue.db")); + } + + public void Dispose() + { + Queue.Dispose(); + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/LehrerApp.Sync.Tests/EventApplierTests.cs b/LehrerApp.Sync.Tests/EventApplierTests.cs index b8e6a0b..3e6ea80 100644 --- a/LehrerApp.Sync.Tests/EventApplierTests.cs +++ b/LehrerApp.Sync.Tests/EventApplierTests.cs @@ -12,13 +12,13 @@ public sealed class EventApplierTests private static readonly byte[] Key = SyncCrypto.GenerateKey(); [Fact] - public void Apply_Save_SchreibtEntitaetDirektInDieCollection() + public async Task ApplyAsync_Save_SchreibtEntitaetDirektInDieCollection() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; - applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student)); + await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student)); var saved = db.Students.FindById(student.Id); Assert.NotNull(saved); @@ -26,32 +26,32 @@ public sealed class EventApplierTests } [Fact] - public void Apply_Delete_EntferntDenDatensatz() + public async Task ApplyAsync_Delete_EntferntDenDatensatz() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; db.Students.Insert(student); - applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null)); + await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null)); Assert.Null(db.Students.FindById(student.Id)); } [Fact] - public void Apply_UnbekannterEntityType_TutNichtsUndWirftNicht() + public async Task ApplyAsync_UnbekannterEntityType_TutNichtsUndWirftNicht() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); - var exception = Record.Exception(() => - applier.Apply(MakeEvent("UnbekannterTyp", Guid.NewGuid().ToString(), "Save", new { Foo = "Bar" }))); + var exception = await Record.ExceptionAsync(() => + applier.ApplyAsync(MakeEvent("UnbekannterTyp", Guid.NewGuid().ToString(), "Save", new { Foo = "Bar" }))); Assert.Null(exception); } [Fact] - public void Apply_GroupDelete_FuehrtDieselbeKaskadeAusWieDasRepository() + public async Task ApplyAsync_GroupDelete_FuehrtDieselbeKaskadeAusWieDasRepository() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); @@ -60,14 +60,14 @@ public sealed class EventApplierTests var gradeId = Guid.NewGuid(); db.Grades.Insert(new Grade { Id = gradeId, GroupId = groupId, StudentId = Guid.NewGuid() }); - applier.Apply(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null)); + await applier.ApplyAsync(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null)); Assert.Null(db.Groups.FindById(groupId)); Assert.Null(db.Grades.FindById(gradeId)); } [Fact] - public void Apply_VerletztHartenUniqueIndex_WirdUebersprungenOhneAusnahme() + public async Task ApplyAsync_VerletztHartenUniqueIndex_WirdUebersprungenOhneAusnahme() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); @@ -77,8 +77,8 @@ public sealed class EventApplierTests // Zweite Mitgliedschaft für dasselbe Schüler/Gruppe-Paar verletzt den ux_student_group-Index. var duplicate = new GroupMembership { Id = Guid.NewGuid(), StudentId = studentId, GroupId = groupId }; - var exception = Record.Exception(() => - applier.Apply(MakeEvent(nameof(GroupMembership), duplicate.Id.ToString(), "Save", duplicate))); + var exception = await Record.ExceptionAsync(() => + applier.ApplyAsync(MakeEvent(nameof(GroupMembership), duplicate.Id.ToString(), "Save", duplicate))); Assert.Null(exception); Assert.Single(db.Memberships.FindAll()); @@ -89,7 +89,7 @@ public sealed class EventApplierTests // sonst entsteht ein Sync-Ping-Pong zwischen den Geräten (siehe EventApplier-Kommentar). [Fact] - public void Apply_Save_LoestNIEMALSDenOnChangeHookAus() + public async Task ApplyAsync_Save_LoestNIEMALSDenOnChangeHookAus() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); @@ -97,13 +97,13 @@ public sealed class EventApplierTests db.OnChange = (_, _, _, _) => onChangeCallCount++; var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; - applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student)); + await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student)); Assert.Equal(0, onChangeCallCount); } [Fact] - public void Apply_Delete_LoestNIEMALSDenOnChangeHookAus() + public async Task ApplyAsync_Delete_LoestNIEMALSDenOnChangeHookAus() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); @@ -112,13 +112,13 @@ public sealed class EventApplierTests var onChangeCallCount = 0; db.OnChange = (_, _, _, _) => onChangeCallCount++; - applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null)); + await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null)); Assert.Equal(0, onChangeCallCount); } [Fact] - public void Apply_GroupDeleteKaskade_LoestNIEMALSDenOnChangeHookAus() + public async Task ApplyAsync_GroupDeleteKaskade_LoestNIEMALSDenOnChangeHookAus() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); @@ -129,11 +129,57 @@ public sealed class EventApplierTests var onChangeCallCount = 0; db.OnChange = (_, _, _, _) => onChangeCallCount++; - applier.Apply(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null)); + await applier.ApplyAsync(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null)); Assert.Equal(0, onChangeCallCount); } + // ── Fehlende Anhänge nachladen ─────────────────────────────────────────────── + + [Fact] + public async Task ApplyAsync_DocumentationMitFehlendemAnhang_LaedtIhnUeberHttpNach() + { + using var db = NewInMemoryContext(); + var storageId = Guid.NewGuid().ToString("N"); + var handler = new FakeHttpMessageHandler(req => + { + Assert.Equal($"/api/sync/attachments/{storageId}", req.RequestUri!.AbsolutePath); + var encrypted = SyncCrypto.Encrypt([9, 8, 7], Key); + return new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { Content = new ByteArrayContent(encrypted) }; + }); + var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") }; + var applier = new EventApplier(db, Key, http); + var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Elternbrief" }; + doc.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "brief.pdf", SizeBytes = 3 }); + + await applier.ApplyAsync(MakeEvent(nameof(Documentation), doc.Id.ToString(), "Save", doc)); + + Assert.Single(handler.Requests); + Assert.True(db.Attachments.Exists(storageId)); + using var read = db.Attachments.OpenRead(storageId); + using var ms = new MemoryStream(); + await read.CopyToAsync(ms); + Assert.Equal([9, 8, 7], ms.ToArray()); + } + + [Fact] + public async Task ApplyAsync_DocumentationMitBereitsVorhandenemAnhang_LaedtNichtErneut() + { + using var db = NewInMemoryContext(); + var storageId = Guid.NewGuid().ToString("N"); + db.Attachments.Upload(storageId, "brief.pdf", new MemoryStream([1, 2, 3])); + var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)); + var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") }; + var applier = new EventApplier(db, Key, http); + var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Elternbrief" }; + doc.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "brief.pdf", SizeBytes = 3 }); + + await applier.ApplyAsync(MakeEvent(nameof(Documentation), doc.Id.ToString(), "Save", doc)); + + Assert.Empty(handler.Requests); + } + private static SyncEvent MakeEvent(string entityType, string entityId, string operation, object? payload) => new() { DeviceId = "companion-1", diff --git a/LehrerApp.Sync.Tests/FakeHttpMessageHandler.cs b/LehrerApp.Sync.Tests/FakeHttpMessageHandler.cs new file mode 100644 index 0000000..aca173c --- /dev/null +++ b/LehrerApp.Sync.Tests/FakeHttpMessageHandler.cs @@ -0,0 +1,14 @@ +namespace LehrerApp.Sync.Tests; + +/// Zeichnet Requests auf und beantwortet sie über eine Callback-Funktion, ohne echtes Netzwerk. +public sealed class FakeHttpMessageHandler(Func respond) : HttpMessageHandler +{ + public List Requests { get; } = []; + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(respond(request)); + } +} diff --git a/LehrerApp.Sync/AttachmentSyncer.cs b/LehrerApp.Sync/AttachmentSyncer.cs new file mode 100644 index 0000000..f71064a --- /dev/null +++ b/LehrerApp.Sync/AttachmentSyncer.cs @@ -0,0 +1,36 @@ +using LehrerApp.Data; +using LehrerApp.Sync.Crypto; + +namespace LehrerApp.Sync; + +/// +/// Lädt ausstehende Datei-Anhänge (siehe ) +/// als eigenen, verschlüsselten Binärtransfer hoch — getrennt vom JSON-Ereigniskanal, damit +/// Fotos/Scans ihn nicht aufblähen. Gegenstück zum Download in . +/// +public class AttachmentSyncer(LiteDbContext db, HttpClient http, byte[] syncKey) +{ + public async Task UploadPendingAsync(EventQueue queue) + { + foreach (var storageId in queue.GetPendingAttachmentUploads()) + { + if (!db.Attachments.Exists(storageId)) + { + // Lokal inzwischen wieder gelöscht (z.B. HardDelete vor dem eigentlichen Upload) - + // nichts hochzuladen, Warteliste trotzdem bereinigen. + queue.MarkAttachmentUploaded(storageId); + continue; + } + + using var raw = db.Attachments.OpenRead(storageId); + using var buffer = new MemoryStream(); + await raw.CopyToAsync(buffer); + var encrypted = SyncCrypto.Encrypt(buffer.ToArray(), syncKey); + + using var content = new ByteArrayContent(encrypted); + var resp = await http.PostAsync($"/api/sync/attachments/{storageId}", content); + resp.EnsureSuccessStatusCode(); + queue.MarkAttachmentUploaded(storageId); + } + } +} diff --git a/LehrerApp.Sync/EventApplier.cs b/LehrerApp.Sync/EventApplier.cs index f1c687b..facc140 100644 --- a/LehrerApp.Sync/EventApplier.cs +++ b/LehrerApp.Sync/EventApplier.cs @@ -23,17 +23,19 @@ namespace LehrerApp.Sync; /// Pfad bewusst NICHT geprüft (v1-Einschränkung, siehe TODO.md 10.3) — nur harte LiteDB-Unique- /// Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses. /// -public class EventApplier(LiteDbContext db, byte[] syncKey) +public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = null) { private static readonly Dictionary Handlers = BuildHandlers(); - public void Apply(SyncEvent evt) + public async Task ApplyAsync(SyncEvent evt) { if (!Handlers.TryGetValue(evt.EntityType, out var handler)) return; try { var json = evt.Payload.Length == 0 ? "" : Decrypt(evt.Payload); handler(db, evt.Operation, evt.EntityId, json); + if (evt.EntityType == nameof(Documentation) && evt.Operation != "Delete" && http is not null) + await DownloadMissingAttachmentsAsync(json); } catch (LiteException) { @@ -42,6 +44,27 @@ public class EventApplier(LiteDbContext db, byte[] syncKey) } } + // Anhang-Bytes reisen nicht im JSON-Ereignis mit (siehe SyncEventPublisher) - nach dem + // Anwenden der Documentation-Metadaten fehlende, lokal noch nicht vorhandene Anhänge einzeln + // nachladen. Gegenstück zum Upload in AttachmentSyncer. + private async Task DownloadMissingAttachmentsAsync(string json) + { + var doc = JsonSerializer.Deserialize(json); + if (doc is null) return; + foreach (var attachment in doc.Attachments) + { + if (db.Attachments.Exists(attachment.StorageId)) continue; + var resp = await http!.GetAsync($"/api/sync/attachments/{attachment.StorageId}"); + if (!resp.IsSuccessStatusCode) continue; + var encrypted = await resp.Content.ReadAsByteArrayAsync(); + var decrypted = SyncCrypto.Decrypt(encrypted, syncKey); + using var stream = new MemoryStream(decrypted); + // Über die rohe Collection statt IAttachmentStorage.Upload, da dieses immer eine + // neue Id vergibt - hier muss die Original-StorageId erhalten bleiben. + db.Attachments.Upload(attachment.StorageId, attachment.FileName, stream); + } + } + private string Decrypt(string payloadBase64) => Encoding.UTF8.GetString(SyncCrypto.Decrypt(Convert.FromBase64String(payloadBase64), syncKey)); diff --git a/LehrerApp.Sync/EventQueue.cs b/LehrerApp.Sync/EventQueue.cs index a4bd54d..886dca4 100644 --- a/LehrerApp.Sync/EventQueue.cs +++ b/LehrerApp.Sync/EventQueue.cs @@ -13,6 +13,7 @@ public class EventQueue : IDisposable private readonly ILiteCollection _queue; private readonly ILiteCollection _meta; private readonly ILiteCollection _conflicts; + private readonly ILiteCollection _attachmentUploads; private long _currentSeq; public EventQueue(string path) @@ -21,6 +22,8 @@ public class EventQueue : IDisposable _queue = _db.GetCollection("queue"); _meta = _db.GetCollection("meta"); _conflicts = _db.GetCollection("conflicts"); + _attachmentUploads = _db.GetCollection("attachment_uploads"); + _attachmentUploads.EnsureIndex(x => x.StorageId, unique: true); _queue.EnsureIndex(x => x.SequenceNr); _currentSeq = _meta.FindById("seq")?.Value ?? 0; } @@ -56,6 +59,18 @@ public class EventQueue : IDisposable public void AddConflict(ConflictEntry c) => _conflicts.Insert(c); public List GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList(); public int ConflictCount() => _conflicts.Count(c => !c.Reviewed); + + // ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ──── + public void QueueAttachmentUpload(string storageId) + { + if (!_attachmentUploads.Exists(a => a.StorageId == storageId)) + _attachmentUploads.Insert(new PendingAttachmentUpload { StorageId = storageId }); + } + public List GetPendingAttachmentUploads() => + _attachmentUploads.FindAll().Select(a => a.StorageId).ToList(); + public void MarkAttachmentUploaded(string storageId) => + _attachmentUploads.DeleteMany(a => a.StorageId == storageId); + public void Dispose() => _db.Dispose(); } @@ -75,3 +90,9 @@ internal class SyncMeta public long Value { get; set; } public DateTime? Timestamp { get; set; } } + +internal class PendingAttachmentUpload +{ + public ObjectId Id { get; set; } = ObjectId.NewObjectId(); + public string StorageId { get; set; } = ""; +} diff --git a/LehrerApp.Sync/SyncEngine.cs b/LehrerApp.Sync/SyncEngine.cs index 812eaaa..6dc969e 100644 --- a/LehrerApp.Sync/SyncEngine.cs +++ b/LehrerApp.Sync/SyncEngine.cs @@ -12,6 +12,7 @@ public class SyncEngine : IDisposable private readonly EventQueue _queue; private readonly ConflictResolver _resolver; private readonly EventApplier _applier; + private readonly AttachmentSyncer _attachments; private readonly HttpClient _http; private readonly SyncConfig _config; private readonly Timer _timer; @@ -20,13 +21,14 @@ public class SyncEngine : IDisposable public event Action? StatusChanged; public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier, - HttpClient http, SyncConfig config) + AttachmentSyncer attachments, HttpClient http, SyncConfig config) { - _queue = queue; - _resolver = resolver; - _applier = applier; - _http = http; - _config = config; + _queue = queue; + _resolver = resolver; + _applier = applier; + _attachments = attachments; + _http = http; + _config = config; _timer = new Timer( async _ => await SyncNowAsync(true), null, TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes), @@ -42,6 +44,7 @@ public class SyncEngine : IDisposable try { var (pushed, _) = await PushAsync(); + await _attachments.UploadPendingAsync(_queue); var (pulled, conflicts) = await PullAsync(); _queue.SetLastSyncAt(DateTime.UtcNow); SetState(SyncState.Idle); @@ -77,10 +80,10 @@ public class SyncEngine : IDisposable foreach (var evt in resp.Events) { var c = _resolver.TryResolve(evt, _config.DeviceId); - if (c is null) { _applier.Apply(evt); continue; } + if (c is null) { await _applier.ApplyAsync(evt); continue; } _queue.AddConflict(c); conflicts++; - if (c.Resolution == "RemoteWon") _applier.Apply(evt); + if (c.Resolution == "RemoteWon") await _applier.ApplyAsync(evt); } _queue.SetLastServerSeq(resp.ServerSequenceNr); return (resp.Events.Count, conflicts); diff --git a/LehrerApp.Sync/SyncEventPublisher.cs b/LehrerApp.Sync/SyncEventPublisher.cs index 9c2dfe1..01e38e9 100644 --- a/LehrerApp.Sync/SyncEventPublisher.cs +++ b/LehrerApp.Sync/SyncEventPublisher.cs @@ -1,3 +1,4 @@ +using LehrerApp.Core.Models; using LehrerApp.Data; using LehrerApp.Sync.Crypto; using LehrerApp.Sync.Models; @@ -15,5 +16,11 @@ public class SyncEventPublisher(EventQueue queue, string deviceId, byte[] syncKe { var encrypted = payload is null ? "" : SyncCrypto.EncryptObject(payload, syncKey); queue.Enqueue(deviceId, DeviceType.Desktop, entityType, entityId, operation, encrypted); + + // Anhänge reisen nicht im JSON-Ereignis mit (würde den Kanal für Fotos/Scans aufblähen), + // sondern als eigener Binärtransfer über AttachmentSyncer — hier nur zur Warteliste hinzufügen. + if (payload is Documentation doc) + foreach (var attachment in doc.Attachments) + queue.QueueAttachmentUpload(attachment.StorageId); } }