feat: sync guard - Blockieren alter Clients beim sync
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.");
|
||||
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user