mirror of
https://github.com/LBPUnion/ProjectLighthouse.git
synced 2025-05-18 15:42:26 +00:00
Add support for migrations that need the database context
This commit is contained in:
parent
6d0673fcb8
commit
64f65ba574
15 changed files with 656 additions and 481 deletions
|
@ -3,11 +3,13 @@
|
||||||
<component name="UserContentModel">
|
<component name="UserContentModel">
|
||||||
<attachedFolders />
|
<attachedFolders />
|
||||||
<explicitIncludes>
|
<explicitIncludes>
|
||||||
|
<Path>.config/dotnet-tools.json</Path>
|
||||||
<Path>.github</Path>
|
<Path>.github</Path>
|
||||||
<Path>.gitignore</Path>
|
<Path>.gitignore</Path>
|
||||||
<Path>.idea</Path>
|
<Path>.idea</Path>
|
||||||
<Path>CONTRIBUTING.md</Path>
|
<Path>CONTRIBUTING.md</Path>
|
||||||
<Path>DatabaseMigrations</Path>
|
<Path>DatabaseMigrations</Path>
|
||||||
|
<Path>LICENSE</Path>
|
||||||
<Path>ProjectLighthouse.sln.DotSettings</Path>
|
<Path>ProjectLighthouse.sln.DotSettings</Path>
|
||||||
<Path>ProjectLighthouse.sln.DotSettings.user</Path>
|
<Path>ProjectLighthouse.sln.DotSettings.user</Path>
|
||||||
<Path>README.md</Path>
|
<Path>README.md</Path>
|
||||||
|
|
25
ProjectLighthouse/Administration/CompletedMigration.cs
Normal file
25
ProjectLighthouse/Administration/CompletedMigration.cs
Normal 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; }
|
||||||
|
}
|
|
@ -1,7 +1,9 @@
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using JetBrains.Annotations;
|
||||||
|
|
||||||
namespace LBPUnion.ProjectLighthouse.Administration.Maintenance;
|
namespace LBPUnion.ProjectLighthouse.Administration.Maintenance;
|
||||||
|
|
||||||
|
[UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)]
|
||||||
public interface IMaintenanceJob
|
public interface IMaintenanceJob
|
||||||
{
|
{
|
||||||
public Task Run();
|
public Task Run();
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -1,9 +1,11 @@
|
||||||
#nullable enable
|
#nullable enable
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using LBPUnion.ProjectLighthouse.Extensions;
|
||||||
using LBPUnion.ProjectLighthouse.Logging;
|
using LBPUnion.ProjectLighthouse.Logging;
|
||||||
using LBPUnion.ProjectLighthouse.Logging.Loggers;
|
using LBPUnion.ProjectLighthouse.Logging.Loggers;
|
||||||
|
|
||||||
|
@ -11,24 +13,16 @@ namespace LBPUnion.ProjectLighthouse.Administration.Maintenance;
|
||||||
|
|
||||||
public static class MaintenanceHelper
|
public static class MaintenanceHelper
|
||||||
{
|
{
|
||||||
|
|
||||||
static MaintenanceHelper()
|
static MaintenanceHelper()
|
||||||
{
|
{
|
||||||
Commands = getListOfInterfaceObjects<ICommand>();
|
Commands = getListOfInterfaceObjects<ICommand>();
|
||||||
MaintenanceJobs = getListOfInterfaceObjects<IMaintenanceJob>();
|
MaintenanceJobs = getListOfInterfaceObjects<IMaintenanceJob>();
|
||||||
|
MigrationTasks = getListOfInterfaceObjects<IMigrationTask>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<ICommand> Commands { get; }
|
public static List<ICommand> Commands { get; }
|
||||||
|
|
||||||
public static List<IMaintenanceJob> MaintenanceJobs { get; }
|
public static List<IMaintenanceJob> MaintenanceJobs { get; }
|
||||||
|
public static List<IMigrationTask> MigrationTasks { 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 async Task<List<LogLine>> RunCommand(string[] args)
|
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);
|
IMaintenanceJob? job = MaintenanceJobs.FirstOrDefault(j => j.GetType().Name == jobName);
|
||||||
if (job == null) throw new ArgumentNullException();
|
if (job == null) throw new ArgumentNullException();
|
||||||
|
|
||||||
await RunMaintenanceJob(job);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task RunMaintenanceJob(IMaintenanceJob job)
|
|
||||||
{
|
|
||||||
await job.Run();
|
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()!;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -25,7 +25,7 @@ public class CleanupBrokenPhotosMaintenanceJob : IMaintenanceJob
|
||||||
bool largeHashIsInvalidFile = false;
|
bool largeHashIsInvalidFile = false;
|
||||||
bool tooManyPhotoSubjects = false;
|
bool tooManyPhotoSubjects = false;
|
||||||
bool duplicatePhotoSubjects = false;
|
bool duplicatePhotoSubjects = false;
|
||||||
bool takenInTheFuture = true;
|
bool takenInTheFuture = false;
|
||||||
|
|
||||||
// Checks should generally be ordered in least computationally expensive to most.
|
// Checks should generally be ordered in least computationally expensive to most.
|
||||||
|
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using LBPUnion.ProjectLighthouse.Administration;
|
||||||
using LBPUnion.ProjectLighthouse.Administration.Reports;
|
using LBPUnion.ProjectLighthouse.Administration.Reports;
|
||||||
using LBPUnion.ProjectLighthouse.Configuration;
|
using LBPUnion.ProjectLighthouse.Configuration;
|
||||||
using LBPUnion.ProjectLighthouse.Helpers;
|
using LBPUnion.ProjectLighthouse.Helpers;
|
||||||
|
@ -21,6 +22,7 @@ namespace LBPUnion.ProjectLighthouse;
|
||||||
|
|
||||||
public class Database : DbContext
|
public class Database : DbContext
|
||||||
{
|
{
|
||||||
|
public DbSet<CompletedMigration> CompletedMigrations { get; set; }
|
||||||
public DbSet<User> Users { get; set; }
|
public DbSet<User> Users { get; set; }
|
||||||
public DbSet<Location> Locations { get; set; }
|
public DbSet<Location> Locations { get; set; }
|
||||||
public DbSet<Slot> Slots { get; set; }
|
public DbSet<Slot> Slots { get; set; }
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using LBPUnion.ProjectLighthouse.Levels;
|
using LBPUnion.ProjectLighthouse.Levels;
|
||||||
using LBPUnion.ProjectLighthouse.PlayerData;
|
using LBPUnion.ProjectLighthouse.PlayerData;
|
||||||
using LBPUnion.ProjectLighthouse.PlayerData.Reviews;
|
using LBPUnion.ProjectLighthouse.PlayerData.Reviews;
|
||||||
|
@ -52,4 +55,7 @@ public static class DatabaseExtensions
|
||||||
|
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task<bool> Has<T>(this IQueryable<T> queryable, Expression<Func<T, bool>> predicate) =>
|
||||||
|
await queryable.FirstOrDefaultAsync(predicate) != null;
|
||||||
}
|
}
|
|
@ -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
|
@ -50,6 +50,10 @@
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="Administration\Maintenance\MaintenanceJobs\CleanupXMLInjection.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<Target Name="PreBuild" BeforeTargets="PreBuildEvent">
|
<Target Name="PreBuild" BeforeTargets="PreBuildEvent">
|
||||||
<Exec Command="git describe --long --always --dirty --exclude=\* --abbrev=8 > "$(ProjectDir)/gitVersion.txt"" />
|
<Exec Command="git describe --long --always --dirty --exclude=\* --abbrev=8 > "$(ProjectDir)/gitVersion.txt"" />
|
||||||
<Exec Command="git branch --show-current > "$(ProjectDir)/gitBranch.txt"" />
|
<Exec Command="git branch --show-current > "$(ProjectDir)/gitBranch.txt"" />
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using LBPUnion.ProjectLighthouse.Administration;
|
||||||
using LBPUnion.ProjectLighthouse.Administration.Maintenance;
|
using LBPUnion.ProjectLighthouse.Administration.Maintenance;
|
||||||
using LBPUnion.ProjectLighthouse.Configuration;
|
using LBPUnion.ProjectLighthouse.Configuration;
|
||||||
using LBPUnion.ProjectLighthouse.Extensions;
|
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);
|
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();
|
Stopwatch stopwatch = new();
|
||||||
|
totalStopwatch.Start();
|
||||||
stopwatch.Start();
|
stopwatch.Start();
|
||||||
|
|
||||||
database.Database.MigrateAsync().Wait();
|
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();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,4 +1,9 @@
|
||||||
#!/bin/bash
|
#!/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'
|
export LIGHTHOUSE_DB_CONNECTION_STRING='server=127.0.0.1;uid=root;pwd=lighthouse;database=lighthouse'
|
||||||
dotnet ef migrations add "$1" --project ProjectLighthouse
|
dotnet ef migrations add "$1" --project ProjectLighthouse
|
Loading…
Add table
Add a link
Reference in a new issue