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);
+ }
+}