Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/Schuly.API/AppVersion/AppVersionGate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Schuly.Application.Queries.App;

namespace Schuly.API.AppVersion
{
/// <summary>
/// Answers 426 to a mobile app older than <c>App:MinVersion</c>, so it shows its "please update" screen instead of failing on API changes.
/// Only requests that name their version (<see cref="Header"/>) are checked: the web app and apps from before the header pass. <c>/api/app</c>
/// stays open so an old app can still read the minimum.
/// </summary>
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);
}

/// <summary>The minimum version when the request comes from an app below it, else null.</summary>
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;
}
}
}
2 changes: 2 additions & 0 deletions src/Schuly.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@

app.UseExceptionHandler();

app.UseMiddleware<Schuly.API.AppVersion.AppVersionGate>();

if (app.Configuration.SwitzerlandOnly())
app.UseMiddleware<SwissSignUpGate>();

Expand Down
2 changes: 1 addition & 1 deletion src/Schuly.Application/Dtos/AppDto.cs
Original file line number Diff line number Diff line change
@@ -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);
}
12 changes: 11 additions & 1 deletion src/Schuly.Application/Queries/App/AppQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,19 @@ public class AppQueryHandler(IConfiguration configuration) : IQueryHandler<AppQu
?.Split('+')[0]
?? "0.0.0";

/// <summary>
/// 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 <c>App:MinVersion</c> (env <c>App__MinVersion</c>, "0.0.0" turns it off).
/// </summary>
public const string DefaultMinAppVersion = "3.5.0";

/// <summary><c>App:MinVersion</c> when it is a version, else <see cref="DefaultMinAppVersion"/>.</summary>
public static string MinAppVersion(IConfiguration configuration) =>
System.Version.TryParse(configuration["App:MinVersion"]?.Trim(), out var min) ? min.ToString() : DefaultMinAppVersion;

public async ValueTask<Result<AppDto>> Handle(AppQuery query, CancellationToken cancellationToken)
{
return Result<AppDto>.Success(new AppDto(Version, configuration.GetValue("LocalLogin:AllowSelfRegistration", false), PrivacyNotice.Version(configuration), PrivacyNotice.Url(configuration), PrivacyNotice.SwissSchoolConfirmationRequired(configuration)));
return Result<AppDto>.Success(new AppDto(Version, configuration.GetValue("LocalLogin:AllowSelfRegistration", false), PrivacyNotice.Version(configuration), PrivacyNotice.Url(configuration), PrivacyNotice.SwissSchoolConfirmationRequired(configuration), MinAppVersion(configuration)));
}
}
}
55 changes: 55 additions & 0 deletions src/Schuly.Tests/MinAppVersionTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string?> { ["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<string, string?> { ["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);
}
}
Loading