Add support for migrations that need the database context

This commit is contained in:
jvyden 2022-06-10 02:21:01 -04:00
parent 6d0673fcb8
commit 64f65ba574
No known key found for this signature in database
GPG key ID: 18BCF2BE0262B278
15 changed files with 656 additions and 481 deletions

View file

@ -3,11 +3,13 @@
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes>
<Path>.config/dotnet-tools.json</Path>
<Path>.github</Path>
<Path>.gitignore</Path>
<Path>.idea</Path>
<Path>CONTRIBUTING.md</Path>
<Path>DatabaseMigrations</Path>
<Path>LICENSE</Path>
<Path>ProjectLighthouse.sln.DotSettings</Path>
<Path>ProjectLighthouse.sln.DotSettings.user</Path>
<Path>README.md</Path>

View file

@ -0,0 +1,25 @@
using System;
using System.ComponentModel.DataAnnotations;
using LBPUnion.ProjectLighthouse.Administration.Maintenance;
namespace LBPUnion.ProjectLighthouse.Administration;
/// <summary>
/// A record of the completion of a <see cref="IMigrationTask"/>.
/// </summary>
public class CompletedMigration
{
/// <summary>
/// The name of the migration.
/// </summary>
/// <remarks>
/// Do not use the user-friendly name when setting this.
/// </remarks>
[Key]
public string MigrationName { get; set; }
/// <summary>
/// The moment the migration was ran.
/// </summary>
public DateTime RanAt { get; set; }
}

View file

@ -1,7 +1,9 @@
using System.Threading.Tasks;
using JetBrains.Annotations;
namespace LBPUnion.ProjectLighthouse.Administration.Maintenance;
[UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)]
public interface IMaintenanceJob
{
public Task Run();

View file

@ -0,0 +1,20 @@
using System.Threading.Tasks;
using JetBrains.Annotations;
namespace LBPUnion.ProjectLighthouse.Administration.Maintenance;
[UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)]
public interface IMigrationTask
{
/// <summary>
/// The user-friendly name of a migration.
/// </summary>
public string Name();
/// <summary>
/// Performs the migration.
/// </summary>
/// <param name="database">The Lighthouse database.</param>
/// <returns>True if successful, false if not.</returns>
internal Task<bool> Run(Database database);
}

View file

@ -1,9 +1,11 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Logging;
using LBPUnion.ProjectLighthouse.Logging.Loggers;
@ -11,24 +13,16 @@ namespace LBPUnion.ProjectLighthouse.Administration.Maintenance;
public static class MaintenanceHelper
{
static MaintenanceHelper()
{
Commands = getListOfInterfaceObjects<ICommand>();
MaintenanceJobs = getListOfInterfaceObjects<IMaintenanceJob>();
MigrationTasks = getListOfInterfaceObjects<IMigrationTask>();
}
public static List<ICommand> Commands { get; }
public static List<IMaintenanceJob> MaintenanceJobs { get; }
private static List<T> getListOfInterfaceObjects<T>() where T : class
{
return Assembly.GetExecutingAssembly()
.GetTypes()
.Where(t => t.GetInterfaces().Contains(typeof(T)) && t.GetConstructor(Type.EmptyTypes) != null)
.Select(t => Activator.CreateInstance(t) as T)
.ToList()!;
}
public static List<IMigrationTask> MigrationTasks { get; }
public static async Task<List<LogLine>> RunCommand(string[] args)
{
@ -66,11 +60,57 @@ public static class MaintenanceHelper
IMaintenanceJob? job = MaintenanceJobs.FirstOrDefault(j => j.GetType().Name == jobName);
if (job == null) throw new ArgumentNullException();
await RunMaintenanceJob(job);
}
public static async Task RunMaintenanceJob(IMaintenanceJob job)
{
await job.Run();
}
public static async Task RunMigration(IMigrationTask migrationTask, Database? database = null)
{
database ??= new Database();
// Migrations should never be run twice.
Debug.Assert(!await database.CompletedMigrations.Has(m => m.MigrationName == migrationTask.GetType().Name));
Logger.Info($"Running migration task {migrationTask.Name()}", LogArea.Database);
bool success;
Exception? exception = null;
try
{
success = await migrationTask.Run(database);
}
catch(Exception e)
{
success = false;
exception = e;
}
if(!success)
{
Logger.Error($"Could not run migration {migrationTask.Name()}", LogArea.Database);
if (exception != null) Logger.Error(exception.ToDetailedException(), LogArea.Database);
return;
}
Logger.Success($"Successfully completed migration {migrationTask.Name()}", LogArea.Database);
CompletedMigration completedMigration = new()
{
MigrationName = migrationTask.GetType().Name,
RanAt = DateTime.Now,
};
database.CompletedMigrations.Add(completedMigration);
await database.SaveChangesAsync();
}
private static List<T> getListOfInterfaceObjects<T>() where T : class
{
return Assembly.GetExecutingAssembly()
.GetTypes()
.Where(t => t.GetInterfaces().Contains(typeof(T)) && t.GetConstructor(Type.EmptyTypes) != null)
.Select(t => Activator.CreateInstance(t) as T)
.ToList()!;
}
}

