From d9888f57dcf158251c71ae521ec34b40e146d333 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:52:58 +0100 Subject: [PATCH 1/2] Tell too-old apps to update --- src/Schuly.API/AppVersion/AppVersionGate.cs | 38 ++++++++++++++ src/Schuly.API/Program.cs | 2 + src/Schuly.Application/Dtos/AppDto.cs | 2 +- .../Queries/App/AppQuery.cs | 6 ++- src/Schuly.Tests/MinAppVersionTests.cs | 52 +++++++++++++++++++ 5 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 src/Schuly.API/AppVersion/AppVersionGate.cs create mode 100644 src/Schuly.Tests/MinAppVersionTests.cs diff --git a/src/Schuly.API/AppVersion/AppVersionGate.cs b/src/Schuly.API/AppVersion/AppVersionGate.cs new file mode 100644 index 00000000..33d471cd --- /dev/null +++ b/src/Schuly.API/AppVersion/AppVersionGate.cs @@ -0,0 +1,38 @@ +using Schuly.Application.Queries.App; + +namespace Schuly.API.AppVersion +{ + /// + /// Answers 426 to a mobile app older than App:MinVersion, so it shows its "please update" screen instead of failing on API changes. + /// Only requests that name their version () are checked: the web app and apps from before the header pass. /api/app + /// stays open so an old app can still read the minimum. + /// + public sealed class AppVersionGate(RequestDelegate next, IConfiguration configuration) + { + public const string Header = "X-Schuly-App-Version"; + public const string Code = "app_update_required"; + + public async Task InvokeAsync(HttpContext context) + { + if (IsTooOld(context.Request, configuration) is { } min) + { + context.Response.StatusCode = StatusCodes.Status426UpgradeRequired; + await context.Response.WriteAsJsonAsync(new { code = Code, minAppVersion = min, message = "This version of Schuly is too old. Update the app to continue." }); + return; + } + await next(context); + } + + /// The minimum version when the request comes from an app below it, else null. + public static string? IsTooOld(HttpRequest request, IConfiguration configuration) + { + var path = request.Path; + if (!(path.StartsWithSegments("/api") || path.StartsWithSegments("/auth")) || path.StartsWithSegments("/api/app", StringComparison.OrdinalIgnoreCase) && !path.StartsWithSegments("/api/app/school-systems")) + return null; + if (AppQueryHandler.MinAppVersion(configuration) is not { } min) return null; + // Pre-release or build suffixes ("3.6.0-beta+12") are cut off; an unreadable version is let through. + var sent = request.Headers[Header].ToString().Split('-', '+')[0].Trim(); + return System.Version.TryParse(sent, out var app) && app < System.Version.Parse(min) ? min : null; + } + } +} diff --git a/src/Schuly.API/Program.cs b/src/Schuly.API/Program.cs index 8399a341..910d2d14 100644 --- a/src/Schuly.API/Program.cs +++ b/src/Schuly.API/Program.cs @@ -80,6 +80,8 @@ app.UseExceptionHandler(); +app.UseMiddleware(); + if (app.Configuration.SwitzerlandOnly()) app.UseMiddleware(); diff --git a/src/Schuly.Application/Dtos/AppDto.cs b/src/Schuly.Application/Dtos/AppDto.cs index 57d7da01..9f756fbc 100644 --- a/src/Schuly.Application/Dtos/AppDto.cs +++ b/src/Schuly.Application/Dtos/AppDto.cs @@ -1,4 +1,4 @@ namespace Schuly.Application.Dtos { - public record AppDto(string Version, bool RegistrationEnabled, string PolicyVersion, string PolicyUrl, bool SwissSchoolConfirmationRequired); + public record AppDto(string Version, bool RegistrationEnabled, string PolicyVersion, string PolicyUrl, bool SwissSchoolConfirmationRequired, string? MinAppVersion = null); } diff --git a/src/Schuly.Application/Queries/App/AppQuery.cs b/src/Schuly.Application/Queries/App/AppQuery.cs index 5a39e14a..f7b67955 100644 --- a/src/Schuly.Application/Queries/App/AppQuery.cs +++ b/src/Schuly.Application/Queries/App/AppQuery.cs @@ -19,9 +19,13 @@ public class AppQueryHandler(IConfiguration configuration) : IQueryHandlerThe oldest mobile app that still works with this API (App:MinVersion); older apps ask the user to update. Unset or not a version: none. + public static string? MinAppVersion(IConfiguration configuration) => + System.Version.TryParse(configuration["App:MinVersion"]?.Trim(), out var min) ? min.ToString() : null; + public async ValueTask> Handle(AppQuery query, CancellationToken cancellationToken) { - return Result.Success(new AppDto(Version, configuration.GetValue("LocalLogin:AllowSelfRegistration", false), PrivacyNotice.Version(configuration), PrivacyNotice.Url(configuration), PrivacyNotice.SwissSchoolConfirmationRequired(configuration))); + return Result.Success(new AppDto(Version, configuration.GetValue("LocalLogin:AllowSelfRegistration", false), PrivacyNotice.Version(configuration), PrivacyNotice.Url(configuration), PrivacyNotice.SwissSchoolConfirmationRequired(configuration), MinAppVersion(configuration))); } } } diff --git a/src/Schuly.Tests/MinAppVersionTests.cs b/src/Schuly.Tests/MinAppVersionTests.cs new file mode 100644 index 00000000..fc612e23 --- /dev/null +++ b/src/Schuly.Tests/MinAppVersionTests.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Configuration; +using Schuly.Application.Queries.App; + +namespace Schuly.Tests +{ + public class MinAppVersionTests + { + private static string? Min(string? value) => + AppQueryHandler.MinAppVersion(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["App:MinVersion"] = value }).Build()); + + [Test] + [Arguments("3.6.0", "3.6.0")] + [Arguments(" 3.6.0 ", "3.6.0")] + [Arguments(null, null)] + [Arguments("", null)] + [Arguments("latest", null)] + public async Task ReadsOnlyARealVersion(string? configured, string? expected) => await Assert.That(Min(configured)).IsEqualTo(expected); + } +} + +namespace Schuly.Tests +{ + using Microsoft.AspNetCore.Http; + using Schuly.API.AppVersion; + + public class AppVersionGateTests + { + private static string? Gate(string path, string? sent, string? min = "3.6.0") + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["App:MinVersion"] = min }).Build(); + var context = new DefaultHttpContext(); + context.Request.Path = path; + if (sent is not null) context.Request.Headers[AppVersionGate.Header] = sent; + return AppVersionGate.IsTooOld(context.Request, config); + } + + [Test] + [Arguments("/api/grades", "3.5.0", "3.6.0")] + [Arguments("/auth/login", "3.5.9", "3.6.0")] + [Arguments("/api/app/school-systems", "3.5.0", "3.6.0")] + [Arguments("/api/grades", "3.6.0", null)] + [Arguments("/api/grades", "3.7.0-beta+3", null)] + [Arguments("/api/grades", null, null)] + [Arguments("/api/grades", "garbage", null)] + [Arguments("/api/app", "1.0.0", null)] + [Arguments("/index.html", "1.0.0", null)] + public async Task BlocksOnlyOlderApps(string path, string? sent, string? expected) => await Assert.That(Gate(path, sent)).IsEqualTo(expected); + + [Test] + public async Task NoMinimumBlocksNothing() => await Assert.That(Gate("/api/grades", "0.1.0", min: null)).IsNull(); + } +} From c4cdef28d62ad3987a872fe3f38d62c54c04c66e Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:54:52 +0100 Subject: [PATCH 2/2] Fall back to a minimum app version set in code --- src/Schuly.API/AppVersion/AppVersionGate.cs | 2 +- src/Schuly.Application/Queries/App/AppQuery.cs | 12 +++++++++--- src/Schuly.Tests/MinAppVersionTests.cs | 11 +++++++---- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/Schuly.API/AppVersion/AppVersionGate.cs b/src/Schuly.API/AppVersion/AppVersionGate.cs index 33d471cd..14f0461b 100644 --- a/src/Schuly.API/AppVersion/AppVersionGate.cs +++ b/src/Schuly.API/AppVersion/AppVersionGate.cs @@ -29,7 +29,7 @@ public async Task InvokeAsync(HttpContext context) var path = request.Path; if (!(path.StartsWithSegments("/api") || path.StartsWithSegments("/auth")) || path.StartsWithSegments("/api/app", StringComparison.OrdinalIgnoreCase) && !path.StartsWithSegments("/api/app/school-systems")) return null; - if (AppQueryHandler.MinAppVersion(configuration) is not { } min) return null; + var min = AppQueryHandler.MinAppVersion(configuration); // Pre-release or build suffixes ("3.6.0-beta+12") are cut off; an unreadable version is let through. var sent = request.Headers[Header].ToString().Split('-', '+')[0].Trim(); return System.Version.TryParse(sent, out var app) && app < System.Version.Parse(min) ? min : null; diff --git a/src/Schuly.Application/Queries/App/AppQuery.cs b/src/Schuly.Application/Queries/App/AppQuery.cs index f7b67955..4c3d599c 100644 --- a/src/Schuly.Application/Queries/App/AppQuery.cs +++ b/src/Schuly.Application/Queries/App/AppQuery.cs @@ -19,9 +19,15 @@ public class AppQueryHandler(IConfiguration configuration) : IQueryHandlerThe oldest mobile app that still works with this API (App:MinVersion); older apps ask the user to update. Unset or not a version: none. - public static string? MinAppVersion(IConfiguration configuration) => - System.Version.TryParse(configuration["App:MinVersion"]?.Trim(), out var min) ? min.ToString() : null; + /// + /// The oldest mobile app that still works with this API. Raise it in the same change that breaks older apps; they then show + /// "please update". An operator can override it with App:MinVersion (env App__MinVersion, "0.0.0" turns it off). + /// + public const string DefaultMinAppVersion = "3.5.0"; + + /// App:MinVersion when it is a version, else . + public static string MinAppVersion(IConfiguration configuration) => + System.Version.TryParse(configuration["App:MinVersion"]?.Trim(), out var min) ? min.ToString() : DefaultMinAppVersion; public async ValueTask> Handle(AppQuery query, CancellationToken cancellationToken) { diff --git a/src/Schuly.Tests/MinAppVersionTests.cs b/src/Schuly.Tests/MinAppVersionTests.cs index fc612e23..7e69716c 100644 --- a/src/Schuly.Tests/MinAppVersionTests.cs +++ b/src/Schuly.Tests/MinAppVersionTests.cs @@ -11,9 +11,9 @@ public class MinAppVersionTests [Test] [Arguments("3.6.0", "3.6.0")] [Arguments(" 3.6.0 ", "3.6.0")] - [Arguments(null, null)] - [Arguments("", null)] - [Arguments("latest", null)] + [Arguments(null, AppQueryHandler.DefaultMinAppVersion)] + [Arguments("", AppQueryHandler.DefaultMinAppVersion)] + [Arguments("latest", AppQueryHandler.DefaultMinAppVersion)] public async Task ReadsOnlyARealVersion(string? configured, string? expected) => await Assert.That(Min(configured)).IsEqualTo(expected); } } @@ -47,6 +47,9 @@ public class AppVersionGateTests public async Task BlocksOnlyOlderApps(string path, string? sent, string? expected) => await Assert.That(Gate(path, sent)).IsEqualTo(expected); [Test] - public async Task NoMinimumBlocksNothing() => await Assert.That(Gate("/api/grades", "0.1.0", min: null)).IsNull(); + public async Task ZeroBlocksNothing() => await Assert.That(Gate("/api/grades", "0.1.0", min: "0.0.0")).IsNull(); + + [Test] + public async Task UnsetUsesTheCodedMinimum() => await Assert.That(Gate("/api/grades", "3.4.0", min: null)).IsEqualTo(AppQueryHandler.DefaultMinAppVersion); } }