diff --git a/src/Turnierplan.App/Constants/TurnierplanVersion.cs b/src/Turnierplan.App/Constants/TurnierplanVersion.cs index baac625e..8ed87dc7 100644 --- a/src/Turnierplan.App/Constants/TurnierplanVersion.cs +++ b/src/Turnierplan.App/Constants/TurnierplanVersion.cs @@ -1,13 +1,46 @@ +using System.Text.RegularExpressions; + namespace Turnierplan.App.Constants; -internal static class TurnierplanVersion +internal static partial class TurnierplanVersion { - public static readonly string Version = DetermineVersion(); + public static readonly bool IsVersionAvailable; + + public static readonly string Version; + + public static readonly int Major; + public static readonly int Minor; + public static readonly int Patch; - private static string DetermineVersion() + static TurnierplanVersion() { var assemblyVersion = typeof(TurnierplanVersion).Assembly.GetName().Version?.ToString(); - return assemblyVersion?[..assemblyVersion.LastIndexOf('.')] ?? "?.?.?"; + if (assemblyVersion is null) + { + IsVersionAvailable = false; + Version = "?.?.?"; + + return; + } + + var match = VersionRegex().Match(assemblyVersion); + + if (!match.Success) + { + IsVersionAvailable = false; + Version = "?.?.?"; + + return; + } + + IsVersionAvailable = true; + Major = int.Parse(match.Groups["Major"].Value); + Minor = int.Parse(match.Groups["Minor"].Value); + Patch = int.Parse(match.Groups["Patch"].Value); + Version = $"{Major}.{Minor}.{Patch}"; } + + [GeneratedRegex(@"^(?\d+)\.(?\d+)\.(?\d+)\.\d+$")] + private static partial Regex VersionRegex(); } diff --git a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs index eb1693ec..e652ec13 100644 --- a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using Npgsql; +using Turnierplan.App.Constants; using Turnierplan.App.Options; using Turnierplan.Core.User; using Turnierplan.Dal; @@ -24,22 +26,84 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) if (context.Database.IsNpgsql()) { - // If the database is in-memory, no migration is necessary + // If the database is in-memory, no migration or downgrade check is necessary await context.Database.MigrateAsync(); + await EnsureNoDowngradeAsync(context, logger); } + await EnsureInitialUserCreatedAsync(scope.ServiceProvider, context, logger); + } + + private static async Task EnsureNoDowngradeAsync(TurnierplanContext context, ILogger logger) + { + const string schema = TurnierplanContext.Schema; + + if (!TurnierplanVersion.IsVersionAvailable) + { + throw new InvalidOperationException("Downgrade check failed because the current version is not available."); + } + + /* The version check works by creating a special table which stores all turnierplan.NET versions that have ever + * been run on this database along with the corresponding timestamp. Then, we try to insert a new row with the + * current version - doing nothing if a row for that specific version already exists. Finally, we query the row + * with the most recent version. If that version does not match the version we are currently running, a version + * downgrade has occurred, and we stop the application from continuing execution. */ + + // The transaction is necessary because otherwise, we will save invalid + // version history entries to the database in the case of a version downgrade. + await context.Database.BeginTransactionAsync(); + + var versionParameter = new NpgsqlParameter("version", TurnierplanVersion.Version); + var majorParameter = new NpgsqlParameter("major", TurnierplanVersion.Major); + var minorParameter = new NpgsqlParameter("minor", TurnierplanVersion.Minor); + var patchParameter = new NpgsqlParameter("patch", TurnierplanVersion.Patch); + + await context.Database.ExecuteSqlRawAsync($""" +CREATE TABLE IF NOT EXISTS {schema}."__TPVersionHistory" ( + "Version" text NOT NULL UNIQUE, + "Major" integer NOT NULL, + "Minor" integer NOT NULL, + "Patch" integer NOT NULL, + "Timestamp" timestamp with time zone NOT NULL +); + +INSERT INTO {schema}."__TPVersionHistory" ("Version", "Major", "Minor", "Patch", "Timestamp") + VALUES (@version, @major, @minor, @patch, now()) + ON CONFLICT DO NOTHING; +""", versionParameter, majorParameter, minorParameter, patchParameter); + + var mostRecentVersion = await context.Database.SqlQueryRaw($"SELECT * FROM {schema}.\"__TPVersionHistory\"") + .OrderByDescending(x => x.Major) + .ThenByDescending(x => x.Minor) + .ThenByDescending(x => x.Patch) + .FirstAsync(); + + if (!mostRecentVersion.Version.Equals(TurnierplanVersion.Version)) + { + logger.LogCritical("Detected version downgrade from '{MostRecentVersion}' to '{CurrentVersion}'.", mostRecentVersion.Version, TurnierplanVersion.Version); + Environment.Exit(1); + + return; + } + + // Commit only after we know that the current version is "valid" + await context.Database.CommitTransactionAsync(); + } + + private static async Task EnsureInitialUserCreatedAsync(IServiceProvider serviceProvider, TurnierplanContext context, ILogger logger) + { var userCount = await context.Users.CountAsync(); if (userCount == 0) { - var options = scope.ServiceProvider.GetRequiredService>().Value; + var options = serviceProvider.GetRequiredService>().Value; var overwriteInitialUserPassword = !string.IsNullOrWhiteSpace(options.InitialUserPassword); var initialUserName = string.IsNullOrWhiteSpace(options.InitialUserName) ? "admin" : options.InitialUserName; var initialUserPassword = overwriteInitialUserPassword ? options.InitialUserPassword! : Guid.NewGuid().ToString(); - var passwordHasher = scope.ServiceProvider.GetRequiredService>(); + var passwordHasher = serviceProvider.GetRequiredService>(); var initialUser = new User(initialUserName) { @@ -69,4 +133,6 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) } private sealed record DatabaseMigrator; + + private sealed record VersionHistory(string Version, int Major, int Minor, int Patch); }