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
41 changes: 37 additions & 4 deletions src/Turnierplan.App/Constants/TurnierplanVersion.cs
Original file line number Diff line number Diff line change
@@ -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(@"^(?<Major>\d+)\.(?<Minor>\d+)\.(?<Patch>\d+)\.\d+$")]
private static partial Regex VersionRegex();
}
72 changes: 69 additions & 3 deletions src/Turnierplan.App/Extensions/WebApplicationExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<DatabaseMigrator> 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<VersionHistory>($"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<DatabaseMigrator> logger)
{
var userCount = await context.Users.CountAsync();

if (userCount == 0)
{
var options = scope.ServiceProvider.GetRequiredService<IOptions<TurnierplanOptions>>().Value;
var options = serviceProvider.GetRequiredService<IOptions<TurnierplanOptions>>().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<IPasswordHasher<User>>();
var passwordHasher = serviceProvider.GetRequiredService<IPasswordHasher<User>>();

var initialUser = new User(initialUserName)
{
Expand Down Expand Up @@ -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);
}
Loading