View file

@ -25,7 +25,7 @@ public class CleanupBrokenPhotosMaintenanceJob : IMaintenanceJob
bool largeHashIsInvalidFile = false;
bool tooManyPhotoSubjects = false;
bool duplicatePhotoSubjects = false;
bool takenInTheFuture = true;
bool takenInTheFuture = false;
// Checks should generally be ordered in least computationally expensive to most.

View file

@ -1,36 +0,0 @@
#nullable enable
using System.Threading.Tasks;
using LBPUnion.ProjectLighthouse.Administration.Reports;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.Levels;
using LBPUnion.ProjectLighthouse.PlayerData;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
using LBPUnion.ProjectLighthouse.PlayerData.Reviews;
namespace LBPUnion.ProjectLighthouse.Administration.Maintenance.MaintenanceJobs;
public class CleanupXmlInjection : IMaintenanceJob
{
private readonly Database database = new();
public string Name() => "Sanitize user content";
public string Description() => "Sanitizes all user-generated strings in levels, reviews, comments, users, and scores to prevent XML injection. Only needs to be run once.";
public async Task Run()
{
foreach (Slot slot in this.database.Slots) SanitizationHelper.SanitizeStringsInClass(slot);
foreach (Review review in this.database.Reviews) SanitizationHelper.SanitizeStringsInClass(review);
foreach (Comment comment in this.database.Comments) SanitizationHelper.SanitizeStringsInClass(comment);
foreach (Score score in this.database.Scores) SanitizationHelper.SanitizeStringsInClass(score);
foreach (User user in this.database.Users) SanitizationHelper.SanitizeStringsInClass(user);
foreach (Photo photo in this.database.Photos) SanitizationHelper.SanitizeStringsInClass(photo);
foreach (GriefReport report in this.database.Reports) SanitizationHelper.SanitizeStringsInClass(report);
await this.database.SaveChangesAsync();
}
}

View file

@ -0,0 +1,31 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using LBPUnion.ProjectLighthouse.Helpers;
namespace LBPUnion.ProjectLighthouse.Administration.Maintenance.MigrationTasks;
public class CleanupXmlInjectionMigration : IMigrationTask
{
public string Name() => "Cleanup XML injections";
// Weird, but required. Thanks, hejlsberg.
async Task<bool> IMigrationTask.Run(Database database)
{
List<object> objsToBeSanitized = new();
// Store all the objects we need to sanitize in a list.
// The alternative here is to loop through every table, but thats a ton of code...
objsToBeSanitized.AddRange(database.Slots);
objsToBeSanitized.AddRange(database.Reviews);
objsToBeSanitized.AddRange(database.Comments);
objsToBeSanitized.AddRange(database.Scores);
objsToBeSanitized.AddRange(database.Users);
objsToBeSanitized.AddRange(database.Photos);
objsToBeSanitized.AddRange(database.Reports);
foreach (object obj in objsToBeSanitized) SanitizationHelper.SanitizeStringsInClass(obj);
await database.SaveChangesAsync();
return true;
}
}

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using LBPUnion.ProjectLighthouse.Administration;
using LBPUnion.ProjectLighthouse.Administration.Reports;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Helpers;
@ -21,6 +22,7 @@ namespace LBPUnion.ProjectLighthouse;
public class Database : DbContext
{
public DbSet<CompletedMigration> CompletedMigrations { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<Location> Locations { get; set; }
public DbSet<Slot> Slots { get; set; }

View file

@ -1,4 +1,7 @@
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using LBPUnion.ProjectLighthouse.Levels;
using LBPUnion.ProjectLighthouse.PlayerData;
using LBPUnion.ProjectLighthouse.PlayerData.Reviews;
@ -52,4 +55,7 @@ public static class DatabaseExtensions
return query;
}
public static async Task<bool> Has<T>(this IQueryable<T> queryable, Expression<Func<T, bool>> predicate) =>
await queryable.FirstOrDefaultAsync(predicate) != null;
}

