From 0ad5b974b0055b8797fe01b6bca8653dd5130975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 5 Oct 2025 11:29:18 +0200 Subject: [PATCH 1/8] wip --- .../Extensions/WebApplicationExtensions.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs index eb1693ec..12c1484c 100644 --- a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs @@ -22,6 +22,8 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) var logger = scope.ServiceProvider.GetRequiredService>(); var context = scope.ServiceProvider.GetRequiredService(); + await EnsureNoDowngradeAsync(context); + if (context.Database.IsNpgsql()) { // If the database is in-memory, no migration is necessary @@ -68,5 +70,12 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) } } + private static async Task EnsureNoDowngradeAsync(TurnierplanContext context) + { + var database = context.Database; + + // timestamp with time zone + } + private sealed record DatabaseMigrator; } From bc0d5353a057c88bc03e0ef0d5472306db368149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 5 Oct 2025 12:21:52 +0200 Subject: [PATCH 2/8] Add --- .../Constants/TurnierplanVersion.cs | 41 ++++++++++-- .../Extensions/WebApplicationExtensions.cs | 66 +++++++++++++++---- 2 files changed, 92 insertions(+), 15 deletions(-) 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 12c1484c..958a5751 100644 --- a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using Turnierplan.App.Constants; using Turnierplan.App.Options; using Turnierplan.Core.User; using Turnierplan.Dal; @@ -22,26 +23,76 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) var logger = scope.ServiceProvider.GetRequiredService>(); var context = scope.ServiceProvider.GetRequiredService(); - await EnsureNoDowngradeAsync(context); - if (context.Database.IsNpgsql()) { // If the database is in-memory, no migration is necessary await context.Database.MigrateAsync(); } + await EnsureNoDowngradeAsync(logger, context); + await EnsureInitialUserCreatedAsync(scope.ServiceProvider, logger, context); + } + + private static async Task EnsureNoDowngradeAsync(ILogger logger, TurnierplanContext context) + { + const string schema = TurnierplanContext.Schema; + + if (!TurnierplanVersion.IsVersionAvailable) + { + throw new InvalidOperationException("Downgrade check failed because the current version is not available."); + } + + await context.Database.BeginTransactionAsync(); + + /* 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. */ + +#pragma warning disable EF1002 + 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 ('{TurnierplanVersion.Version}', {TurnierplanVersion.Major}, {TurnierplanVersion.Minor}, {TurnierplanVersion.Patch}, now()) + ON CONFLICT DO NOTHING; +"""); +#pragma warning restore EF1002 + + var mostRecentVersion = await context.Database.SqlQueryRaw($""" +SELECT "Version" AS "Value" FROM {schema}."__TPVersionHistory" ORDER BY "Major" DESC, "Minor" DESC, "Patch" DESC +""").FirstAsync(); + + if (!mostRecentVersion.Equals(TurnierplanVersion.Version)) + { + logger.LogCritical("Detected version downgrade from '{MostRecentVersion}' to '{CurrentVersion}'.", mostRecentVersion, TurnierplanVersion.Version); + Environment.Exit(1); + } + + await context.Database.CommitTransactionAsync(); + } + + private static async Task EnsureInitialUserCreatedAsync(IServiceProvider serviceProvider, ILogger logger, TurnierplanContext context) + { 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) { @@ -70,12 +121,5 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) } } - private static async Task EnsureNoDowngradeAsync(TurnierplanContext context) - { - var database = context.Database; - - // timestamp with time zone - } - private sealed record DatabaseMigrator; } From 5d1042e13fe53403c447d9b9ddfd6f69c291045e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 6 Oct 2025 18:09:11 +0200 Subject: [PATCH 3/8] Fix in-memory case --- src/Turnierplan.App/Extensions/WebApplicationExtensions.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs index 958a5751..c7f30885 100644 --- a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs @@ -25,11 +25,13 @@ 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(logger, context); } - await EnsureNoDowngradeAsync(logger, context); await EnsureInitialUserCreatedAsync(scope.ServiceProvider, logger, context); } From bfd73d38faed595a7026f4e45e5257d48610d944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 6 Oct 2025 18:24:27 +0200 Subject: [PATCH 4/8] Imrpvoedes DB stuff --- .../Extensions/WebApplicationExtensions.cs | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs index c7f30885..a5c7f8e4 100644 --- a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs @@ -1,6 +1,7 @@ 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; @@ -52,7 +53,11 @@ private static async Task EnsureNoDowngradeAsync(ILogger logge * 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. */ -#pragma warning disable EF1002 + 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, @@ -63,18 +68,20 @@ await context.Database.ExecuteSqlRawAsync($""" ); INSERT INTO {schema}."__TPVersionHistory" ("Version", "Major", "Minor", "Patch", "Timestamp") - VALUES ('{TurnierplanVersion.Version}', {TurnierplanVersion.Major}, {TurnierplanVersion.Minor}, {TurnierplanVersion.Patch}, now()) + VALUES (@version, @major, @minor, @patch, now()) ON CONFLICT DO NOTHING; -"""); -#pragma warning restore EF1002 +""", versionParameter, majorParameter, minorParameter, patchParameter); - var mostRecentVersion = await context.Database.SqlQueryRaw($""" -SELECT "Version" AS "Value" FROM {schema}."__TPVersionHistory" ORDER BY "Major" DESC, "Minor" DESC, "Patch" DESC -""").FirstAsync(); + 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.Equals(TurnierplanVersion.Version)) + if (!mostRecentVersion.Version.Equals(TurnierplanVersion.Version)) { - logger.LogCritical("Detected version downgrade from '{MostRecentVersion}' to '{CurrentVersion}'.", mostRecentVersion, TurnierplanVersion.Version); + logger.LogCritical("Detected version downgrade from '{MostRecentVersion}' to '{CurrentVersion}'.", mostRecentVersion.Version, TurnierplanVersion.Version); Environment.Exit(1); } @@ -124,4 +131,6 @@ private static async Task EnsureInitialUserCreatedAsync(IServiceProvider service } private sealed record DatabaseMigrator; + + private sealed record VersionHistory(string Version, int Major, int Minor, int Patch); } From 9bd2b56f428971aba62c22cba02604325c5c221a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 6 Oct 2025 18:38:36 +0200 Subject: [PATCH 5/8] Comments --- .../Extensions/WebApplicationExtensions.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs index a5c7f8e4..9a022478 100644 --- a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs @@ -45,14 +45,16 @@ private static async Task EnsureNoDowngradeAsync(ILogger logge throw new InvalidOperationException("Downgrade check failed because the current version is not available."); } - await context.Database.BeginTransactionAsync(); - /* 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); @@ -83,8 +85,11 @@ await context.Database.ExecuteSqlRawAsync($""" { 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(); } From 8891d6858438b7e1a11c8fe188c5f4c1d28c3e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 6 Oct 2025 18:42:23 +0200 Subject: [PATCH 6/8] format --- src/Turnierplan.App/Extensions/WebApplicationExtensions.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs index 9a022478..ea326f53 100644 --- a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs @@ -27,9 +27,7 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) if (context.Database.IsNpgsql()) { // If the database is in-memory, no migration or downgrade check is necessary - await context.Database.MigrateAsync(); - await EnsureNoDowngradeAsync(logger, context); } From 271107817582f037154b8b5838a6e8c56ed933f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 6 Oct 2025 18:43:46 +0200 Subject: [PATCH 7/8] , --- src/Turnierplan.App/Extensions/WebApplicationExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs index ea326f53..ec864c88 100644 --- a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs @@ -46,7 +46,7 @@ private static async Task EnsureNoDowngradeAsync(ILogger logge /* 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 + * 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 From 4b4ce68143141a901d8faa83e5fabeb148fd6ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 6 Oct 2025 19:05:27 +0200 Subject: [PATCH 8/8] some formatting --- .../Extensions/WebApplicationExtensions.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs index ec864c88..e652ec13 100644 --- a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs @@ -28,13 +28,13 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) { // If the database is in-memory, no migration or downgrade check is necessary await context.Database.MigrateAsync(); - await EnsureNoDowngradeAsync(logger, context); + await EnsureNoDowngradeAsync(context, logger); } - await EnsureInitialUserCreatedAsync(scope.ServiceProvider, logger, context); + await EnsureInitialUserCreatedAsync(scope.ServiceProvider, context, logger); } - private static async Task EnsureNoDowngradeAsync(ILogger logger, TurnierplanContext context) + private static async Task EnsureNoDowngradeAsync(TurnierplanContext context, ILogger logger) { const string schema = TurnierplanContext.Schema; @@ -72,8 +72,7 @@ await context.Database.ExecuteSqlRawAsync($""" ON CONFLICT DO NOTHING; """, versionParameter, majorParameter, minorParameter, patchParameter); - var mostRecentVersion = await context.Database.SqlQueryRaw( - $"SELECT * FROM {schema}.\"__TPVersionHistory\"") + var mostRecentVersion = await context.Database.SqlQueryRaw($"SELECT * FROM {schema}.\"__TPVersionHistory\"") .OrderByDescending(x => x.Major) .ThenByDescending(x => x.Minor) .ThenByDescending(x => x.Patch) @@ -91,7 +90,7 @@ await context.Database.ExecuteSqlRawAsync($""" await context.Database.CommitTransactionAsync(); } - private static async Task EnsureInitialUserCreatedAsync(IServiceProvider serviceProvider, ILogger logger, TurnierplanContext context) + private static async Task EnsureInitialUserCreatedAsync(IServiceProvider serviceProvider, TurnierplanContext context, ILogger logger) { var userCount = await context.Users.CountAsync();