diff --git a/LehrerApp.Api.Tests/SyncProtocolVersionMiddlewareTests.cs b/LehrerApp.Api.Tests/SyncProtocolVersionMiddlewareTests.cs new file mode 100644 index 0000000..870795b --- /dev/null +++ b/LehrerApp.Api.Tests/SyncProtocolVersionMiddlewareTests.cs @@ -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; + } +} diff --git a/LehrerApp.Api/Program.cs b/LehrerApp.Api/Program.cs index a718ddc..34dca9d 100644 --- a/LehrerApp.Api/Program.cs +++ b/LehrerApp.Api/Program.cs @@ -85,6 +85,8 @@ builder.Services.AddSingleton(sp => var app = builder.Build(); app.UseForwardedHeaders(); app.UseRateLimiter(); +// Muss vor Auth/Endpoints laufen: inkompatible Clients dürfen keinen Sync-Store erreichen. +app.UseMiddleware(); app.UseAuthentication(); app.UseAuthorization(); app.MapAuthEndpoints(secret); diff --git a/LehrerApp.Api/SyncProtocolVersionMiddleware.cs b/LehrerApp.Api/SyncProtocolVersionMiddleware.cs new file mode 100644 index 0000000..2f02c82 --- /dev/null +++ b/LehrerApp.Api/SyncProtocolVersionMiddleware.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using LehrerApp.Sync; + +namespace LehrerApp.Api; + +/// +/// 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. +/// +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); +} diff --git a/LehrerApp.Desktop.Tests/SyncAuthServiceProtocolTests.cs b/LehrerApp.Desktop.Tests/SyncAuthServiceProtocolTests.cs new file mode 100644 index 0000000..ef5f5cb --- /dev/null +++ b/LehrerApp.Desktop.Tests/SyncAuthServiceProtocolTests.cs @@ -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 response) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(response(request)); + } +} diff --git a/LehrerApp.Desktop.Tests/SyncStatusViewModelTests.cs b/LehrerApp.Desktop.Tests/SyncStatusViewModelTests.cs index caf940d..2e98515 100644 --- a/LehrerApp.Desktop.Tests/SyncStatusViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SyncStatusViewModelTests.cs @@ -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 SendAsync( @@ -100,6 +113,17 @@ public sealed class SyncStatusViewModelTests }); } + private sealed class IncompatibleVersionStubHandler : HttpMessageHandler + { + protected override Task 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( diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index a6c9d89..0eb89af 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -253,7 +253,9 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => - new SyncStatusViewModel(sp.GetService())); + new SyncStatusViewModel( + sp.GetService(), + sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/LehrerApp.Desktop/Services/SyncAuthService.cs b/LehrerApp.Desktop/Services/SyncAuthService.cs index af4582a..f973eeb 100644 --- a/LehrerApp.Desktop/Services/SyncAuthService.cs +++ b/LehrerApp.Desktop/Services/SyncAuthService.cs @@ -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 } /// /// 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 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; } diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 04c3e98..bfb8c2e 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -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.", }; } diff --git a/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs b/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs index d6e4528..42f3e3f 100644 --- a/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs @@ -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; /// Feuert, wenn ein Sync tatsächlich Daten angewendet hat (siehe SyncEngine. @@ -21,10 +30,12 @@ public partial class SyncStatusViewModel : ObservableObject /// schreibt. 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))] diff --git a/LehrerApp.Desktop/Views/MainWindow.axaml b/LehrerApp.Desktop/Views/MainWindow.axaml index 6369a89..3dfc8d5 100644 --- a/LehrerApp.Desktop/Views/MainWindow.axaml +++ b/LehrerApp.Desktop/Views/MainWindow.axaml @@ -30,8 +30,7 @@ + DrawerLayoutBehavior="CompactInline"> @@ -63,6 +62,26 @@ + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/SyncStatusBar.axaml b/LehrerApp.Desktop/Views/SyncStatusBar.axaml index 829f861..3a036ee 100644 --- a/LehrerApp.Desktop/Views/SyncStatusBar.axaml +++ b/LehrerApp.Desktop/Views/SyncStatusBar.axaml @@ -4,14 +4,19 @@ x:Class="LehrerApp.Desktop.Views.SyncStatusBar" x:DataType="vm:SyncStatusViewModel"> - + + + + +