View file

@ -0,0 +1,37 @@
using System;
using LBPUnion.ProjectLighthouse;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ProjectLighthouse.Migrations
{
[DbContext(typeof(Database))]
[Migration("20220610061641_AddCompletedMigrations")]
public class AddCompletedMigrations : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CompletedMigrations",
columns: table => new
{
MigrationName = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
RanAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CompletedMigrations", x => x.MigrationName);
})
.Annotation("MySql:CharSet", "utf8mb4");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CompletedMigrations");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,10 @@
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Compile Remove="Administration\Maintenance\MaintenanceJobs\CleanupXMLInjection.cs" />
</ItemGroup>
<Target Name="PreBuild" BeforeTargets="PreBuildEvent">
<Exec Command="git describe --long --always --dirty --exclude=\* --abbrev=8 &gt; &quot;$(ProjectDir)/gitVersion.txt&quot;" />
<Exec Command="git branch --show-current &gt; &quot;$(ProjectDir)/gitBranch.txt&quot;" />

View file

@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using LBPUnion.ProjectLighthouse.Administration;
using LBPUnion.ProjectLighthouse.Administration.Maintenance;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Extensions;
@ -112,14 +114,35 @@ public static class StartupTasks
Logger.Success($"Ready! Startup took {stopwatch.ElapsedMilliseconds}ms. Passing off control to ASP.NET...", LogArea.Startup);
}
private static void migrateDatabase(DbContext database)
private static void migrateDatabase(Database database)
{
Stopwatch totalStopwatch = new();
Stopwatch stopwatch = new();
totalStopwatch.Start();
stopwatch.Start();
database.Database.MigrateAsync().Wait();
stopwatch.Stop();
Logger.Success($"Structure migration took {stopwatch.ElapsedMilliseconds}ms.", LogArea.Database);
stopwatch.Reset();
stopwatch.Start();
List<CompletedMigration> completedMigrations = database.CompletedMigrations.ToList();
List<IMigrationTask> migrationsToRun = MaintenanceHelper.MigrationTasks
.Where(migrationTask => !completedMigrations
.Select(m => m.MigrationName)
.Contains(migrationTask.GetType().Name)
).ToList();
foreach (IMigrationTask migrationTask in migrationsToRun)
{
MaintenanceHelper.RunMigration(migrationTask, database).Wait();
}
stopwatch.Stop();
Logger.Success($"Migration took {stopwatch.ElapsedMilliseconds}ms.", LogArea.Database);
totalStopwatch.Stop();
Logger.Success($"Extra migration tasks took {stopwatch.ElapsedMilliseconds}ms.", LogArea.Database);
Logger.Success($"Total migration took {totalStopwatch.ElapsedMilliseconds}ms.", LogArea.Database);
}
}

View file

@ -1,4 +1,9 @@
#!/bin/bash
# Developer script to create EntityFramework database migrations
#
# $1: Name of the migration, e.g. SwitchToPermissionLevels
# Invoked manually
export LIGHTHOUSE_DB_CONNECTION_STRING='server=127.0.0.1;uid=root;pwd=lighthouse;database=lighthouse'
dotnet ef migrations add "$1" --project ProjectLighthouse