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 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 11:30:04 +02:00
co-authored by Claude Sonnet 5
parent ce4dfb0197
commit f0f8fa25e5
14 changed files with 391 additions and 29 deletions
@@ -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 <root>/attachments/<user> 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);
}
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace LehrerApp.Api;
/// <summary>
/// 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.
/// </summary>
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 == '-'));
}
+24
View File
@@ -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) ───────────────────────────────────────────── // ── Snapshot (Device-Pairing) ─────────────────────────────────────────────
public static void MapSnapshotEndpoints(this WebApplication app) public static void MapSnapshotEndpoints(this WebApplication app)
+2
View File
@@ -30,6 +30,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddSingleton<UserStore>(_ => new UserStore(data)); builder.Services.AddSingleton<UserStore>(_ => new UserStore(data));
builder.Services.AddSingleton<AttachmentStore>(_ => new AttachmentStore(data));
builder.Services.AddSingleton<EventStore>(_ => new EventStore(data)); builder.Services.AddSingleton<EventStore>(_ => new EventStore(data));
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data)); builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data)); builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
@@ -41,6 +42,7 @@ app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapAuthEndpoints(secret); app.MapAuthEndpoints(secret);
app.MapSyncEndpoints(); app.MapSyncEndpoints();
app.MapAttachmentEndpoints();
app.MapSnapshotEndpoints(); app.MapSnapshotEndpoints();
app.MapReadableSnapshotEndpoints(); app.MapReadableSnapshotEndpoints();
app.MapPlainSyncEndpoints(); app.MapPlainSyncEndpoints();
+6 -1
View File
@@ -179,14 +179,19 @@ public static class AppBootstrapper
if (!string.IsNullOrEmpty(serverUrl)) if (!string.IsNullOrEmpty(serverUrl))
{ {
services.AddSingleton(sp => new EventApplier( services.AddSingleton(sp => new EventApplier(
sp.GetRequiredService<LiteDbContext>(), sp.GetRequiredService<byte[]>())); sp.GetRequiredService<LiteDbContext>(), sp.GetRequiredService<byte[]>(),
BuildHttp(serverUrl, appData)));
services.AddSingleton(sp => new SyncEventPublisher( services.AddSingleton(sp => new SyncEventPublisher(
sp.GetRequiredService<EventQueue>(), deviceId, sp.GetRequiredService<byte[]>())); sp.GetRequiredService<EventQueue>(), deviceId, sp.GetRequiredService<byte[]>()));
services.AddSingleton(sp => new AttachmentSyncer(
sp.GetRequiredService<LiteDbContext>(), BuildHttp(serverUrl, appData),
sp.GetRequiredService<byte[]>()));
services.AddSingleton<SyncEngine>(sp => new SyncEngine( services.AddSingleton<SyncEngine>(sp => new SyncEngine(
sp.GetRequiredService<EventQueue>(), sp.GetRequiredService<EventQueue>(),
sp.GetRequiredService<ConflictResolver>(), sp.GetRequiredService<ConflictResolver>(),
sp.GetRequiredService<EventApplier>(), sp.GetRequiredService<EventApplier>(),
sp.GetRequiredService<AttachmentSyncer>(),
BuildHttp(serverUrl, appData), BuildHttp(serverUrl, appData),
new SyncConfig new SyncConfig
{ {
+6
View File
@@ -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)]
@@ -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);
}
}
}
+64 -18
View File
@@ -12,13 +12,13 @@ public sealed class EventApplierTests
private static readonly byte[] Key = SyncCrypto.GenerateKey(); private static readonly byte[] Key = SyncCrypto.GenerateKey();
[Fact] [Fact]
public void Apply_Save_SchreibtEntitaetDirektInDieCollection() public async Task ApplyAsync_Save_SchreibtEntitaetDirektInDieCollection()
{ {
using var db = NewInMemoryContext(); using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key); var applier = new EventApplier(db, Key);
var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; 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); var saved = db.Students.FindById(student.Id);
Assert.NotNull(saved); Assert.NotNull(saved);
@@ -26,32 +26,32 @@ public sealed class EventApplierTests
} }
[Fact] [Fact]
public void Apply_Delete_EntferntDenDatensatz() public async Task ApplyAsync_Delete_EntferntDenDatensatz()
{ {
using var db = NewInMemoryContext(); using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key); var applier = new EventApplier(db, Key);
var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
db.Students.Insert(student); 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)); Assert.Null(db.Students.FindById(student.Id));
} }
[Fact] [Fact]
public void Apply_UnbekannterEntityType_TutNichtsUndWirftNicht() public async Task ApplyAsync_UnbekannterEntityType_TutNichtsUndWirftNicht()
{ {
using var db = NewInMemoryContext(); using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key); var applier = new EventApplier(db, Key);
var exception = Record.Exception(() => var exception = await Record.ExceptionAsync(() =>
applier.Apply(MakeEvent("UnbekannterTyp", Guid.NewGuid().ToString(), "Save", new { Foo = "Bar" }))); applier.ApplyAsync(MakeEvent("UnbekannterTyp", Guid.NewGuid().ToString(), "Save", new { Foo = "Bar" })));
Assert.Null(exception); Assert.Null(exception);
} }
[Fact] [Fact]
public void Apply_GroupDelete_FuehrtDieselbeKaskadeAusWieDasRepository() public async Task ApplyAsync_GroupDelete_FuehrtDieselbeKaskadeAusWieDasRepository()
{ {
using var db = NewInMemoryContext(); using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key); var applier = new EventApplier(db, Key);
@@ -60,14 +60,14 @@ public sealed class EventApplierTests
var gradeId = Guid.NewGuid(); var gradeId = Guid.NewGuid();
db.Grades.Insert(new Grade { Id = gradeId, GroupId = groupId, StudentId = 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.Groups.FindById(groupId));
Assert.Null(db.Grades.FindById(gradeId)); Assert.Null(db.Grades.FindById(gradeId));
} }
[Fact] [Fact]
public void Apply_VerletztHartenUniqueIndex_WirdUebersprungenOhneAusnahme() public async Task ApplyAsync_VerletztHartenUniqueIndex_WirdUebersprungenOhneAusnahme()
{ {
using var db = NewInMemoryContext(); using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key); 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. // 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 duplicate = new GroupMembership { Id = Guid.NewGuid(), StudentId = studentId, GroupId = groupId };
var exception = Record.Exception(() => var exception = await Record.ExceptionAsync(() =>
applier.Apply(MakeEvent(nameof(GroupMembership), duplicate.Id.ToString(), "Save", duplicate))); applier.ApplyAsync(MakeEvent(nameof(GroupMembership), duplicate.Id.ToString(), "Save", duplicate)));
Assert.Null(exception); Assert.Null(exception);
Assert.Single(db.Memberships.FindAll()); 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). // sonst entsteht ein Sync-Ping-Pong zwischen den Geräten (siehe EventApplier-Kommentar).
[Fact] [Fact]
public void Apply_Save_LoestNIEMALSDenOnChangeHookAus() public async Task ApplyAsync_Save_LoestNIEMALSDenOnChangeHookAus()
{ {
using var db = NewInMemoryContext(); using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key); var applier = new EventApplier(db, Key);
@@ -97,13 +97,13 @@ public sealed class EventApplierTests
db.OnChange = (_, _, _, _) => onChangeCallCount++; db.OnChange = (_, _, _, _) => onChangeCallCount++;
var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; 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); Assert.Equal(0, onChangeCallCount);
} }
[Fact] [Fact]
public void Apply_Delete_LoestNIEMALSDenOnChangeHookAus() public async Task ApplyAsync_Delete_LoestNIEMALSDenOnChangeHookAus()
{ {
using var db = NewInMemoryContext(); using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key); var applier = new EventApplier(db, Key);
@@ -112,13 +112,13 @@ public sealed class EventApplierTests
var onChangeCallCount = 0; var onChangeCallCount = 0;
db.OnChange = (_, _, _, _) => onChangeCallCount++; 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); Assert.Equal(0, onChangeCallCount);
} }
[Fact] [Fact]
public void Apply_GroupDeleteKaskade_LoestNIEMALSDenOnChangeHookAus() public async Task ApplyAsync_GroupDeleteKaskade_LoestNIEMALSDenOnChangeHookAus()
{ {
using var db = NewInMemoryContext(); using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key); var applier = new EventApplier(db, Key);
@@ -129,11 +129,57 @@ public sealed class EventApplierTests
var onChangeCallCount = 0; var onChangeCallCount = 0;
db.OnChange = (_, _, _, _) => onChangeCallCount++; 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); 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() private static SyncEvent MakeEvent(string entityType, string entityId, string operation, object? payload) => new()
{ {
DeviceId = "companion-1", DeviceId = "companion-1",
@@ -0,0 +1,14 @@
namespace LehrerApp.Sync.Tests;
/// <summary>Zeichnet Requests auf und beantwortet sie über eine Callback-Funktion, ohne echtes Netzwerk.</summary>
public sealed class FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
public List<HttpRequestMessage> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(request);
return Task.FromResult(respond(request));
}
}
+36
View File
@@ -0,0 +1,36 @@
using LehrerApp.Data;
using LehrerApp.Sync.Crypto;
namespace LehrerApp.Sync;
/// <summary>
/// Lädt ausstehende Datei-Anhänge (siehe <see cref="EventQueue.GetPendingAttachmentUploads"/>)
/// als eigenen, verschlüsselten Binärtransfer hoch — getrennt vom JSON-Ereigniskanal, damit
/// Fotos/Scans ihn nicht aufblähen. Gegenstück zum Download in <see cref="EventApplier"/>.
/// </summary>
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);
}
}
}
+25 -2
View File
@@ -23,17 +23,19 @@ namespace LehrerApp.Sync;
/// Pfad bewusst NICHT geprüft (v1-Einschränkung, siehe TODO.md 10.3) — nur harte LiteDB-Unique- /// 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. /// Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses.
/// </summary> /// </summary>
public class EventApplier(LiteDbContext db, byte[] syncKey) public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = null)
{ {
private static readonly Dictionary<string, EntityHandler> Handlers = BuildHandlers(); private static readonly Dictionary<string, EntityHandler> Handlers = BuildHandlers();
public void Apply(SyncEvent evt) public async Task ApplyAsync(SyncEvent evt)
{ {
if (!Handlers.TryGetValue(evt.EntityType, out var handler)) return; if (!Handlers.TryGetValue(evt.EntityType, out var handler)) return;
try try
{ {
var json = evt.Payload.Length == 0 ? "" : Decrypt(evt.Payload); var json = evt.Payload.Length == 0 ? "" : Decrypt(evt.Payload);
handler(db, evt.Operation, evt.EntityId, json); handler(db, evt.Operation, evt.EntityId, json);
if (evt.EntityType == nameof(Documentation) && evt.Operation != "Delete" && http is not null)
await DownloadMissingAttachmentsAsync(json);
} }
catch (LiteException) 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<Documentation>(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) => private string Decrypt(string payloadBase64) =>
Encoding.UTF8.GetString(SyncCrypto.Decrypt(Convert.FromBase64String(payloadBase64), syncKey)); Encoding.UTF8.GetString(SyncCrypto.Decrypt(Convert.FromBase64String(payloadBase64), syncKey));
+21
View File
@@ -13,6 +13,7 @@ public class EventQueue : IDisposable
private readonly ILiteCollection<SyncEvent> _queue; private readonly ILiteCollection<SyncEvent> _queue;
private readonly ILiteCollection<SyncMeta> _meta; private readonly ILiteCollection<SyncMeta> _meta;
private readonly ILiteCollection<ConflictEntry> _conflicts; private readonly ILiteCollection<ConflictEntry> _conflicts;
private readonly ILiteCollection<PendingAttachmentUpload> _attachmentUploads;
private long _currentSeq; private long _currentSeq;
public EventQueue(string path) public EventQueue(string path)
@@ -21,6 +22,8 @@ public class EventQueue : IDisposable
_queue = _db.GetCollection<SyncEvent>("queue"); _queue = _db.GetCollection<SyncEvent>("queue");
_meta = _db.GetCollection<SyncMeta>("meta"); _meta = _db.GetCollection<SyncMeta>("meta");
_conflicts = _db.GetCollection<ConflictEntry>("conflicts"); _conflicts = _db.GetCollection<ConflictEntry>("conflicts");
_attachmentUploads = _db.GetCollection<PendingAttachmentUpload>("attachment_uploads");
_attachmentUploads.EnsureIndex(x => x.StorageId, unique: true);
_queue.EnsureIndex(x => x.SequenceNr); _queue.EnsureIndex(x => x.SequenceNr);
_currentSeq = _meta.FindById("seq")?.Value ?? 0; _currentSeq = _meta.FindById("seq")?.Value ?? 0;
} }
@@ -56,6 +59,18 @@ public class EventQueue : IDisposable
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c); public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList(); public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed); 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<string> GetPendingAttachmentUploads() =>
_attachmentUploads.FindAll().Select(a => a.StorageId).ToList();
public void MarkAttachmentUploaded(string storageId) =>
_attachmentUploads.DeleteMany(a => a.StorageId == storageId);
public void Dispose() => _db.Dispose(); public void Dispose() => _db.Dispose();
} }
@@ -75,3 +90,9 @@ internal class SyncMeta
public long Value { get; set; } public long Value { get; set; }
public DateTime? Timestamp { get; set; } public DateTime? Timestamp { get; set; }
} }
internal class PendingAttachmentUpload
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string StorageId { get; set; } = "";
}
+6 -3
View File
@@ -12,6 +12,7 @@ public class SyncEngine : IDisposable
private readonly EventQueue _queue; private readonly EventQueue _queue;
private readonly ConflictResolver _resolver; private readonly ConflictResolver _resolver;
private readonly EventApplier _applier; private readonly EventApplier _applier;
private readonly AttachmentSyncer _attachments;
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly SyncConfig _config; private readonly SyncConfig _config;
private readonly Timer _timer; private readonly Timer _timer;
@@ -20,11 +21,12 @@ public class SyncEngine : IDisposable
public event Action<SyncStatus>? StatusChanged; public event Action<SyncStatus>? StatusChanged;
public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier, public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier,
HttpClient http, SyncConfig config) AttachmentSyncer attachments, HttpClient http, SyncConfig config)
{ {
_queue = queue; _queue = queue;
_resolver = resolver; _resolver = resolver;
_applier = applier; _applier = applier;
_attachments = attachments;
_http = http; _http = http;
_config = config; _config = config;
_timer = new Timer( _timer = new Timer(
@@ -42,6 +44,7 @@ public class SyncEngine : IDisposable
try try
{ {
var (pushed, _) = await PushAsync(); var (pushed, _) = await PushAsync();
await _attachments.UploadPendingAsync(_queue);
var (pulled, conflicts) = await PullAsync(); var (pulled, conflicts) = await PullAsync();
_queue.SetLastSyncAt(DateTime.UtcNow); _queue.SetLastSyncAt(DateTime.UtcNow);
SetState(SyncState.Idle); SetState(SyncState.Idle);
@@ -77,10 +80,10 @@ public class SyncEngine : IDisposable
foreach (var evt in resp.Events) foreach (var evt in resp.Events)
{ {
var c = _resolver.TryResolve(evt, _config.DeviceId); 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); _queue.AddConflict(c);
conflicts++; conflicts++;
if (c.Resolution == "RemoteWon") _applier.Apply(evt); if (c.Resolution == "RemoteWon") await _applier.ApplyAsync(evt);
} }
_queue.SetLastServerSeq(resp.ServerSequenceNr); _queue.SetLastServerSeq(resp.ServerSequenceNr);
return (resp.Events.Count, conflicts); return (resp.Events.Count, conflicts);
+7
View File
@@ -1,3 +1,4 @@
using LehrerApp.Core.Models;
using LehrerApp.Data; using LehrerApp.Data;
using LehrerApp.Sync.Crypto; using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models; 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); var encrypted = payload is null ? "" : SyncCrypto.EncryptObject(payload, syncKey);
queue.Enqueue(deviceId, DeviceType.Desktop, entityType, entityId, operation, encrypted); 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);
} }
} }