diff --git a/src/Schuly.API/AppVersion/AppVersionGate.cs b/src/Schuly.API/AppVersion/AppVersionGate.cs new file mode 100644 index 00000000..14f0461b --- /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; + 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.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..4c3d599c 100644 --- a/src/Schuly.Application/Queries/App/AppQuery.cs +++ b/src/Schuly.Application/Queries/App/AppQuery.cs @@ -19,9 +19,19 @@ public class AppQueryHandler(IConfiguration configuration) : IQueryHandler + /// 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) { - 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..7e69716c --- /dev/null +++ b/src/Schuly.Tests/MinAppVersionTests.cs @@ -0,0 +1,55 @@ +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, AppQueryHandler.DefaultMinAppVersion)] + [Arguments("", AppQueryHandler.DefaultMinAppVersion)] + [Arguments("latest", AppQueryHandler.DefaultMinAppVersion)] + 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 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); + } +}