feat: sync guard - Blockieren alter Clients beim sync

This commit is contained in:
2026-08-20 23:54:04 +02:00
parent 7b883555bf
commit 59bd8abb45
19 changed files with 369 additions and 26 deletions
+5 -3
View File
@@ -27,9 +27,11 @@ public class AttachmentSyncer(LiteDbContext db, HttpClient http, byte[] syncKey)
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();
using var request = SyncProtocol.CreateRequest(HttpMethod.Post,
$"/api/sync/attachments/{storageId}");
request.Content = new ByteArrayContent(encrypted);
using var resp = await http.SendAsync(request);
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
queue.MarkAttachmentUploaded(storageId);
}
}
+11 -1
View File
@@ -55,6 +55,12 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
versions?.SetKnownServerSeq(evt.EntityType, evt.EntityId, evt.SequenceNr);
logger?.Info($"Sync: Ereignis angewendet - {evt.EntityType} {evt.Operation} EntityId={evt.EntityId}");
}
catch (SyncProtocolMismatchException)
{
// Anders als ein einzelnes korruptes Ereignis betrifft dies den gesamten Batch. Der
// Pull-Cursor darf nicht vorrücken, solange Client und Server inkompatibel sind.
throw;
}
catch (LiteException ex)
{
// Harte Constraint-Verletzung (z.B. Unique-Index) - dieses eine Ereignis
@@ -86,7 +92,11 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
foreach (var attachment in entity.Attachments)
{
if (db.Attachments.Exists(attachment.StorageId)) continue;
var resp = await http!.GetAsync($"/api/sync/attachments/{attachment.StorageId}");
using var request = SyncProtocol.CreateRequest(HttpMethod.Get,
$"/api/sync/attachments/{attachment.StorageId}");
using var resp = await http!.SendAsync(request);
if (resp.StatusCode == System.Net.HttpStatusCode.UpgradeRequired)
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
if (!resp.IsSuccessStatusCode) continue;
var encrypted = await resp.Content.ReadAsByteArrayAsync();
var decrypted = SyncCrypto.Decrypt(encrypted, syncKey);
+1 -1
View File
@@ -114,4 +114,4 @@ public class SyncStatus
// ── Enums ─────────────────────────────────────────────────────────────────────
public enum DeviceType { Desktop, Companion }
public enum SyncState { Idle, Syncing, Error, Offline }
public enum SyncState { Idle, Syncing, Error, Offline, IncompatibleVersion }
+13 -8
View File
@@ -28,20 +28,24 @@ public class SnapshotService(
// Schritt 1: Upload ohne Key → Code erhalten
Report(SnapshotStep.Uploading, "Code wird angefordert…");
var r1 = await http.PostAsJsonAsync("/api/snapshot/upload",
new SnapshotUploadRequest { EncryptedPayload = encPayload, DeviceType = deviceType }, ct);
r1.EnsureSuccessStatusCode();
using var request1 = SyncProtocol.CreateRequest(HttpMethod.Post, "/api/snapshot/upload");
request1.Content = JsonContent.Create(
new SnapshotUploadRequest { EncryptedPayload = encPayload, DeviceType = deviceType });
using var r1 = await http.SendAsync(request1, ct);
await SyncProtocol.EnsureCompatibleSuccessAsync(r1);
var init = await r1.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
?? throw new InvalidOperationException("Leere Server-Antwort.");
// Schritt 2: Key mit Code verschlüsseln + erneut hochladen
Report(SnapshotStep.Uploading, "Schlüssel wird verschlüsselt…");
var encKey = SyncCrypto.EncryptKeyWithCode(syncKey, init.Code);
var r2 = await http.PostAsJsonAsync("/api/snapshot/upload",
using var request2 = SyncProtocol.CreateRequest(HttpMethod.Post, "/api/snapshot/upload");
request2.Content = JsonContent.Create(
new SnapshotUploadRequest { EncryptedPayload = encPayload,
EncryptedSyncKey = encKey, DeviceType = deviceType,
Code = init.Code }, ct);
r2.EnsureSuccessStatusCode();
Code = init.Code });
using var r2 = await http.SendAsync(request2, ct);
await SyncProtocol.EnsureCompatibleSuccessAsync(r2);
var result = await r2.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
?? throw new InvalidOperationException("Leere Server-Antwort.");
Report(SnapshotStep.Done, $"Bereit Code: {result.Code}");
@@ -53,10 +57,11 @@ public class SnapshotService(
{
var sanitized = code.Trim().ToUpperInvariant();
Report(SnapshotStep.Downloading, "Snapshot wird geladen…");
var resp = await http.GetAsync($"/api/snapshot/{sanitized}", ct);
using var request = SyncProtocol.CreateRequest(HttpMethod.Get, $"/api/snapshot/{sanitized}");
using var resp = await http.SendAsync(request, ct);
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
throw new SnapshotNotFoundException($"Code '{sanitized}' nicht gefunden oder abgelaufen.");
resp.EnsureSuccessStatusCode();
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
var dl = await resp.Content.ReadFromJsonAsync<SnapshotDownloadResponse>(ct)
?? throw new InvalidOperationException("Leere Server-Antwort.");
+18 -5
View File
@@ -100,6 +100,12 @@ public class SyncEngine : IDisposable
$"{pulled} gepullt ({conflicts} Pull-Konflikte)");
return new() { Success = true, EventsPushed = pushed, EventsPulled = pulled, Conflicts = conflicts };
}
catch (SyncProtocolMismatchException ex)
{
_logger?.Error("Sync wegen inkompatibler Protokollversion abgebrochen", ex);
SetState(SyncState.IncompatibleVersion, ex.Message);
return new() { Reason = ex.Message };
}
catch (HttpRequestException ex)
{
// Sammelt sowohl echte Netzwerkfehler als auch nicht-erfolgreiche HTTP-Antworten
@@ -128,8 +134,10 @@ public class SyncEngine : IDisposable
evt.BasedOnServerSeq = _queue.GetKnownServerSeq(evt.EntityType, evt.EntityId);
_logger?.Info($"Sync: Push - {pending.Count} Ereignis(se) ausstehend: " +
string.Join(", ", pending.Select(e => $"{e.EntityType}/{e.Operation}")));
var resp = await _http.PostAsJsonAsync("/api/sync/push", pending);
resp.EnsureSuccessStatusCode();
using var request = SyncProtocol.CreateRequest(HttpMethod.Post, "/api/sync/push");
request.Content = JsonContent.Create(pending);
using var resp = await _http.SendAsync(request);
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
var result = await resp.Content.ReadFromJsonAsync<PushResponse>();
if (result is null) { _logger?.Warn("Sync: Push - leere Server-Antwort."); return (0, 0); }
_queue.Acknowledge(pending
@@ -196,7 +204,9 @@ public class SyncEngine : IDisposable
HttpResponseMessage resp;
try
{
resp = await _http.GetAsync($"/api/sync/entity/{local.EntityType}/{local.EntityId}");
using var request = SyncProtocol.CreateRequest(HttpMethod.Get,
$"/api/sync/entity/{local.EntityType}/{local.EntityId}");
resp = await _http.SendAsync(request);
}
catch (Exception ex)
{
@@ -217,7 +227,7 @@ public class SyncEngine : IDisposable
"nächster Push behandelt sie als neu.");
continue;
}
resp.EnsureSuccessStatusCode();
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
var remote = await resp.Content.ReadFromJsonAsync<SyncEvent>();
if (remote is null)
{
@@ -253,8 +263,11 @@ public class SyncEngine : IDisposable
{
var since = _queue.GetLastServerSeq();
_logger?.Info($"Sync: Pull - frage Server nach Ereignissen seit ServerSequenceNr={since}.");
var resp = await _http.GetFromJsonAsync<PullResponse>(
using var request = SyncProtocol.CreateRequest(HttpMethod.Get,
$"/api/sync/pull?since={since}&deviceId={_config.DeviceId}");
using var response = await _http.SendAsync(request);
await SyncProtocol.EnsureCompatibleSuccessAsync(response);
var resp = await response.Content.ReadFromJsonAsync<PullResponse>();
if (resp is null || resp.Events.Count == 0)
{
_logger?.Info("Sync: Pull - keine neuen Ereignisse vom Server.");
+44
View File
@@ -0,0 +1,44 @@
using System.Net;
namespace LehrerApp.Sync;
/// <summary>
/// Version des Wire-Protokolls zwischen Desktop-App und Sync-Server.
///
/// Bei jeder inkompatiblen Änderung an Sync-Events, Snapshots oder deren Verarbeitung erhöhen.
/// Der Server lehnt Clients mit einer anderen (oder keiner) Version ab, bevor Daten gelesen oder
/// geschrieben werden. Dadurch können alte App-Versionen nach einem Server-Deployment keine
/// nicht mehr kompatiblen Daten in den Server-Store schreiben.
/// </summary>
public static class SyncProtocol
{
public const string CurrentVersion = "1";
public const string VersionHeaderName = "X-LehrerApp-Sync-Version";
public static HttpRequestMessage CreateRequest(HttpMethod method, string requestUri)
{
var request = new HttpRequestMessage(method, requestUri);
request.Headers.Add(VersionHeaderName, CurrentVersion);
return request;
}
public static Task EnsureCompatibleSuccessAsync(HttpResponseMessage response)
{
if (response.StatusCode == HttpStatusCode.UpgradeRequired)
{
var serverVersion = response.Headers.TryGetValues(VersionHeaderName, out var values)
? values.FirstOrDefault()
: null;
var detail = serverVersion is null
? $"Diese App verwendet Sync-Version {CurrentVersion}, der Server eine andere Version."
: $"Diese App verwendet Sync-Version {CurrentVersion}, der Server Version {serverVersion}.";
throw new SyncProtocolMismatchException(
$"{detail} Bitte App und Server auf denselben Stand aktualisieren.");
}
response.EnsureSuccessStatusCode();
return Task.CompletedTask;
}
}
public sealed class SyncProtocolMismatchException(string message) : Exception(message);