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
@@ -0,0 +1,81 @@
using LehrerApp.Sync;
using Microsoft.AspNetCore.Http;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class SyncProtocolVersionMiddlewareTests
{
[Theory]
[InlineData("/api/sync/push")]
[InlineData("/api/sync/plain/push")]
[InlineData("/api/sync/attachments/datei")]
[InlineData("/api/snapshot/upload")]
public async Task FehlendeVersion_BlockiertAlleSyncKanaeleVorDemEndpoint(string path)
{
var endpointReached = false;
var middleware = Middleware(() => endpointReached = true);
var context = Context(path);
await middleware.InvokeAsync(context);
Assert.False(endpointReached);
Assert.Equal(StatusCodes.Status426UpgradeRequired, context.Response.StatusCode);
Assert.Equal(SyncProtocol.CurrentVersion,
context.Response.Headers[SyncProtocol.VersionHeaderName].ToString());
}
[Fact]
public async Task AbweichendeVersion_BlockiertRequest()
{
var endpointReached = false;
var middleware = Middleware(() => endpointReached = true);
var context = Context("/api/sync/push");
context.Request.Headers[SyncProtocol.VersionHeaderName] = "0";
await middleware.InvokeAsync(context);
Assert.False(endpointReached);
Assert.Equal(StatusCodes.Status426UpgradeRequired, context.Response.StatusCode);
}
[Fact]
public async Task AktuelleVersion_LaesstRequestZumEndpointDurch()
{
var endpointReached = false;
var middleware = Middleware(() => endpointReached = true);
var context = Context("/api/sync/push");
context.Request.Headers[SyncProtocol.VersionHeaderName] = SyncProtocol.CurrentVersion;
await middleware.InvokeAsync(context);
Assert.True(endpointReached);
Assert.NotEqual(StatusCodes.Status426UpgradeRequired, context.Response.StatusCode);
}
[Fact]
public async Task NichtSyncEndpoint_BrauchtKeineVersion()
{
var endpointReached = false;
var middleware = Middleware(() => endpointReached = true);
var context = Context("/api/auth/login");
await middleware.InvokeAsync(context);
Assert.True(endpointReached);
}
private static SyncProtocolVersionMiddleware Middleware(Action reached) => new(_ =>
{
reached();
return Task.CompletedTask;
});
private static DefaultHttpContext Context(string path)
{
var context = new DefaultHttpContext();
context.Request.Path = path;
context.Response.Body = new MemoryStream();
return context;
}
}
+2
View File
@@ -85,6 +85,8 @@ builder.Services.AddSingleton<PlainEventStore>(sp =>
var app = builder.Build();
app.UseForwardedHeaders();
app.UseRateLimiter();
// Muss vor Auth/Endpoints laufen: inkompatible Clients dürfen keinen Sync-Store erreichen.
app.UseMiddleware<SyncProtocolVersionMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.MapAuthEndpoints(secret);
@@ -0,0 +1,44 @@
using System.Text.Json;
using LehrerApp.Sync;
namespace LehrerApp.Api;
/// <summary>
/// Stoppt veraltete Sync-Clients vor Authentifizierung und insbesondere vor jedem Store-Zugriff.
/// Die Prüfung gilt auch für Anhänge, Plain-Sync und Device-Pairing-Snapshots.
/// </summary>
public sealed class SyncProtocolVersionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
if (!IsProtectedSyncPath(context.Request.Path))
{
await next(context);
return;
}
context.Response.Headers[SyncProtocol.VersionHeaderName] = SyncProtocol.CurrentVersion;
var clientVersion = context.Request.Headers[SyncProtocol.VersionHeaderName].ToString();
if (string.Equals(clientVersion, SyncProtocol.CurrentVersion, StringComparison.Ordinal))
{
await next(context);
return;
}
context.Response.StatusCode = StatusCodes.Status426UpgradeRequired;
context.Response.ContentType = "application/problem+json";
await JsonSerializer.SerializeAsync(context.Response.Body, new
{
type = "https://lehrerapp.local/problems/sync-version-mismatch",
title = "Inkompatible Sync-Version",
status = StatusCodes.Status426UpgradeRequired,
detail = $"Der Server erwartet Sync-Version {SyncProtocol.CurrentVersion}.",
expectedVersion = SyncProtocol.CurrentVersion,
receivedVersion = string.IsNullOrEmpty(clientVersion) ? null : clientVersion,
});
}
private static bool IsProtectedSyncPath(PathString path) =>
path.StartsWithSegments("/api/sync", StringComparison.OrdinalIgnoreCase) ||
path.StartsWithSegments("/api/snapshot", StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,33 @@
using System.Net;
using LehrerApp.Desktop.Services;
using LehrerApp.Sync;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SyncAuthServiceProtocolTests
{
[Fact]
public async Task TestConnectionAsync_InkompatibleVersion_WirdNichtAlsOfflineGemeldet()
{
HttpRequestMessage? sent = null;
var service = new SyncAuthService(new HttpClient(new Handler(request =>
{
sent = request;
return new HttpResponseMessage(HttpStatusCode.UpgradeRequired);
})));
var result = await service.TestConnectionAsync("https://sync.example.invalid", "token");
Assert.Equal(SyncConnectionTestResult.IncompatibleVersion, result);
Assert.Equal(SyncProtocol.CurrentVersion,
sent!.Headers.GetValues(SyncProtocol.VersionHeaderName).Single());
}
private sealed class Handler(Func<HttpRequestMessage, HttpResponseMessage> response) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(response(request));
}
}
@@ -67,6 +67,19 @@ public sealed class SyncStatusViewModelTests
Assert.False(fired);
}
[Fact]
public async Task InkompatibleSyncVersion_ZeigtKlareUpdateMeldung()
{
using var temp = new TempSyncEngine(new IncompatibleVersionStubHandler());
var vm = new SyncStatusViewModel(temp.Engine);
await temp.Engine.SyncNowAsync();
Assert.True(vm.IsIncompatibleVersion);
Assert.Equal("Update erforderlich", vm.StatusText);
Assert.Contains("Bitte aktualisiere die LehrerApp", SyncStatusViewModel.IncompatibleVersionMessage);
}
private sealed class PullEventStubHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
@@ -100,6 +113,17 @@ public sealed class SyncStatusViewModelTests
});
}
private sealed class IncompatibleVersionStubHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(HttpStatusCode.UpgradeRequired);
response.Headers.Add(SyncProtocol.VersionHeaderName, "2");
return Task.FromResult(response);
}
}
private sealed class TempSyncEngine : IDisposable
{
private readonly string _queuePath = Path.Combine(
+3 -1
View File
@@ -253,7 +253,9 @@ public static class AppBootstrapper
services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<DashboardViewModel>();
services.AddSingleton(sp =>
new SyncStatusViewModel(sp.GetService<SyncEngine>()));
new SyncStatusViewModel(
sp.GetService<SyncEngine>(),
sp.GetRequiredService<NotificationService>()));
services.AddSingleton<GroupListViewModel>();
services.AddSingleton<StudentListViewModel>();
services.AddSingleton<TimetableViewModel>();
@@ -1,11 +1,12 @@
using System.Net;
using System.Net.Http.Json;
using LehrerApp.Sync;
namespace LehrerApp.Desktop.Services;
public class SyncAuthException(string userMessage) : Exception(userMessage);
public enum SyncConnectionTestResult { Ok, Unauthorized, Unreachable }
public enum SyncConnectionTestResult { Ok, Unauthorized, IncompatibleVersion, Unreachable }
/// <summary>
/// Login/Verbindungstest gegen den eigenen Sync-Server (LehrerApp.Api) — getrennt vom bereits
@@ -27,7 +28,10 @@ public class SyncAuthService(HttpClient http)
HttpResponseMessage resp;
try
{
resp = await http.PostAsJsonAsync(CombineUrl(serverUrl, "/api/auth/login"), new { username, password });
using var req = SyncProtocol.CreateRequest(HttpMethod.Post,
CombineUrl(serverUrl, "/api/auth/login"));
req.Content = JsonContent.Create(new { username, password });
resp = await http.SendAsync(req);
}
catch (HttpRequestException)
{
@@ -49,6 +53,7 @@ public class SyncAuthService(HttpClient http)
public async Task<SyncConnectionTestResult> TestConnectionAsync(string serverUrl, string? token)
{
using var req = new HttpRequestMessage(HttpMethod.Get, CombineUrl(serverUrl, "/api/sync/status"));
req.Headers.Add(SyncProtocol.VersionHeaderName, SyncProtocol.CurrentVersion);
if (token is not null)
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
@@ -56,6 +61,8 @@ public class SyncAuthService(HttpClient http)
{
var resp = await http.SendAsync(req);
if (resp.StatusCode == HttpStatusCode.Unauthorized) return SyncConnectionTestResult.Unauthorized;
if (resp.StatusCode == HttpStatusCode.UpgradeRequired)
return SyncConnectionTestResult.IncompatibleVersion;
return resp.IsSuccessStatusCode ? SyncConnectionTestResult.Ok : SyncConnectionTestResult.Unreachable;
}
catch (HttpRequestException) { return SyncConnectionTestResult.Unreachable; }
@@ -512,6 +512,9 @@ public partial class SettingsViewModel : ObservableObject
{
SyncConnectionTestResult.Ok => "Verbindung erfolgreich.",
SyncConnectionTestResult.Unauthorized => "Server erreichbar, aber nicht angemeldet oder Anmeldung abgelaufen.",
SyncConnectionTestResult.IncompatibleVersion =>
"Diese App-Version ist veraltet oder nicht mehr mit dem Sync-Server kompatibel. " +
"Bitte aktualisiere die LehrerApp.",
_ => "Server nicht erreichbar. Bitte Adresse und Internetverbindung prüfen.",
};
}
@@ -1,5 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services;
using LehrerApp.Sync;
using LehrerApp.Sync.Models;
@@ -8,11 +9,19 @@ namespace LehrerApp.Desktop.ViewModels;
public partial class SyncStatusViewModel : ObservableObject
{
private readonly SyncEngine? _engine;
private readonly NotificationService? _notifications;
private bool _incompatibleNotificationShown;
public const string IncompatibleVersionMessage =
"Diese App-Version ist veraltet oder nicht mit dem Sync-Server kompatibel. " +
"Bitte aktualisiere die LehrerApp. Die Synchronisierung wurde zum Schutz deiner Daten angehalten.";
[ObservableProperty] private string _statusText = "Kein Server konfiguriert";
[ObservableProperty] private string _lastSyncText = "";
[ObservableProperty] private bool _isSyncing;
[ObservableProperty] private bool _isServerConfigured;
[ObservableProperty] private bool _isIncompatibleVersion;
[ObservableProperty] private bool _canAttemptSync;
[ObservableProperty] private int _pendingCount;
/// <summary>Feuert, wenn ein Sync tatsächlich Daten angewendet hat (siehe SyncEngine.
@@ -21,10 +30,12 @@ public partial class SyncStatusViewModel : ObservableObject
/// schreibt.</summary>
public event Action? DataChanged;
public SyncStatusViewModel(SyncEngine? engine)
public SyncStatusViewModel(SyncEngine? engine, NotificationService? notifications = null)
{
_engine = engine;
_notifications = notifications;
IsServerConfigured = engine is not null;
CanAttemptSync = IsServerConfigured;
if (_engine is not null)
{
_engine.StatusChanged += OnStatus;
@@ -41,6 +52,8 @@ public partial class SyncStatusViewModel : ObservableObject
private void OnStatus(SyncStatus s)
{
IsSyncing = s.State == SyncState.Syncing;
IsIncompatibleVersion = s.State == SyncState.IncompatibleVersion;
CanAttemptSync = IsServerConfigured && !IsIncompatibleVersion;
PendingCount = s.PendingEvents;
StatusText = s.State switch
{
@@ -48,9 +61,16 @@ public partial class SyncStatusViewModel : ObservableObject
SyncState.Syncing => "Synchronisiere…",
SyncState.Offline => "Offline",
SyncState.Error => $"Fehler: {s.ErrorMessage}",
SyncState.IncompatibleVersion => "Update erforderlich",
_ => "",
};
LastSyncText = s.LastSyncAt.HasValue ? $"Zuletzt: {s.LastSyncAt:HH:mm}" : "Noch nie";
if (IsIncompatibleVersion && !_incompatibleNotificationShown)
{
_incompatibleNotificationShown = true;
_notifications?.ShowError(IncompatibleVersionMessage);
}
}
[RelayCommand(CanExecute = nameof(CanSync))]
+21 -2
View File
@@ -30,8 +30,7 @@
<DrawerPage x:Name="RootDrawer"
DrawerLength="220"
DrawerBehavior="Auto"
DrawerLayoutBehavior="CompactInline"
Content="{Binding CurrentPage}">
DrawerLayoutBehavior="CompactInline">
<DrawerPage.DataTemplates>
<DataTemplate DataType="vm:DashboardViewModel">
@@ -63,6 +62,26 @@
</DataTemplate>
</DrawerPage.DataTemplates>
<DrawerPage.Content>
<DockPanel>
<Border DockPanel.Dock="Top"
IsVisible="{Binding SyncStatus.IsIncompatibleVersion}"
Background="#B3261E" Padding="16,12">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="⚠" FontSize="18" Foreground="White"
VerticalAlignment="Center"/>
<StackPanel>
<TextBlock Text="Software-Update erforderlich"
Foreground="White" FontWeight="SemiBold"/>
<TextBlock Text="{x:Static vm:SyncStatusViewModel.IncompatibleVersionMessage}"
Foreground="White" Opacity="0.92" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
</Border>
<ContentControl Content="{Binding CurrentPage}"/>
</DockPanel>
</DrawerPage.Content>
<DrawerPage.Drawer>
<DockPanel Classes.compact="{Binding !#RootDrawer.IsOpen}">
<DockPanel.Styles>
+7 -2
View File
@@ -4,14 +4,19 @@
x:Class="LehrerApp.Desktop.Views.SyncStatusBar"
x:DataType="vm:SyncStatusViewModel">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<StackPanel Grid.Column="0" IsVisible="{Binding !IsIncompatibleVersion}">
<TextBlock Text="{Binding StatusText}" FontSize="12" Opacity="0.7"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding LastSyncText}" FontSize="10" Opacity="0.4"/>
</StackPanel>
<StackPanel Grid.Column="0" IsVisible="{Binding IsIncompatibleVersion}">
<TextBlock Text="⚠ Update erforderlich" FontSize="12" FontWeight="SemiBold"
Foreground="#D32F2F"/>
<TextBlock Text="Sync angehalten" FontSize="10" Foreground="#D32F2F" Opacity="0.8"/>
</StackPanel>
<Button Grid.Column="1" Content="↻" FontSize="14"
Command="{Binding SyncNowCommand}"
IsVisible="{Binding IsServerConfigured}"
IsVisible="{Binding CanAttemptSync}"
Background="Transparent" Padding="6,4"
ToolTip.Tip="Jetzt synchronisieren"/>
</Grid>
@@ -34,6 +34,8 @@ public sealed class AttachmentSyncerTests
var request = Assert.Single(handler.Requests);
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal($"/api/sync/attachments/{storageId}", request.RequestUri!.AbsolutePath);
Assert.Equal(SyncProtocol.CurrentVersion,
request.Headers.GetValues(SyncProtocol.VersionHeaderName).Single());
Assert.NotNull(uploadedEncrypted);
Assert.Equal([1, 2, 3, 4], SyncCrypto.Decrypt(uploadedEncrypted!, Key));
Assert.Empty(temp.Queue.GetPendingAttachmentUploads());
+27
View File
@@ -13,6 +13,33 @@ public sealed class SyncEngineTests
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
private static readonly byte[] Key = SyncCrypto.GenerateKey();
[Fact]
public async Task SyncNowAsync_InkompatibleServerVersion_BrichtVorWeiterenRequestsAb()
{
using var temp = new TempEventQueue();
var pending = temp.Queue.Enqueue("this-device", DeviceType.Desktop,
"Lesson", Guid.NewGuid().ToString(), "Save", "lokale-daten");
var handler = new FakeHttpMessageHandler(req =>
{
var response = new HttpResponseMessage(HttpStatusCode.UpgradeRequired);
response.Headers.Add(SyncProtocol.VersionHeaderName, "2");
return response;
});
var engine = MakeEngine(temp, handler);
var result = await engine.SyncNowAsync();
Assert.False(result.Success);
Assert.Contains("Sync-Version 1", result.Reason);
Assert.Contains("Server Version 2", result.Reason);
Assert.Equal(SyncState.IncompatibleVersion, engine.Status.State);
Assert.Equal(pending.EventId, Assert.Single(temp.Queue.GetPending()).EventId);
var request = Assert.Single(handler.Requests);
Assert.Equal("/api/sync/push", request.RequestUri!.AbsolutePath);
Assert.Equal(SyncProtocol.CurrentVersion,
request.Headers.GetValues(SyncProtocol.VersionHeaderName).Single());
}
/// Regression: EventApplier.ApplyAsync fing früher nur LiteException ab. Jede andere Ausnahme
/// (z.B. eine ungültige/korrupte Payload eines einzelnen Ereignisses) fiel unbehandelt aus
/// SyncEngine.PullAsync heraus, BEVOR _queue.SetLastServerSeq() erreicht wurde — der nächste
+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);