feat: sync guard - Blockieren alter Clients beim sync
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,8 @@ builder.Services.AddSingleton<PlainEventStore>(sp =>
|
|||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.UseForwardedHeaders();
|
app.UseForwardedHeaders();
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
|
// Muss vor Auth/Endpoints laufen: inkompatible Clients dürfen keinen Sync-Store erreichen.
|
||||||
|
app.UseMiddleware<SyncProtocolVersionMiddleware>();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapAuthEndpoints(secret);
|
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);
|
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
|
private sealed class PullEventStubHandler : HttpMessageHandler
|
||||||
{
|
{
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
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 sealed class TempSyncEngine : IDisposable
|
||||||
{
|
{
|
||||||
private readonly string _queuePath = Path.Combine(
|
private readonly string _queuePath = Path.Combine(
|
||||||
|
|||||||
@@ -253,7 +253,9 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<MainWindowViewModel>();
|
services.AddSingleton<MainWindowViewModel>();
|
||||||
services.AddSingleton<DashboardViewModel>();
|
services.AddSingleton<DashboardViewModel>();
|
||||||
services.AddSingleton(sp =>
|
services.AddSingleton(sp =>
|
||||||
new SyncStatusViewModel(sp.GetService<SyncEngine>()));
|
new SyncStatusViewModel(
|
||||||
|
sp.GetService<SyncEngine>(),
|
||||||
|
sp.GetRequiredService<NotificationService>()));
|
||||||
services.AddSingleton<GroupListViewModel>();
|
services.AddSingleton<GroupListViewModel>();
|
||||||
services.AddSingleton<StudentListViewModel>();
|
services.AddSingleton<StudentListViewModel>();
|
||||||
services.AddSingleton<TimetableViewModel>();
|
services.AddSingleton<TimetableViewModel>();
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using LehrerApp.Sync;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Services;
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
public class SyncAuthException(string userMessage) : Exception(userMessage);
|
public class SyncAuthException(string userMessage) : Exception(userMessage);
|
||||||
|
|
||||||
public enum SyncConnectionTestResult { Ok, Unauthorized, Unreachable }
|
public enum SyncConnectionTestResult { Ok, Unauthorized, IncompatibleVersion, Unreachable }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Login/Verbindungstest gegen den eigenen Sync-Server (LehrerApp.Api) — getrennt vom bereits
|
/// Login/Verbindungstest gegen den eigenen Sync-Server (LehrerApp.Api) — getrennt vom bereits
|
||||||
@@ -27,7 +28,10 @@ public class SyncAuthService(HttpClient http)
|
|||||||
HttpResponseMessage resp;
|
HttpResponseMessage resp;
|
||||||
try
|
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)
|
catch (HttpRequestException)
|
||||||
{
|
{
|
||||||
@@ -49,6 +53,7 @@ public class SyncAuthService(HttpClient http)
|
|||||||
public async Task<SyncConnectionTestResult> TestConnectionAsync(string serverUrl, string? token)
|
public async Task<SyncConnectionTestResult> TestConnectionAsync(string serverUrl, string? token)
|
||||||
{
|
{
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Get, CombineUrl(serverUrl, "/api/sync/status"));
|
using var req = new HttpRequestMessage(HttpMethod.Get, CombineUrl(serverUrl, "/api/sync/status"));
|
||||||
|
req.Headers.Add(SyncProtocol.VersionHeaderName, SyncProtocol.CurrentVersion);
|
||||||
if (token is not null)
|
if (token is not null)
|
||||||
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
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);
|
var resp = await http.SendAsync(req);
|
||||||
if (resp.StatusCode == HttpStatusCode.Unauthorized) return SyncConnectionTestResult.Unauthorized;
|
if (resp.StatusCode == HttpStatusCode.Unauthorized) return SyncConnectionTestResult.Unauthorized;
|
||||||
|
if (resp.StatusCode == HttpStatusCode.UpgradeRequired)
|
||||||
|
return SyncConnectionTestResult.IncompatibleVersion;
|
||||||
return resp.IsSuccessStatusCode ? SyncConnectionTestResult.Ok : SyncConnectionTestResult.Unreachable;
|
return resp.IsSuccessStatusCode ? SyncConnectionTestResult.Ok : SyncConnectionTestResult.Unreachable;
|
||||||
}
|
}
|
||||||
catch (HttpRequestException) { return SyncConnectionTestResult.Unreachable; }
|
catch (HttpRequestException) { return SyncConnectionTestResult.Unreachable; }
|
||||||
|
|||||||
@@ -512,6 +512,9 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
SyncConnectionTestResult.Ok => "Verbindung erfolgreich.",
|
SyncConnectionTestResult.Ok => "Verbindung erfolgreich.",
|
||||||
SyncConnectionTestResult.Unauthorized => "Server erreichbar, aber nicht angemeldet oder Anmeldung abgelaufen.",
|
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.",
|
_ => "Server nicht erreichbar. Bitte Adresse und Internetverbindung prüfen.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Sync;
|
using LehrerApp.Sync;
|
||||||
using LehrerApp.Sync.Models;
|
using LehrerApp.Sync.Models;
|
||||||
|
|
||||||
@@ -8,11 +9,19 @@ namespace LehrerApp.Desktop.ViewModels;
|
|||||||
public partial class SyncStatusViewModel : ObservableObject
|
public partial class SyncStatusViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly SyncEngine? _engine;
|
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 _statusText = "Kein Server konfiguriert";
|
||||||
[ObservableProperty] private string _lastSyncText = "";
|
[ObservableProperty] private string _lastSyncText = "";
|
||||||
[ObservableProperty] private bool _isSyncing;
|
[ObservableProperty] private bool _isSyncing;
|
||||||
[ObservableProperty] private bool _isServerConfigured;
|
[ObservableProperty] private bool _isServerConfigured;
|
||||||
|
[ObservableProperty] private bool _isIncompatibleVersion;
|
||||||
|
[ObservableProperty] private bool _canAttemptSync;
|
||||||
[ObservableProperty] private int _pendingCount;
|
[ObservableProperty] private int _pendingCount;
|
||||||
|
|
||||||
/// <summary>Feuert, wenn ein Sync tatsächlich Daten angewendet hat (siehe SyncEngine.
|
/// <summary>Feuert, wenn ein Sync tatsächlich Daten angewendet hat (siehe SyncEngine.
|
||||||
@@ -21,10 +30,12 @@ public partial class SyncStatusViewModel : ObservableObject
|
|||||||
/// schreibt.</summary>
|
/// schreibt.</summary>
|
||||||
public event Action? DataChanged;
|
public event Action? DataChanged;
|
||||||
|
|
||||||
public SyncStatusViewModel(SyncEngine? engine)
|
public SyncStatusViewModel(SyncEngine? engine, NotificationService? notifications = null)
|
||||||
{
|
{
|
||||||
_engine = engine;
|
_engine = engine;
|
||||||
|
_notifications = notifications;
|
||||||
IsServerConfigured = engine is not null;
|
IsServerConfigured = engine is not null;
|
||||||
|
CanAttemptSync = IsServerConfigured;
|
||||||
if (_engine is not null)
|
if (_engine is not null)
|
||||||
{
|
{
|
||||||
_engine.StatusChanged += OnStatus;
|
_engine.StatusChanged += OnStatus;
|
||||||
@@ -41,6 +52,8 @@ public partial class SyncStatusViewModel : ObservableObject
|
|||||||
private void OnStatus(SyncStatus s)
|
private void OnStatus(SyncStatus s)
|
||||||
{
|
{
|
||||||
IsSyncing = s.State == SyncState.Syncing;
|
IsSyncing = s.State == SyncState.Syncing;
|
||||||
|
IsIncompatibleVersion = s.State == SyncState.IncompatibleVersion;
|
||||||
|
CanAttemptSync = IsServerConfigured && !IsIncompatibleVersion;
|
||||||
PendingCount = s.PendingEvents;
|
PendingCount = s.PendingEvents;
|
||||||
StatusText = s.State switch
|
StatusText = s.State switch
|
||||||
{
|
{
|
||||||
@@ -48,9 +61,16 @@ public partial class SyncStatusViewModel : ObservableObject
|
|||||||
SyncState.Syncing => "Synchronisiere…",
|
SyncState.Syncing => "Synchronisiere…",
|
||||||
SyncState.Offline => "Offline",
|
SyncState.Offline => "Offline",
|
||||||
SyncState.Error => $"Fehler: {s.ErrorMessage}",
|
SyncState.Error => $"Fehler: {s.ErrorMessage}",
|
||||||
|
SyncState.IncompatibleVersion => "Update erforderlich",
|
||||||
_ => "",
|
_ => "",
|
||||||
};
|
};
|
||||||
LastSyncText = s.LastSyncAt.HasValue ? $"Zuletzt: {s.LastSyncAt:HH:mm}" : "Noch nie";
|
LastSyncText = s.LastSyncAt.HasValue ? $"Zuletzt: {s.LastSyncAt:HH:mm}" : "Noch nie";
|
||||||
|
|
||||||
|
if (IsIncompatibleVersion && !_incompatibleNotificationShown)
|
||||||
|
{
|
||||||
|
_incompatibleNotificationShown = true;
|
||||||
|
_notifications?.ShowError(IncompatibleVersionMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanSync))]
|
[RelayCommand(CanExecute = nameof(CanSync))]
|
||||||
|
|||||||
@@ -30,8 +30,7 @@
|
|||||||
<DrawerPage x:Name="RootDrawer"
|
<DrawerPage x:Name="RootDrawer"
|
||||||
DrawerLength="220"
|
DrawerLength="220"
|
||||||
DrawerBehavior="Auto"
|
DrawerBehavior="Auto"
|
||||||
DrawerLayoutBehavior="CompactInline"
|
DrawerLayoutBehavior="CompactInline">
|
||||||
Content="{Binding CurrentPage}">
|
|
||||||
|
|
||||||
<DrawerPage.DataTemplates>
|
<DrawerPage.DataTemplates>
|
||||||
<DataTemplate DataType="vm:DashboardViewModel">
|
<DataTemplate DataType="vm:DashboardViewModel">
|
||||||
@@ -63,6 +62,26 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</DrawerPage.DataTemplates>
|
</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>
|
<DrawerPage.Drawer>
|
||||||
<DockPanel Classes.compact="{Binding !#RootDrawer.IsOpen}">
|
<DockPanel Classes.compact="{Binding !#RootDrawer.IsOpen}">
|
||||||
<DockPanel.Styles>
|
<DockPanel.Styles>
|
||||||
|
|||||||
@@ -4,14 +4,19 @@
|
|||||||
x:Class="LehrerApp.Desktop.Views.SyncStatusBar"
|
x:Class="LehrerApp.Desktop.Views.SyncStatusBar"
|
||||||
x:DataType="vm:SyncStatusViewModel">
|
x:DataType="vm:SyncStatusViewModel">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel Grid.Column="0">
|
<StackPanel Grid.Column="0" IsVisible="{Binding !IsIncompatibleVersion}">
|
||||||
<TextBlock Text="{Binding StatusText}" FontSize="12" Opacity="0.7"
|
<TextBlock Text="{Binding StatusText}" FontSize="12" Opacity="0.7"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
<TextBlock Text="{Binding LastSyncText}" FontSize="10" Opacity="0.4"/>
|
<TextBlock Text="{Binding LastSyncText}" FontSize="10" Opacity="0.4"/>
|
||||||
</StackPanel>
|
</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"
|
<Button Grid.Column="1" Content="↻" FontSize="14"
|
||||||
Command="{Binding SyncNowCommand}"
|
Command="{Binding SyncNowCommand}"
|
||||||
IsVisible="{Binding IsServerConfigured}"
|
IsVisible="{Binding CanAttemptSync}"
|
||||||
Background="Transparent" Padding="6,4"
|
Background="Transparent" Padding="6,4"
|
||||||
ToolTip.Tip="Jetzt synchronisieren"/>
|
ToolTip.Tip="Jetzt synchronisieren"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ public sealed class AttachmentSyncerTests
|
|||||||
var request = Assert.Single(handler.Requests);
|
var request = Assert.Single(handler.Requests);
|
||||||
Assert.Equal(HttpMethod.Post, request.Method);
|
Assert.Equal(HttpMethod.Post, request.Method);
|
||||||
Assert.Equal($"/api/sync/attachments/{storageId}", request.RequestUri!.AbsolutePath);
|
Assert.Equal($"/api/sync/attachments/{storageId}", request.RequestUri!.AbsolutePath);
|
||||||
|
Assert.Equal(SyncProtocol.CurrentVersion,
|
||||||
|
request.Headers.GetValues(SyncProtocol.VersionHeaderName).Single());
|
||||||
Assert.NotNull(uploadedEncrypted);
|
Assert.NotNull(uploadedEncrypted);
|
||||||
Assert.Equal([1, 2, 3, 4], SyncCrypto.Decrypt(uploadedEncrypted!, Key));
|
Assert.Equal([1, 2, 3, 4], SyncCrypto.Decrypt(uploadedEncrypted!, Key));
|
||||||
Assert.Empty(temp.Queue.GetPendingAttachmentUploads());
|
Assert.Empty(temp.Queue.GetPendingAttachmentUploads());
|
||||||
|
|||||||
@@ -13,6 +13,33 @@ public sealed class SyncEngineTests
|
|||||||
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
||||||
private static readonly byte[] Key = SyncCrypto.GenerateKey();
|
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
|
/// 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
|
/// (z.B. eine ungültige/korrupte Payload eines einzelnen Ereignisses) fiel unbehandelt aus
|
||||||
/// SyncEngine.PullAsync heraus, BEVOR _queue.SetLastServerSeq() erreicht wurde — der nächste
|
/// SyncEngine.PullAsync heraus, BEVOR _queue.SetLastServerSeq() erreicht wurde — der nächste
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ public class AttachmentSyncer(LiteDbContext db, HttpClient http, byte[] syncKey)
|
|||||||
await raw.CopyToAsync(buffer);
|
await raw.CopyToAsync(buffer);
|
||||||
var encrypted = SyncCrypto.Encrypt(buffer.ToArray(), syncKey);
|
var encrypted = SyncCrypto.Encrypt(buffer.ToArray(), syncKey);
|
||||||
|
|
||||||
using var content = new ByteArrayContent(encrypted);
|
using var request = SyncProtocol.CreateRequest(HttpMethod.Post,
|
||||||
var resp = await http.PostAsync($"/api/sync/attachments/{storageId}", content);
|
$"/api/sync/attachments/{storageId}");
|
||||||
resp.EnsureSuccessStatusCode();
|
request.Content = new ByteArrayContent(encrypted);
|
||||||
|
using var resp = await http.SendAsync(request);
|
||||||
|
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
|
||||||
queue.MarkAttachmentUploaded(storageId);
|
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);
|
versions?.SetKnownServerSeq(evt.EntityType, evt.EntityId, evt.SequenceNr);
|
||||||
logger?.Info($"Sync: Ereignis angewendet - {evt.EntityType} {evt.Operation} EntityId={evt.EntityId}");
|
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)
|
catch (LiteException ex)
|
||||||
{
|
{
|
||||||
// Harte Constraint-Verletzung (z.B. Unique-Index) - dieses eine Ereignis
|
// 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)
|
foreach (var attachment in entity.Attachments)
|
||||||
{
|
{
|
||||||
if (db.Attachments.Exists(attachment.StorageId)) continue;
|
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;
|
if (!resp.IsSuccessStatusCode) continue;
|
||||||
var encrypted = await resp.Content.ReadAsByteArrayAsync();
|
var encrypted = await resp.Content.ReadAsByteArrayAsync();
|
||||||
var decrypted = SyncCrypto.Decrypt(encrypted, syncKey);
|
var decrypted = SyncCrypto.Decrypt(encrypted, syncKey);
|
||||||
|
|||||||
@@ -114,4 +114,4 @@ public class SyncStatus
|
|||||||
// ── Enums ─────────────────────────────────────────────────────────────────────
|
// ── Enums ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public enum DeviceType { Desktop, Companion }
|
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
|
// Schritt 1: Upload ohne Key → Code erhalten
|
||||||
Report(SnapshotStep.Uploading, "Code wird angefordert…");
|
Report(SnapshotStep.Uploading, "Code wird angefordert…");
|
||||||
var r1 = await http.PostAsJsonAsync("/api/snapshot/upload",
|
using var request1 = SyncProtocol.CreateRequest(HttpMethod.Post, "/api/snapshot/upload");
|
||||||
new SnapshotUploadRequest { EncryptedPayload = encPayload, DeviceType = deviceType }, ct);
|
request1.Content = JsonContent.Create(
|
||||||
r1.EnsureSuccessStatusCode();
|
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)
|
var init = await r1.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
|
||||||
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||||
|
|
||||||
// Schritt 2: Key mit Code verschlüsseln + erneut hochladen
|
// Schritt 2: Key mit Code verschlüsseln + erneut hochladen
|
||||||
Report(SnapshotStep.Uploading, "Schlüssel wird verschlüsselt…");
|
Report(SnapshotStep.Uploading, "Schlüssel wird verschlüsselt…");
|
||||||
var encKey = SyncCrypto.EncryptKeyWithCode(syncKey, init.Code);
|
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,
|
new SnapshotUploadRequest { EncryptedPayload = encPayload,
|
||||||
EncryptedSyncKey = encKey, DeviceType = deviceType,
|
EncryptedSyncKey = encKey, DeviceType = deviceType,
|
||||||
Code = init.Code }, ct);
|
Code = init.Code });
|
||||||
r2.EnsureSuccessStatusCode();
|
using var r2 = await http.SendAsync(request2, ct);
|
||||||
|
await SyncProtocol.EnsureCompatibleSuccessAsync(r2);
|
||||||
var result = await r2.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
|
var result = await r2.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
|
||||||
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||||
Report(SnapshotStep.Done, $"Bereit – Code: {result.Code}");
|
Report(SnapshotStep.Done, $"Bereit – Code: {result.Code}");
|
||||||
@@ -53,10 +57,11 @@ public class SnapshotService(
|
|||||||
{
|
{
|
||||||
var sanitized = code.Trim().ToUpperInvariant();
|
var sanitized = code.Trim().ToUpperInvariant();
|
||||||
Report(SnapshotStep.Downloading, "Snapshot wird geladen…");
|
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)
|
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
throw new SnapshotNotFoundException($"Code '{sanitized}' nicht gefunden oder abgelaufen.");
|
throw new SnapshotNotFoundException($"Code '{sanitized}' nicht gefunden oder abgelaufen.");
|
||||||
resp.EnsureSuccessStatusCode();
|
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
|
||||||
var dl = await resp.Content.ReadFromJsonAsync<SnapshotDownloadResponse>(ct)
|
var dl = await resp.Content.ReadFromJsonAsync<SnapshotDownloadResponse>(ct)
|
||||||
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ public class SyncEngine : IDisposable
|
|||||||
$"{pulled} gepullt ({conflicts} Pull-Konflikte)");
|
$"{pulled} gepullt ({conflicts} Pull-Konflikte)");
|
||||||
return new() { Success = true, EventsPushed = pushed, EventsPulled = pulled, Conflicts = conflicts };
|
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)
|
catch (HttpRequestException ex)
|
||||||
{
|
{
|
||||||
// Sammelt sowohl echte Netzwerkfehler als auch nicht-erfolgreiche HTTP-Antworten
|
// 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);
|
evt.BasedOnServerSeq = _queue.GetKnownServerSeq(evt.EntityType, evt.EntityId);
|
||||||
_logger?.Info($"Sync: Push - {pending.Count} Ereignis(se) ausstehend: " +
|
_logger?.Info($"Sync: Push - {pending.Count} Ereignis(se) ausstehend: " +
|
||||||
string.Join(", ", pending.Select(e => $"{e.EntityType}/{e.Operation}")));
|
string.Join(", ", pending.Select(e => $"{e.EntityType}/{e.Operation}")));
|
||||||
var resp = await _http.PostAsJsonAsync("/api/sync/push", pending);
|
using var request = SyncProtocol.CreateRequest(HttpMethod.Post, "/api/sync/push");
|
||||||
resp.EnsureSuccessStatusCode();
|
request.Content = JsonContent.Create(pending);
|
||||||
|
using var resp = await _http.SendAsync(request);
|
||||||
|
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
|
||||||
var result = await resp.Content.ReadFromJsonAsync<PushResponse>();
|
var result = await resp.Content.ReadFromJsonAsync<PushResponse>();
|
||||||
if (result is null) { _logger?.Warn("Sync: Push - leere Server-Antwort."); return (0, 0); }
|
if (result is null) { _logger?.Warn("Sync: Push - leere Server-Antwort."); return (0, 0); }
|
||||||
_queue.Acknowledge(pending
|
_queue.Acknowledge(pending
|
||||||
@@ -196,7 +204,9 @@ public class SyncEngine : IDisposable
|
|||||||
HttpResponseMessage resp;
|
HttpResponseMessage resp;
|
||||||
try
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -217,7 +227,7 @@ public class SyncEngine : IDisposable
|
|||||||
"nächster Push behandelt sie als neu.");
|
"nächster Push behandelt sie als neu.");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
resp.EnsureSuccessStatusCode();
|
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
|
||||||
var remote = await resp.Content.ReadFromJsonAsync<SyncEvent>();
|
var remote = await resp.Content.ReadFromJsonAsync<SyncEvent>();
|
||||||
if (remote is null)
|
if (remote is null)
|
||||||
{
|
{
|
||||||
@@ -253,8 +263,11 @@ public class SyncEngine : IDisposable
|
|||||||
{
|
{
|
||||||
var since = _queue.GetLastServerSeq();
|
var since = _queue.GetLastServerSeq();
|
||||||
_logger?.Info($"Sync: Pull - frage Server nach Ereignissen seit ServerSequenceNr={since}.");
|
_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}");
|
$"/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)
|
if (resp is null || resp.Events.Count == 0)
|
||||||
{
|
{
|
||||||
_logger?.Info("Sync: Pull - keine neuen Ereignisse vom Server.");
|
_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