diff --git a/src/Dapr/AdminPanel.Host/Program.cs b/src/Dapr/AdminPanel.Host/Program.cs index 6035940b37..5c18f9dfc7 100644 --- a/src/Dapr/AdminPanel.Host/Program.cs +++ b/src/Dapr/AdminPanel.Host/Program.cs @@ -7,6 +7,7 @@ using MUnique.OpenMU.AdminPanel.Host; using MUnique.OpenMU.Dapr.Common; using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; using MUnique.OpenMU.PlugIns; using MUnique.OpenMU.ServerClients; using MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; @@ -25,6 +26,7 @@ .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddAdminUserRepository(); builder.AddAdminPanel(); diff --git a/src/Dapr/Common/Extensions.cs b/src/Dapr/Common/Extensions.cs index efae0cb1a5..88b2890e8f 100644 --- a/src/Dapr/Common/Extensions.cs +++ b/src/Dapr/Common/Extensions.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -14,7 +14,9 @@ namespace MUnique.OpenMU.Dapr.Common; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Network; using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Persistence.AdminAuth; using MUnique.OpenMU.Persistence.EntityFramework; +using MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; using MUnique.OpenMU.PlugIns; using Nito.AsyncEx.Synchronous; using OpenTelemetry.Exporter; @@ -53,7 +55,12 @@ public static IServiceCollection AddPeristenceProvider(this IServiceCollection s .AddSingleton() .AddSingleton(s => (PersistenceContextProvider)s.GetService()!) .AddSingleton(s => (IPersistenceContextProvider)s.GetService()!) - .AddSingleton(s => new Lazy(s.GetRequiredService)); + .AddSingleton(s => new Lazy(s.GetRequiredService)) + .AddAdminUserRepository() + .AddSingleton(s => new BackupService( + s.GetRequiredService(), + s.GetRequiredService())) + .AddSingleton(); } /// diff --git a/src/GameServer/GameServer.cs b/src/GameServer/GameServer.cs index 72b9c3cd23..e8902f86fb 100644 --- a/src/GameServer/GameServer.cs +++ b/src/GameServer/GameServer.cs @@ -27,7 +27,7 @@ namespace MUnique.OpenMU.GameServer; /// /// The game server to which game clients can connect. /// -public sealed class GameServer : IGameServer, IDisposable, IGameServerContextProvider, IConnectionSource +public sealed class GameServer : IGameServer, IDisposable, IAsyncDisposable, IGameServerContextProvider, IConnectionSource { private readonly ILogger _logger; @@ -458,6 +458,22 @@ public void Dispose() (this._gameContext as IDisposable)?.Dispose(); } + /// + /// + /// In contrast to , this also stops the periodic tasks of the game context. + /// + public async ValueTask DisposeAsync() + { + if (this._gameContext is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else + { + this.Dispose(); + } + } + private async ValueTask RemovePlayerFromGuildAsync(Player player, bool unregisterFromContext = true) { if (unregisterFromContext && player.GuildStatus?.GuildId is not null) diff --git a/src/Persistence/AdminAuth/AdminUser.cs b/src/Persistence/AdminAuth/AdminUser.cs index 1a2f9c0f25..ad2bc9007f 100644 --- a/src/Persistence/AdminAuth/AdminUser.cs +++ b/src/Persistence/AdminAuth/AdminUser.cs @@ -15,7 +15,7 @@ namespace MUnique.OpenMU.Persistence.AdminAuth; /// Additionally, the admin panel must be usable before the game database has been initialized, /// which wouldn't be possible if the credentials were stored in the game data schema. /// -public class AdminUser +public class AdminUser : IIdentifiable { /// /// Gets or sets the identifier of this user. diff --git a/src/Persistence/BackupOptions.cs b/src/Persistence/BackupOptions.cs new file mode 100644 index 0000000000..6bb6a53f7a --- /dev/null +++ b/src/Persistence/BackupOptions.cs @@ -0,0 +1,23 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence; + +/// +/// Options which define what a backup contains. +/// +public sealed record BackupOptions +{ + /// + /// Gets the default options, which include everything. + /// + public static BackupOptions Default { get; } = new(); + + /// + /// Gets a value indicating whether the accounts are included in the backup. + /// Exporting the accounts of a running server takes the most time, so it can be + /// skipped when only the configuration should be transferred. + /// + public bool IncludeAccounts { get; init; } = true; +} diff --git a/src/Persistence/BackupService.cs b/src/Persistence/BackupService.cs new file mode 100644 index 0000000000..ec7f2c18c4 --- /dev/null +++ b/src/Persistence/BackupService.cs @@ -0,0 +1,578 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence; + +using System.IO; +using System.IO.Compression; +using System.Reflection; +using System.Threading; +using MUnique.OpenMU.DataModel.Composition; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Persistence.AdminAuth; +using MUnique.OpenMU.Persistence.Json; + +/// +/// Implementation of which uses the available repositories +/// and does not depend on a specific persistence backend. +/// +public class BackupService : IBackupService +{ + /// + /// The file name prefixes of the backup entries and the type of the data which they contain. + /// The order defines in which order the entries are exported and restored - the configuration + /// comes first, because the accounts reference its objects. + /// + private static readonly (string Prefix, Type BasicModelType)[] EntryTypeInfos = + [ + ("AdminUser_", typeof(AdminAuth.AdminUser)), + ("GameConfiguration_", typeof(BasicModel.GameConfiguration)), + ("SystemConfiguration_", typeof(BasicModel.SystemConfiguration)), + ("ChatServerDefinition_", typeof(BasicModel.ChatServerDefinition)), + ("ConnectServerDefinition_", typeof(BasicModel.ConnectServerDefinition)), + ("GameServerDefinition_", typeof(BasicModel.GameServerDefinition)), + ("ConfigurationUpdate_", typeof(BasicModel.ConfigurationUpdate)), + ("ConfigurationUpdateState_", typeof(BasicModel.ConfigurationUpdateState)), + ("Account_", typeof(BasicModel.Account)), + ("CastleSiegeData_", typeof(BasicModel.CastleSiegeData)), + ]; + + private readonly IPersistenceContextProvider _contextProvider; + private readonly IAdminUserRepository _adminUserRepository; + + /// + /// Initializes a new instance of the class. + /// + /// The persistence context provider. + /// The admin user repository. + public BackupService(IPersistenceContextProvider contextProvider, IAdminUserRepository adminUserRepository) + { + this._contextProvider = contextProvider; + this._adminUserRepository = adminUserRepository; + } + + /// + public Task CreateBackupAsync(Stream outputStream, CancellationToken cancellationToken = default) + { + return this.CreateBackupAsync(outputStream, BackupOptions.Default, cancellationToken); + } + + /// + public async Task CreateBackupAsync(Stream outputStream, BackupOptions options, CancellationToken cancellationToken = default) + { + await using var archive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true); + + // A single shared reference handler ensures cross-type references are written as $ref. + var sharedHandler = new IdReferenceHandler(); + + // Use a single context so the context stack is set up correctly for all repository calls. + using var context = this._contextProvider.CreateNewContext(); + + // First the auth users + await this.ExportAdminUsersAsync(archive, sharedHandler, cancellationToken).ConfigureAwait(false); + + // Export in dependency order: configuration first so that accounts can reference config objects. + await ExportAsync(archive, "GameConfiguration_", context, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportAsync(archive, "SystemConfiguration_", context, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportAsync(archive, "ChatServerDefinition_", context, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportAsync(archive, "ConnectServerDefinition_", context, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportAsync(archive, "GameServerDefinition_", context, sharedHandler, cancellationToken).ConfigureAwait(false); + + // The applied updates are exported, too. Otherwise, they would be applied again on the restored data. + await ExportAsync(archive, "ConfigurationUpdate_", context, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportAsync(archive, "ConfigurationUpdateState_", context, sharedHandler, cancellationToken).ConfigureAwait(false); + + if (options.IncludeAccounts) + { + await ExportAsync(archive, "Account_", context, sharedHandler, cancellationToken).ConfigureAwait(false); + } + + await ExportAsync(archive, "CastleSiegeData_", context, sharedHandler, cancellationToken).ConfigureAwait(false); + } + + /// + public bool ContainsRestorableData(Stream inputStream) + { + var previousPosition = inputStream.Position; + try + { + using var archive = new ZipArchive(inputStream, ZipArchiveMode.Read, leaveOpen: true); + return archive.Entries.Any(entry => GetTypeInfoForEntry(entry.Name) is not null); + } + catch (InvalidDataException) + { + return false; + } + finally + { + inputStream.Position = previousPosition; + } + } + + /// + public virtual async Task RestoreBackupAsync(Stream inputStream, CancellationToken cancellationToken = default) + { + await using var archive = new ZipArchive(inputStream, ZipArchiveMode.Read, leaveOpen: true); + + // A single shared handler accumulates deserialized objects so cross-file $ref references resolve correctly. + var sharedHandler = new IdReferenceHandler(); + var createdObjects = new Dictionary(); + + // Sort entries so GameConfiguration is processed first (other types reference its sub-objects). + var orderedEntries = archive.Entries + .OrderBy(e => GetTypeOrder(e.Name)) + .ThenBy(e => e.Name) + .ToList(); + + using var context = this._contextProvider.CreateNewContext(); + using (context.SuspendChangeNotifications()) + { + foreach (var entry in orderedEntries) + { + cancellationToken.ThrowIfCancellationRequested(); + var typeInfo = GetTypeInfoForEntry(entry.Name); + if (typeInfo is null) + { + continue; + } + + await using var entryStream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + var basicModelObj = await DeserializeAsync(entryStream, typeInfo.Value.BasicModelType, sharedHandler, cancellationToken).ConfigureAwait(false); + if (basicModelObj is null) + { + continue; + } + + if (basicModelObj is AdminUser adminUser) + { + await this._adminUserRepository.AddAsync(adminUser, cancellationToken).ConfigureAwait(false); + continue; + } + + // The root object is created and saved before it's filled with its data. + // Otherwise, a persistence layer with foreign keys (entity framework) would run into a + // circular dependency, because objects below the root reference the root object again + // (e.g. GameConfiguration -> GameMapDefinition -> CastleSiegeConfiguration -> GameConfiguration). + // The data initialization does the same for the game configuration. + var rootObject = this.CreateObject(context, basicModelObj, createdObjects); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + this.FillObject(context, basicModelObj, rootObject, createdObjects); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + } + + private async Task ExportAdminUsersAsync( + ZipArchive archive, + IdReferenceHandler sharedHandler, + CancellationToken cancellationToken) + { + var items = await this._adminUserRepository.GetAllAsync(cancellationToken).ConfigureAwait(false); + var serializer = new JsonObjectSerializer(); + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + + var entryName = $"AdminUser_{item.Id}.json"; + var entry = archive.CreateEntry(entryName); + await using var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + await serializer.SerializeAsync(item, stream, sharedHandler, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task ExportAsync( + ZipArchive archive, + string filePrefix, + IContext context, + IdReferenceHandler sharedHandler, + CancellationToken cancellationToken) + where TData : class + where TBasic : class + { + var items = await context.GetAsync(cancellationToken).ConfigureAwait(false); + var serializer = new JsonObjectSerializer(); + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + TBasic targetItem; + if (item is TBasic basicItem) + { + targetItem = basicItem; + } + else if (item is IConvertibleTo convertible) + { + targetItem = convertible.Convert(); + } + else + { + continue; + } + + if (item is not IIdentifiable identifiable) + { + continue; + } + + var basicModel = targetItem; + var entryName = $"{filePrefix}{identifiable.Id}.json"; + var entry = archive.CreateEntry(entryName); + await using var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + await serializer.SerializeAsync(basicModel, stream, sharedHandler, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task DeserializeAsync( + Stream stream, + Type basicModelType, + IdReferenceHandler referenceHandler, + CancellationToken cancellationToken) + { + // Read to memory first because ZipArchive entry streams don't support seeking. + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); + ms.Position = 0; + + var deserializer = new JsonObjectDeserializer(); + + if (basicModelType == typeof(BasicModel.GameConfiguration)) + { + return deserializer.Deserialize(ms, referenceHandler); + } + + if (basicModelType == typeof(BasicModel.ChatServerDefinition)) + { + return deserializer.Deserialize(ms, referenceHandler); + } + + if (basicModelType == typeof(BasicModel.ConnectServerDefinition)) + { + return deserializer.Deserialize(ms, referenceHandler); + } + + if (basicModelType == typeof(BasicModel.GameServerDefinition)) + { + return deserializer.Deserialize(ms, referenceHandler); + } + + if (basicModelType == typeof(BasicModel.SystemConfiguration)) + { + return deserializer.Deserialize(ms, referenceHandler); + } + + if (basicModelType == typeof(BasicModel.ConfigurationUpdate)) + { + return deserializer.Deserialize(ms, referenceHandler); + } + + if (basicModelType == typeof(BasicModel.ConfigurationUpdateState)) + { + return deserializer.Deserialize(ms, referenceHandler); + } + + if (basicModelType == typeof(BasicModel.Account)) + { + return deserializer.Deserialize(ms, referenceHandler); + } + + if (basicModelType == typeof(BasicModel.CastleSiegeData)) + { + return deserializer.Deserialize(ms, referenceHandler); + } + + if (basicModelType == typeof(AdminUser)) + { + return deserializer.Deserialize(ms, referenceHandler); + } + + throw new ArgumentException($"Unsupported backup entry type: {basicModelType}", nameof(basicModelType)); + } + + private static int GetTypeOrder(string entryName) + { + for (var i = 0; i < EntryTypeInfos.Length; i++) + { + if (entryName.StartsWith(EntryTypeInfos[i].Prefix, StringComparison.Ordinal)) + { + return i; + } + } + + return EntryTypeInfos.Length; + } + + private static (string Prefix, Type BasicModelType)? GetTypeInfoForEntry(string entryName) + { + foreach (var typeInfo in EntryTypeInfos) + { + if (entryName.StartsWith(typeInfo.Prefix, StringComparison.Ordinal)) + { + return typeInfo; + } + } + + return null; + } + + private static Type FindDataModelBaseType(Type basicModelType) + { + var current = basicModelType.BaseType; + while (current != null && current != typeof(object)) + { + if (current.Assembly != basicModelType.Assembly + && current.Assembly != typeof(object).Assembly) + { + return current; + } + + current = current.BaseType; + } + + return basicModelType; + } + + private static void SetId(object obj, Guid id) + { + var idProp = obj.GetType().GetProperty("Id", BindingFlags.Public | BindingFlags.Instance); + idProp?.SetValue(obj, id); + } + + private static bool IsCollectionType(Type type) + { + if (type == typeof(string) || type.IsArray) + { + return false; + } + + return type.IsGenericType + && (type.GetGenericTypeDefinition() == typeof(ICollection<>) + || type.GetGenericTypeDefinition() == typeof(IList<>) + || type.GetGenericTypeDefinition() == typeof(List<>)); + } + + /// + /// Determines whether the given property just holds run-time information which is not persisted. + /// + /// The property. + /// true, if the property is marked with the ; otherwise, false. + private static bool IsTransient(PropertyInfo property) + { + return property.GetCustomAttribute() is not null; + } + + /// + /// Determines the Add-method of the -interface which is implemented by the given collection type. + /// We use the interface method, because the implementing type may define additional Add-methods. + /// + /// The type of the collection. + /// The Add-method, if the type implements ; otherwise, null. + private static MethodInfo? FindCollectionAddMethod(Type collectionType) + { + var collectionInterface = collectionType.IsGenericType && collectionType.GetGenericTypeDefinition() == typeof(ICollection<>) + ? collectionType + : collectionType.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ICollection<>)); + + return collectionInterface?.GetMethod("Add"); + } + + private static PropertyInfo? FindCollectionProperty(Type type, string propertyName) + { + var property = type.GetProperty( + propertyName, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy); + + return property is not null + && property.CanRead + && property.GetIndexParameters().Length == 0 + && IsCollectionType(property.PropertyType) + ? property + : null; + } + + private static PropertyInfo? FindWritableProperty(Type type, string propertyName) + { + var prop = type.GetProperty( + propertyName, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy); + + return prop?.GetSetMethod(nonPublic: true) is not null ? prop : null; + } + + private object GetOrCreateObject(IContext context, object basicModelObj, Dictionary createdObjects) + { + if (basicModelObj is IIdentifiable identifiable && createdObjects.TryGetValue(identifiable.Id, out var existing)) + { + return existing; + } + + var newObj = this.CreateObject(context, basicModelObj, createdObjects); + this.FillObject(context, basicModelObj, newObj, createdObjects); + + return newObj; + } + + /// + /// Creates the persistent object for the given object of the backup, without copying its data yet. + /// + /// The context on which the object is created. + /// The object of the backup. + /// The objects which have been created so far, by their identifier. + /// The created object. + private object CreateObject(IContext context, object basicModelObj, Dictionary createdObjects) + { + var newObj = context.CreateNew(FindDataModelBaseType(basicModelObj.GetType())); + if (basicModelObj is IIdentifiable identifiable) + { + createdObjects[identifiable.Id] = newObj; + SetId(newObj, identifiable.Id); + } + + return newObj; + } + + /// + /// Copies the data of the given object of the backup to the created persistent object. + /// Referenced objects are created on the way, if they don't exist yet. + /// + /// The context on which referenced objects are created. + /// The object of the backup. + /// The created object which gets the data. + /// The objects which have been created so far, by their identifier. + private void FillObject(IContext context, object basicModelObj, object target, Dictionary createdObjects) + { + this.CopyProperties(basicModelObj, target, FindDataModelBaseType(basicModelObj.GetType()), context, createdObjects); + this.CopyRawCollectionProperties(basicModelObj, target, context, createdObjects); + } + + private void CopyProperties( + object source, + object target, + Type baseType, + IContext context, + Dictionary createdObjects) + { + var properties = baseType.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + foreach (var prop in properties) + { + if (!prop.CanRead + || prop.GetIndexParameters().Length > 0 + || IsTransient(prop)) + { + continue; + } + + if (IsCollectionType(prop.PropertyType)) + { + // Collections which have a "Raw" counterpart on the source are copied by CopyRawCollectionProperties. + // The other ones (e.g. collections of value types, like ItemSlotType.ItemSlots) are copied here. + if (FindCollectionProperty(source.GetType(), "Raw" + prop.Name) is null) + { + this.CopyCollection(source, target, prop.Name, prop.Name, context, createdObjects); + } + + continue; + } + + if (prop.GetValue(source) is not { } value) + { + continue; + } + + if (FindWritableProperty(target.GetType(), prop.Name) is not { } targetProp) + { + continue; + } + + var targetValue = value is IIdentifiable + ? this.GetOrCreateObject(context, value, createdObjects) + : value; + + if (!targetProp.PropertyType.IsInstanceOfType(targetValue)) + { + throw new InvalidOperationException( + $"Can't restore '{baseType.Name}.{prop.Name}': a value of type '{targetValue.GetType()}' can't be assigned to a property of type '{targetProp.PropertyType}'."); + } + + targetProp.SetValue(target, targetValue); + } + + // Recurse into MUnique parent base types for inherited properties. + if (baseType.BaseType is { } parentBase + && parentBase != typeof(object) + && parentBase.Namespace?.StartsWith("MUnique", StringComparison.Ordinal) is true) + { + this.CopyProperties(source, target, parentBase, context, createdObjects); + } + } + + private void CopyRawCollectionProperties( + object source, + object target, + IContext context, + Dictionary createdObjects) + { + var rawCollectionProps = source.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.Name.StartsWith("Raw", StringComparison.Ordinal) + && IsCollectionType(p.PropertyType) + && p.CanRead + && p.GetIndexParameters().Length == 0); + + foreach (var rawProp in rawCollectionProps) + { + this.CopyCollection(source, target, rawProp.Name, rawProp.Name["Raw".Length..], context, createdObjects); + } + } + + /// + /// Copies the items of a collection of the backup object into the corresponding collection of the created object. + /// + /// The object of the backup. + /// The created object. + /// The name of the collection property of the . + /// + /// The name of the collection property which is used, if the target has no property with the name of the source property. + /// Many-to-many relations don't have a "Raw" collection on the entity framework model - it holds the join entities + /// in a "Joined" collection instead. Adding the items to the collection of the data model creates these join entities. + /// + /// The context on which referenced objects are created. + /// The objects which have been created so far, by their identifier. + private void CopyCollection( + object source, + object target, + string sourcePropertyName, + string fallbackTargetPropertyName, + IContext context, + Dictionary createdObjects) + { + if (FindCollectionProperty(source.GetType(), sourcePropertyName)?.GetValue(source) is not System.Collections.IEnumerable sourceItems) + { + return; + } + + var targetProp = FindCollectionProperty(target.GetType(), sourcePropertyName) + ?? FindCollectionProperty(target.GetType(), fallbackTargetPropertyName); + if (targetProp?.GetValue(target) is not { } targetCollection) + { + return; + } + + var addMethod = FindCollectionAddMethod(targetProp.PropertyType) + ?? throw new InvalidOperationException($"Can't restore '{source.GetType().Name}.{sourcePropertyName}': the target collection '{targetProp.PropertyType}' has no Add-method."); + + foreach (var item in sourceItems) + { + if (item is null) + { + continue; + } + + var targetItem = item is IIdentifiable + ? this.GetOrCreateObject(context, item, createdObjects) + : item; + + addMethod.Invoke(targetCollection, [targetItem]); + } + } +} diff --git a/src/Persistence/EntityFramework/AdminAuth/AdminUserRepository.cs b/src/Persistence/EntityFramework/AdminAuth/AdminUserRepository.cs index 7f190eca69..df601609ee 100644 --- a/src/Persistence/EntityFramework/AdminAuth/AdminUserRepository.cs +++ b/src/Persistence/EntityFramework/AdminAuth/AdminUserRepository.cs @@ -14,7 +14,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; /// Implementation of the which stores the users /// in the admin schema of the configured PostgreSQL database. /// -public class AdminUserRepository : IAdminUserRepository +public sealed class AdminUserRepository : IAdminUserRepository, IDisposable { /// /// The time after which a connection attempt to the database server is given up. @@ -35,14 +35,24 @@ public class AdminUserRepository : IAdminUserRepository private readonly AsyncLock _storageLock = new(); private bool _isStorageReady; private DateTime _nextProbeAt = DateTime.MinValue; + private readonly SetupService _setupService; /// /// Initializes a new instance of the class. /// + /// The setup service. /// The logger. - public AdminUserRepository(ILogger logger) + public AdminUserRepository(SetupService setupService, ILogger logger) { this._logger = logger; + this._setupService = setupService; + this._setupService.DatabaseInitialized += this.OnDatabaseInitialized; + } + + /// + public void Dispose() + { + this._setupService.DatabaseInitialized -= this.OnDatabaseInitialized; } /// @@ -200,4 +210,10 @@ private async ValueTask EnsureAvailableStorageAsync(CancellationToken cancellati throw new InvalidOperationException("The admin user storage is not available. Please check the database connection."); } } + + private ValueTask OnDatabaseInitialized() + { + this._isStorageReady = false; + return ValueTask.CompletedTask; + } } diff --git a/src/Persistence/EntityFramework/DatabaseSnapshotService.cs b/src/Persistence/EntityFramework/DatabaseSnapshotService.cs new file mode 100644 index 0000000000..610cc81e5d --- /dev/null +++ b/src/Persistence/EntityFramework/DatabaseSnapshotService.cs @@ -0,0 +1,373 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.EntityFramework; + +using System.IO; +using System.IO.Compression; +using System.Text.Json; +using System.Threading; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; +using Npgsql; + +/// +/// Implementation of the which uses the COPY command of postgres. +/// Each table is written into its own entry of a zip archive, in the binary format of postgres. +/// The archive also contains a manifest with the applied database migrations, so that a snapshot of +/// an older server can be restored: its schema is created first, and the migrations which came +/// afterwards are applied to the restored data. +/// +public class DatabaseSnapshotService : IDatabaseSnapshotService +{ + /// + /// The name of the archive entry which describes the snapshot. + /// + private const string ManifestEntryName = "manifest.json"; + + /// + /// The version of the snapshot format. It's increased when the layout of the archive changes. + /// + private const int CurrentFormatVersion = 2; + + private const string TableEntryExtension = ".bin"; + + private static readonly string[] IncludedSchemas = + [ + SchemaNames.Configuration, + SchemaNames.AccountData, + SchemaNames.Guild, + SchemaNames.Friend, + SchemaNames.AdminPanel, + ]; + + /// + public async Task CreateSnapshotAsync(Stream outputStream, CancellationToken cancellationToken = default) + { + await using var connection = await CreateConnectionAsync(cancellationToken).ConfigureAwait(false); + using var archive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true); + + var tables = await GetTableNamesAsync(connection, cancellationToken).ConfigureAwait(false); + var manifest = new SnapshotManifest( + CurrentFormatVersion, + DateTime.UtcNow, + await GetAppliedMigrationsAsync(cancellationToken).ConfigureAwait(false), + await GetAppliedMigrationsAsync(cancellationToken).ConfigureAwait(false), + tables.Select(table => table.ToString()).ToArray()); + + var manifestEntry = archive.CreateEntry(ManifestEntryName); + await using (var manifestStream = manifestEntry.Open()) + { + await JsonSerializer.SerializeAsync(manifestStream, manifest, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + foreach (var table in tables) + { + cancellationToken.ThrowIfCancellationRequested(); + var entry = archive.CreateEntry($"{table}{TableEntryExtension}"); + await using var entryStream = entry.Open(); + await using var copyStream = await connection + .BeginRawBinaryCopyAsync($"COPY {table.ToQuotedString()} TO STDOUT (FORMAT BINARY)", cancellationToken) + .ConfigureAwait(false); + await copyStream.CopyToAsync(entryStream, cancellationToken).ConfigureAwait(false); + } + } + + /// + public async ValueTask GetRestoreBlockingReasonAsync(Stream inputStream, CancellationToken cancellationToken = default) + { + var previousPosition = inputStream.Position; + try + { + using var archive = new ZipArchive(inputStream, ZipArchiveMode.Read, leaveOpen: true); + if (await ReadManifestAsync(archive, cancellationToken).ConfigureAwait(false) is not { } manifest) + { + return "The selected file is no database snapshot."; + } + + if (manifest.FormatVersion != CurrentFormatVersion) + { + return $"The snapshot was created in format version {manifest.FormatVersion}, but this server expects version {CurrentFormatVersion}."; + } + + return GetIncompatibilityReason(manifest.Migrations, "game database") + ?? GetIncompatibilityReason(manifest.AdminPanelMigrations, "admin panel database"); + } + catch (InvalidDataException) + { + return "The selected file is no zip archive."; + } + finally + { + inputStream.Position = previousPosition; + } + } + + /// + public async Task RestoreSnapshotAsync(Stream inputStream, CancellationToken cancellationToken = default) + { + using var archive = new ZipArchive(inputStream, ZipArchiveMode.Read, leaveOpen: true); + var manifest = await ReadManifestAsync(archive, cancellationToken).ConfigureAwait(false) + ?? throw new ArgumentException($"The archive doesn't contain a {ManifestEntryName}, so it's no database snapshot.", nameof(inputStream)); + + if ((GetIncompatibilityReason(manifest.Migrations, "game database") + ?? GetIncompatibilityReason(manifest.AdminPanelMigrations, "admin panel database")) is { } incompatibility) + { + throw new InvalidOperationException(incompatibility); + } + + // The data of the snapshot fits to the database schema of the moment when it was created, + // so we build exactly that schema first. Afterwards, the migrations which came later are + // applied to the restored data, like it would happen for a running server. + await DeleteDatabaseAsync(cancellationToken).ConfigureAwait(false); + await MigrateToSnapshotStateAsync(manifest.Migrations, cancellationToken).ConfigureAwait(false); + await MigrateToSnapshotStateAsync(manifest.AdminPanelMigrations, cancellationToken).ConfigureAwait(false); + + await using var connection = await CreateConnectionAsync(cancellationToken).ConfigureAwait(false); + var existingTables = (await GetTableNamesAsync(connection, cancellationToken).ConfigureAwait(false)) + .Select(table => table.ToString()) + .ToHashSet(StringComparer.Ordinal); + if (manifest.Tables.FirstOrDefault(table => !existingTables.Contains(table)) is { } missingTable) + { + throw new InvalidOperationException($"The table '{missingTable}' of the snapshot doesn't exist in the created database. The snapshot can't be restored."); + } + + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + + // The tables reference each other in circles, so we can't insert them in a dependency order. + // Like pg_restore, we disable the foreign key checks for this transaction instead. + await DisableForeignKeyChecksAsync(connection, cancellationToken).ConfigureAwait(false); + + foreach (var tableName in manifest.Tables) + { + cancellationToken.ThrowIfCancellationRequested(); + if (archive.GetEntry($"{tableName}{TableEntryExtension}") is not { } entry) + { + throw new InvalidOperationException($"The archive doesn't contain the data of table '{tableName}', which is listed in its manifest."); + } + + var table = TableName.Parse(tableName); + await using var entryStream = entry.Open(); + await using var copyStream = await connection + .BeginRawBinaryCopyAsync($"COPY {table.ToQuotedString()} FROM STDIN (FORMAT BINARY)", cancellationToken) + .ConfigureAwait(false); + await entryStream.CopyToAsync(copyStream, cancellationToken).ConfigureAwait(false); + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + await connection.CloseAsync().ConfigureAwait(false); + + // Bring the restored data to the current state of this server. + await MigrateToAsync(null, cancellationToken).ConfigureAwait(false); + await MigrateToAsync(null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Determines why a snapshot with the given migrations can't be restored by this server. + /// The snapshot may be older than this server - the migrations which came later are applied + /// to the restored data then. It can't be newer, because we don't know its schema. + /// + /// The type of the database context. + /// The migrations which were applied when the snapshot was created. + /// The name of the database, for the message. + /// The reason why it can't be restored; null, if it can be restored. + private static string? GetIncompatibilityReason(string[] snapshotMigrations, string databaseName) + where TContext : DbContext, new() + { + using var context = new TContext(); + var knownMigrations = context.Database.GetMigrations().ToHashSet(StringComparer.Ordinal); + var unknownMigrations = snapshotMigrations.Where(migration => !knownMigrations.Contains(migration)).ToList(); + if (unknownMigrations.Count > 0) + { + return $"The snapshot of the {databaseName} was created by a newer or different version of the server: " + + $"it contains the unknown database migration '{unknownMigrations[0]}'. " + + "Please use the data backup (json) to transfer the data."; + } + + return null; + } + + /// + /// Creates the database schema of the moment when the snapshot was created. + /// + /// The type of the database context. + /// The migrations which were applied when the snapshot was created. + /// The cancellation token. + private static async Task MigrateToSnapshotStateAsync(string[] snapshotMigrations, CancellationToken cancellationToken) + where TContext : DbContext, new() + { + if (snapshotMigrations.Length == 0) + { + // This database didn't exist when the snapshot was created; it's created below, when + // the remaining migrations are applied. + return; + } + + await using var context = new TContext(); + + // The migrations are applied in the order of their identifier. When a migration was added + // later with an earlier identifier - which happens when branches are merged - it's applied + // here, too. That's not a problem as long as it doesn't change a table of the snapshot; + // otherwise the copy of that table fails, and the restore is rolled back. + var target = context.Database.GetMigrations().Last(snapshotMigrations.Contains); + await MigrateToAsync(target, cancellationToken).ConfigureAwait(false); + } + + /// + /// Migrates the schema of the given context to the given migration. + /// + /// The type of the database context. + /// The migration which should be the last applied one; null, to apply all of them. + /// The cancellation token. + private static async Task MigrateToAsync(string? targetMigration, CancellationToken cancellationToken) + where TContext : DbContext, new() + { + await using var context = new TContext(); + if (targetMigration is null && !context.Database.GetMigrations().Any()) + { + return; + } + + await context.Database.GetService() + .MigrateAsync(targetMigration, cancellationToken) + .ConfigureAwait(false); + } + + private static async Task DeleteDatabaseAsync(CancellationToken cancellationToken) + { + try + { + await using var context = new EntityDataContext(); + await context.Database.EnsureDeletedAsync(cancellationToken).ConfigureAwait(false); + } + catch (NpgsqlException) + { + // That's expected when there is no database yet. + } + } + + private static async Task CreateConnectionAsync(CancellationToken cancellationToken) + { + // Creating the context also ensures that the connection settings are initialized. + await using var context = new EntityDataContext(); + var connection = new NpgsqlConnection(context.Database.GetConnectionString()); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + return connection; + } + + private static async Task DisableForeignKeyChecksAsync(NpgsqlConnection connection, CancellationToken cancellationToken) + { + try + { + await using var command = connection.CreateCommand(); + command.CommandText = "SET LOCAL session_replication_role = replica"; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + catch (PostgresException exception) when (exception.SqlState == PostgresErrorCodes.InsufficientPrivilege) + { + throw new InvalidOperationException( + "The database user needs superuser rights to restore a snapshot, because the foreign key checks " + + "have to be disabled while the data is inserted. Please use the data backup (json) instead.", + exception); + } + } + + private static async ValueTask ReadManifestAsync(ZipArchive archive, CancellationToken cancellationToken) + { + if (archive.GetEntry(ManifestEntryName) is not { } entry) + { + return null; + } + + await using var stream = entry.Open(); + return await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private static async ValueTask> GetTableNamesAsync(NpgsqlConnection connection, CancellationToken cancellationToken) + { + // The migration history is not part of the snapshot - the re-created database brings its own. + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT table_schema, table_name + FROM information_schema.tables + WHERE table_schema = ANY(@schemas) + AND table_type = 'BASE TABLE' + AND table_name <> '__EFMigrationsHistory' + ORDER BY table_schema, table_name + """; + command.Parameters.AddWithValue("schemas", IncludedSchemas); + + var result = new System.Collections.Generic.List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + result.Add(new TableName(reader.GetString(0), reader.GetString(1))); + } + + return result; + } + + private static async ValueTask GetAppliedMigrationsAsync(CancellationToken cancellationToken) + where TContext : DbContext, new() + { + try + { + await using var context = new TContext(); + var applied = await context.Database.GetAppliedMigrationsAsync(cancellationToken).ConfigureAwait(false); + + // We keep the order in which they are defined, so that we can determine the last one. + var knownMigrations = context.Database.GetMigrations().ToList(); + return knownMigrations.Where(applied.Contains).ToArray(); + } + catch (PostgresException) + { + // The database of this context doesn't exist yet. + return []; + } + } + + /// + /// The description of a snapshot, so that we can check if it fits to the current database. + /// + /// The version of the snapshot format. + /// The point in time when the snapshot was created. + /// The migrations of the game database which were applied when the snapshot was created. + /// The migrations of the admin panel database which were applied when the snapshot was created. + /// The names of the tables which are contained in the snapshot. + private sealed record SnapshotManifest(int FormatVersion, DateTime CreatedAt, string[] Migrations, string[] AdminPanelMigrations, string[] Tables); + + /// + /// The name of a table of the database. + /// + /// The name of the schema. + /// The name of the table. + private sealed record TableName(string Schema, string Name) + { + /// + /// Parses a table name of the format "schema.table". + /// + /// The value which should be parsed. + /// The parsed table name. + public static TableName Parse(string value) + { + var separatorIndex = value.IndexOf('.', StringComparison.Ordinal); + return separatorIndex < 0 + ? throw new ArgumentException($"'{value}' is no valid table name.", nameof(value)) + : new TableName(value[..separatorIndex], value[(separatorIndex + 1)..]); + } + + /// + public override string ToString() => $"{this.Schema}.{this.Name}"; + + /// + /// Gets the quoted name which can be used in a sql statement. + /// + /// The quoted name. + public string ToQuotedString() => $"{Quote(this.Schema)}.{Quote(this.Name)}"; + + private static string Quote(string identifier) => $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; + } +} diff --git a/src/Persistence/EntityFramework/Json/BinaryAsHexJsonConverter.cs b/src/Persistence/EntityFramework/Json/BinaryAsHexJsonConverter.cs index cfa3a60ffa..decd1ad56e 100644 --- a/src/Persistence/EntityFramework/Json/BinaryAsHexJsonConverter.cs +++ b/src/Persistence/EntityFramework/Json/BinaryAsHexJsonConverter.cs @@ -69,7 +69,7 @@ public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOp { var prefixSize = ByteArrayPrefix.Length + 1; // +1 for escaping var hexData = reader.ValueSpan.Slice(prefixSize); - var data = new byte[(hexData.Length - prefixSize) / 2]; + var data = new byte[hexData.Length / 2]; for (var i = 0; i < data.Length; i++) { var index = i * 2; diff --git a/src/Persistence/EntityFramework/Model/ExtendedTypes.Custom.cs b/src/Persistence/EntityFramework/Model/ExtendedTypes.Custom.cs index 0d618c603d..77fedfa98a 100644 --- a/src/Persistence/EntityFramework/Model/ExtendedTypes.Custom.cs +++ b/src/Persistence/EntityFramework/Model/ExtendedTypes.Custom.cs @@ -274,3 +274,57 @@ internal partial class LetterHeader /// public Guid ReceiverId { get; set; } } + +internal partial class ChatServerDefinition : IConvertibleTo +{ + public BasicModel.ChatServerDefinition Convert() + { + MapsterConfigurator.EnsureConfigured(); + return this.Adapt(); + } +} + +internal partial class GameServerDefinition : IConvertibleTo +{ + public BasicModel.GameServerDefinition Convert() + { + MapsterConfigurator.EnsureConfigured(); + return this.Adapt(); + } +} + +internal partial class SystemConfiguration : IConvertibleTo +{ + public BasicModel.SystemConfiguration Convert() + { + MapsterConfigurator.EnsureConfigured(); + return this.Adapt(); + } +} + +internal partial class ConfigurationUpdate : IConvertibleTo +{ + public BasicModel.ConfigurationUpdate Convert() + { + MapsterConfigurator.EnsureConfigured(); + return this.Adapt(); + } +} + +internal partial class ConfigurationUpdateState : IConvertibleTo +{ + public BasicModel.ConfigurationUpdateState Convert() + { + MapsterConfigurator.EnsureConfigured(); + return this.Adapt(); + } +} + +internal partial class CastleSiegeData : IConvertibleTo +{ + public BasicModel.CastleSiegeData Convert() + { + MapsterConfigurator.EnsureConfigured(); + return this.Adapt(); + } +} diff --git a/src/Persistence/EntityFramework/Model/MapsterConfigurator.Generated.cs b/src/Persistence/EntityFramework/Model/MapsterConfigurator.Generated.cs index 928d2579f8..b1c4c2c9d6 100644 --- a/src/Persistence/EntityFramework/Model/MapsterConfigurator.Generated.cs +++ b/src/Persistence/EntityFramework/Model/MapsterConfigurator.Generated.cs @@ -12,6 +12,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Model; +using MUnique.OpenMU.DataModel.Composition; using MUnique.OpenMU.Persistence; using Mapster; @@ -35,6 +36,18 @@ public static void EnsureConfigured() Mapster.TypeAdapterConfig.GlobalSettings.Default.PreserveReference(true); Mapster.TypeAdapterConfig.GlobalSettings.Default.IgnoreMember((member, side) => member.Name.StartsWith("Raw")); + // Transient properties just hold run-time information and are not persisted. + // Some of them (e.g. of the SkillEntry) can't be mapped by Mapster at all, because their types are interfaces with events. + Mapster.TypeAdapterConfig.GlobalSettings.Default.IgnoreMember( + (member, side) => member.GetCustomAttributes(true).OfType().Any()); + + // Collections of value types (e.g. ItemSlotType.ItemSlots) are only filled when the collection of the + // destination is used. Otherwise, Mapster creates a new, empty one and the values would be lost. + Mapster.TypeAdapterConfig.GlobalSettings.Default.UseDestinationValue( + member => member.Type.IsGenericType + && member.Type.GetGenericTypeDefinition() == typeof(ICollection<>) + && member.Type.GetGenericArguments()[0].IsValueType); + Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); @@ -71,6 +84,9 @@ public static void EnsureConfigured() Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); + Mapster.TypeAdapterConfig.GlobalSettings.ForType() + .ConstructUsing(source => new BasicModel.GuildMember(source.Id)); + Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); @@ -227,6 +243,9 @@ public static void EnsureConfigured() Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); + Mapster.TypeAdapterConfig.GlobalSettings.ForType() + .ConstructUsing(source => new BasicModel.StatAttributeDefinition(source.Attribute, source.BaseValue, source.IncreasableByPlayer)); + Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); @@ -311,15 +330,24 @@ public static void EnsureConfigured() Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); + Mapster.TypeAdapterConfig.GlobalSettings.ForType() + .ConstructUsing(source => new BasicModel.AttributeDefinition(source.Id, source.Designation, source.Description)); + Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); + Mapster.TypeAdapterConfig.GlobalSettings.ForType() + .ConstructUsing(source => new BasicModel.ConstValueAttribute(source.Value, source.Definition, source.AggregateType)); + Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); + Mapster.TypeAdapterConfig.GlobalSettings.ForType() + .ConstructUsing(source => new BasicModel.AttributeRelationship(source.TargetAttribute, source.InputOperand, source.InputAttribute, source.AggregateType)); + Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); diff --git a/src/Persistence/IBackupService.cs b/src/Persistence/IBackupService.cs new file mode 100644 index 0000000000..89de799e58 --- /dev/null +++ b/src/Persistence/IBackupService.cs @@ -0,0 +1,50 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence; + +using System.IO; +using System.Threading; + +/// +/// Service which can create and restore backups of the configuration and account data. +/// +public interface IBackupService +{ + /// + /// Creates a backup of all configuration and account data and writes it to the given stream as a zip archive. + /// + /// The output stream to write the backup zip archive to. + /// The cancellation token. + Task CreateBackupAsync(Stream outputStream, CancellationToken cancellationToken = default) + => this.CreateBackupAsync(outputStream, BackupOptions.Default, cancellationToken); + + /// + /// Creates a backup of the configuration and account data and writes it to the given stream as a zip archive. + /// + /// The output stream to write the backup zip archive to. + /// The options which define what the backup contains. + /// The cancellation token. + Task CreateBackupAsync(Stream outputStream, BackupOptions options, CancellationToken cancellationToken = default); + + /// + /// Determines whether the given stream contains a backup archive with restorable data. + /// It's meant to be called before the database is re-created, so that selecting a wrong file doesn't cause a data loss. + /// + /// The stream which should be checked. Its position is restored afterwards. + /// true, if the stream contains a backup archive with restorable data; otherwise, false. + bool ContainsRestorableData(Stream inputStream); + + /// + /// Restores all configuration and account data from the given backup zip archive stream. + /// + /// + /// Note: This does not recreate the database schema. The caller is responsible for + /// recreating the database (e.g. via ) + /// before calling this method. + /// + /// The backup zip archive stream to restore from. + /// The cancellation token. + Task RestoreBackupAsync(Stream inputStream, CancellationToken cancellationToken = default); +} diff --git a/src/Persistence/Initialization/IDataInitializationPlugIn.cs b/src/Persistence/IDataInitializationPlugIn.cs similarity index 95% rename from src/Persistence/Initialization/IDataInitializationPlugIn.cs rename to src/Persistence/IDataInitializationPlugIn.cs index 28905d5779..b79c3e9822 100644 --- a/src/Persistence/Initialization/IDataInitializationPlugIn.cs +++ b/src/Persistence/IDataInitializationPlugIn.cs @@ -2,7 +2,7 @@ // Licensed under the MIT License. See LICENSE file in the project root for full license information. // -namespace MUnique.OpenMU.Persistence.Initialization; +namespace MUnique.OpenMU.Persistence; using System.Runtime.InteropServices; using MUnique.OpenMU.PlugIns; diff --git a/src/Persistence/IDatabaseSnapshotService.cs b/src/Persistence/IDatabaseSnapshotService.cs new file mode 100644 index 0000000000..2732745adc --- /dev/null +++ b/src/Persistence/IDatabaseSnapshotService.cs @@ -0,0 +1,48 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence; + +using System.IO; +using System.Threading; + +/// +/// Service which creates and restores a snapshot of the whole database. +/// +/// +/// In contrast to the , a snapshot is created with the means of the +/// database system itself. It's a lot faster and contains all data. It can be restored by the same +/// or a newer version of the server, but not by an older one - use the +/// to transfer data to an older version. +/// +public interface IDatabaseSnapshotService +{ + /// + /// Creates a snapshot of the database and writes it to the given stream as a zip archive. + /// + /// The output stream to write the snapshot to. + /// The cancellation token. + Task CreateSnapshotAsync(Stream outputStream, CancellationToken cancellationToken = default); + + /// + /// Determines whether the given stream contains a snapshot which can be restored into the current database. + /// It's meant to be called before the database is re-created, so that selecting a wrong file doesn't cause a data loss. + /// + /// The stream which should be checked. Its position is restored afterwards. + /// The cancellation token. + /// The reason why it can't be restored; null, if it can be restored. + ValueTask GetRestoreBlockingReasonAsync(Stream inputStream, CancellationToken cancellationToken = default); + + /// + /// Restores the given snapshot, by re-creating the database and filling it with its data. + /// + /// + /// The database is created with the schema of the moment when the snapshot was taken. + /// If the snapshot is older than this server, the missing migrations are applied to the + /// restored data afterwards - like it would happen when the server is updated. + /// + /// The snapshot zip archive stream to restore from. + /// The cancellation token. + Task RestoreSnapshotAsync(Stream inputStream, CancellationToken cancellationToken = default); +} diff --git a/src/Persistence/InMemory/InMemoryBackupService.cs b/src/Persistence/InMemory/InMemoryBackupService.cs new file mode 100644 index 0000000000..1dcc2743d1 --- /dev/null +++ b/src/Persistence/InMemory/InMemoryBackupService.cs @@ -0,0 +1,32 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.InMemory; + +using System.IO; +using System.Threading; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// An implementation of for the in-memory persistence layer. +/// Export is supported via the base ; restore is not supported. +/// +public class InMemoryBackupService : BackupService +{ + /// + /// Initializes a new instance of the class. + /// + /// The persistence context provider. + /// The admin user repository. + public InMemoryBackupService(IPersistenceContextProvider contextProvider, IAdminUserRepository adminUserRepository) + : base(contextProvider, adminUserRepository) + { + } + + /// + public override Task RestoreBackupAsync(Stream inputStream, CancellationToken cancellationToken = default) + { + throw new NotSupportedException("Backup restore is not supported for in-memory persistence."); + } +} diff --git a/src/Persistence/Json/JsonObjectDeserializer.cs b/src/Persistence/Json/JsonObjectDeserializer.cs index 0b470492b7..0d477fc381 100644 --- a/src/Persistence/Json/JsonObjectDeserializer.cs +++ b/src/Persistence/Json/JsonObjectDeserializer.cs @@ -8,6 +8,7 @@ namespace MUnique.OpenMU.Persistence.Json; using System.Text.Json; using System.Text.Json.Serialization; using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.Interfaces; /// /// A json deserializer which is able to resolve circular references. @@ -30,7 +31,11 @@ public class JsonObjectDeserializer var options = new JsonSerializerOptions { ReferenceHandler = referenceHandler, - Converters = { new ReferenceResolvingConverterFactory { IgnoredTypes = IgnoredTypes } }, + Converters = + { + new LocalizedStringJsonConverter(), + new ReferenceResolvingConverterFactory { IgnoredTypes = IgnoredTypes }, + }, }; this.BeforeDeserialize(options); diff --git a/src/Persistence/Json/JsonObjectSerializer.cs b/src/Persistence/Json/JsonObjectSerializer.cs index 160b1ab465..e43d1c40a7 100644 --- a/src/Persistence/Json/JsonObjectSerializer.cs +++ b/src/Persistence/Json/JsonObjectSerializer.cs @@ -6,13 +6,28 @@ namespace MUnique.OpenMU.Persistence.Json; using System.IO; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; +using MUnique.OpenMU.Interfaces; /// /// Class to serialize an object to a json string or stream. /// public class JsonObjectSerializer { + /// + /// Serializes the specified object into a stream. + /// + /// The type of the object. + /// The object. + /// The stream. + /// An optional external reference handler to share reference state across multiple serializations. If null, a new one is created. + /// The cancellation token. + public async ValueTask SerializeAsync(T obj, Stream stream, ReferenceHandler? referenceHandler, CancellationToken cancellationToken) + { + await this.SerializeInternalAsync(obj, stream, referenceHandler ?? new IdReferenceHandler(), cancellationToken).ConfigureAwait(false); + } + /// /// Serializes the specified object into a stream. /// @@ -21,13 +36,34 @@ public class JsonObjectSerializer /// The stream. /// The cancellation token. public async ValueTask SerializeAsync(T obj, Stream stream, CancellationToken cancellationToken) + { + await this.SerializeInternalAsync(obj, stream, new IdReferenceHandler(), cancellationToken).ConfigureAwait(false); + } + + /// + /// Serializes the specified object into a string. + /// + /// The type of the object. + /// The object. + /// The cancellation token. + /// The serialized object as string. + public async ValueTask SerializeAsync(T obj, CancellationToken cancellationToken) + { + using var stream = new MemoryStream(); + await this.SerializeAsync(obj, stream, cancellationToken).ConfigureAwait(false); + + return Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length); + } + + private async ValueTask SerializeInternalAsync(T obj, Stream stream, ReferenceHandler referenceHandler, CancellationToken cancellationToken) { var options = new JsonSerializerOptions { - ReferenceHandler = new IdReferenceHandler(), + ReferenceHandler = referenceHandler, WriteIndented = true, Converters = { + new LocalizedStringJsonConverter(), new OnlyWriteBelowRootConverter(), new OnlyWriteBelowRootConverter(), new OnlyWriteBelowRootConverter(), @@ -47,19 +83,4 @@ public async ValueTask SerializeAsync(T obj, Stream stream, CancellationToken await JsonSerializer.SerializeAsync(stream, obj, options, cancellationToken).ConfigureAwait(false); } - - /// - /// Serializes the specified object into a string. - /// - /// The type of the object. - /// The object. - /// The cancellation token. - /// The serialized object as string. - public async ValueTask SerializeAsync(T obj, CancellationToken cancellationToken) - { - using var stream = new MemoryStream(); - await this.SerializeAsync(obj, stream, cancellationToken).ConfigureAwait(false); - - return Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length); - } -} \ No newline at end of file +} diff --git a/src/Persistence/Json/ReferenceResolvingConverter.cs b/src/Persistence/Json/ReferenceResolvingConverter.cs index 9b68976107..7b7ecb2579 100644 --- a/src/Persistence/Json/ReferenceResolvingConverter.cs +++ b/src/Persistence/Json/ReferenceResolvingConverter.cs @@ -93,6 +93,29 @@ static ReferenceResolvingConverter() objParam) .Compile(); } + else if (x.CollectionInterface != null + && properties.All(p => p.Name != "Raw" + x.Property.Name && p.Name != "Joined" + x.Property.Name)) + { + // A collection without a public setter and without a "Raw" or "Joined" counterpart + // which would hold its data (e.g. ItemSlotType.ItemSlots). + // We can't assign a new collection, so we add the items to the existing one. + propertyType = x.CollectionInterface.GetGenericArguments()[0]; + + var collectionExpr = Expression.Convert(Expression.Property(tParam, x.Property), x.CollectionInterface); + var itemExpr = Expression.Convert(objParam, propertyType); + var addCall = Expression.Call(collectionExpr, x.CollectionInterface.GetMethod("Add")!, itemExpr); + + adder = Expression.Lambda>(addCall, tParam, objParam).Compile(); + } + else if (x.Property.GetSetMethod(nonPublic: true) is not null + && properties.All(p => p.Name != "Joined" + x.Property.Name)) + { + // A property with a non-public setter (e.g. ConstValueAttribute.Value). + // A compiled expression isn't allowed to call it, so we set it by reflection. + propertyType = x.Property.PropertyType; + var property = x.Property; + setter = (target, value) => property.SetValue(target, value); + } else { // not supported property, ignore... @@ -163,7 +186,7 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions private static void ReadProperty(ref Utf8JsonReader reader, JsonSerializerOptions options, T? item, (Type PropertyType, Action? Setter, Action? Adder) handler) { - _ = item ?? throw new InvalidOperationException("Item must be set here already. Is $id missing?"); + var target = item ?? throw new InvalidOperationException("Item must be set here already. Is $id missing?"); if (!reader.Read()) { @@ -174,30 +197,44 @@ private static void ReadProperty(ref Utf8JsonReader reader, JsonSerializerOption { if (JsonSerializer.Deserialize(ref reader, handler.PropertyType, options) is { } value) { - handler.Setter(item, value); + handler.Setter(target, value); } } + else if (reader.TokenType == JsonTokenType.StartArray) + { + ReadCollection(ref reader, options, target, handler); + } + else if (reader.TokenType == JsonTokenType.StartObject) + { + // When the json was written with a reference handler, collections are wrapped + // into an object which holds the "$id" of the collection and its "$values". + ReadWrappedCollection(ref reader, options, target, handler); + } else { - if (reader.TokenType == JsonTokenType.StartArray) + reader.Skip(); + } + } + + private static void ReadWrappedCollection(ref Utf8JsonReader reader, JsonSerializerOptions options, T item, (Type PropertyType, Action? Setter, Action? Adder) handler) + { + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) { - while (true) - { - if (!reader.Read()) - { - throw new JsonException($"Bad JSON"); - } - - if (reader.TokenType == JsonTokenType.EndArray) - { - break; - } - - if (JsonSerializer.Deserialize(ref reader, handler.PropertyType, options) is { } collectionItem) - { - handler.Adder!(item, collectionItem); - } - } + reader.Skip(); + continue; + } + + var isValues = reader.ValueTextEquals("$values"u8); + if (!reader.Read()) + { + throw new JsonException("Bad JSON"); + } + + if (isValues && reader.TokenType == JsonTokenType.StartArray) + { + ReadCollection(ref reader, options, item, handler); } else { @@ -206,6 +243,27 @@ private static void ReadProperty(ref Utf8JsonReader reader, JsonSerializerOption } } + private static void ReadCollection(ref Utf8JsonReader reader, JsonSerializerOptions options, T item, (Type PropertyType, Action? Setter, Action? Adder) handler) + { + while (true) + { + if (!reader.Read()) + { + throw new JsonException($"Bad JSON"); + } + + if (reader.TokenType == JsonTokenType.EndArray) + { + break; + } + + if (JsonSerializer.Deserialize(ref reader, handler.PropertyType, options) is { } collectionItem) + { + handler.Adder!(item, collectionItem); + } + } + } + /// /// Resolves the object reference by the reference handler of the serializer. /// diff --git a/src/Web/AdminPanel/Services/SetupService.cs b/src/Persistence/SetupService.cs similarity index 78% rename from src/Web/AdminPanel/Services/SetupService.cs rename to src/Persistence/SetupService.cs index ab1a5af2d1..f3303c3f27 100644 --- a/src/Web/AdminPanel/Services/SetupService.cs +++ b/src/Persistence/SetupService.cs @@ -1,14 +1,12 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // -namespace MUnique.OpenMU.Web.AdminPanel.Services; +namespace MUnique.OpenMU.Persistence; using System.Threading; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.Network.PlugIns; -using MUnique.OpenMU.Persistence; -using MUnique.OpenMU.Persistence.Initialization; using MUnique.OpenMU.PlugIns; using Nito.AsyncEx.Synchronous; @@ -34,6 +32,12 @@ public SetupService(IMigratableDatabaseContextProvider contextProvider, PlugInMa this._plugInManager = plugInManager; } + /// + /// Occurs before the database gets re-created, so that the subscribers can stop + /// accessing it until the initialization is finished. + /// + public event AsyncEventHandler? DatabaseInitializing; + /// /// Occurs when the database got initialized. /// @@ -122,12 +126,39 @@ public async Task InstallUpdatesAsync(CancellationToken cancellationToken) await this._contextProvider.WaitForUpdatedDatabaseAsync(cancellationToken).ConfigureAwait(false); } + /// + /// Restores the database with the given action, which is responsible for creating the database + /// and filling it with data. In contrast to , the database is + /// not created before - a snapshot brings its own schema. + /// + /// The action which restores the database. + public async Task RestoreDatabaseAsync(Func restore) + { + if (this.DatabaseInitializing is { } initializingHandler) + { + await initializingHandler.Invoke().ConfigureAwait(false); + } + + await restore().ConfigureAwait(false); + this._contextProvider.ResetCache(); + + if (this.DatabaseInitialized is { } eventHandler) + { + await eventHandler.Invoke().ConfigureAwait(false); + } + } + /// /// Creates the database. /// /// The data initialization action. public async Task CreateDatabaseAsync(Func dataInitialization) { + if (this.DatabaseInitializing is { } initializingHandler) + { + await initializingHandler.Invoke().ConfigureAwait(false); + } + using var update = await this._contextProvider.ReCreateDatabaseAsync().ConfigureAwait(false); await dataInitialization().ConfigureAwait(false); if (this.DatabaseInitialized is { } eventHandler) diff --git a/src/Persistence/SourceGenerator/EfCoreModelGenerator.cs b/src/Persistence/SourceGenerator/EfCoreModelGenerator.cs index 9f975b0d70..ff309d2911 100644 --- a/src/Persistence/SourceGenerator/EfCoreModelGenerator.cs +++ b/src/Persistence/SourceGenerator/EfCoreModelGenerator.cs @@ -143,6 +143,21 @@ public override int GetHashCode() } } + /// + /// Gets the constructor which Mapster should use to create the target object, if there is one. + /// It's only usable when all of its parameters can be taken from a property of the same name. + /// + /// The type of the data model. + /// The constructor which should be used; otherwise, null. + private static ConstructorInfo GetConstructorToMapWith(Type type) + { + return type.GetConstructors() + .Where(c => c.IsPublic && c.GetParameters().Length > 0) + .FirstOrDefault(c => c.GetParameters().All(parameter => + type.GetProperty(parameter.Name.ToPascalCase()) is { CanRead: true } property + && parameter.ParameterType.IsAssignableFrom(property.PropertyType))); + } + private static bool IsMemberOfAggregate(PropertyInfo propertyInfo) { if (propertyInfo?.Name.StartsWith("Raw") ?? false) @@ -210,12 +225,25 @@ private string GenerateMapsterConfigurator() .AppendLine($" Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<{type.FullName}, {type.FullName}>()") .AppendLine($" .Include<{type.Name}, BasicModel.{type.Name}>();") .AppendLine(); + + // Properties which can only be set through a constructor (e.g. ConstValueAttribute.Value) + // would be lost, because Mapster creates the target with its parameterless constructor. + // We can only do that when each parameter has a property of the same name to take the value from. + if (GetConstructorToMapWith(type) is { } constructor) + { + var arguments = string.Join(", ", constructor.GetParameters().Select(p => $"source.{p.Name.ToPascalCase()}")); + configs + .AppendLine($" Mapster.TypeAdapterConfig.GlobalSettings.ForType<{type.FullName}, BasicModel.{type.Name}>()") + .AppendLine($" .ConstructUsing(source => new BasicModel.{type.Name}({arguments}));") + .AppendLine(); + } } var source = $@"{string.Format(ModelGeneratorHelper.FileHeaderTemplate, "MapsterConfigurator")} namespace MUnique.OpenMU.Persistence.EntityFramework.Model; +using MUnique.OpenMU.DataModel.Composition; using MUnique.OpenMU.Persistence; using Mapster; @@ -239,6 +267,18 @@ public static void EnsureConfigured() Mapster.TypeAdapterConfig.GlobalSettings.Default.PreserveReference(true); Mapster.TypeAdapterConfig.GlobalSettings.Default.IgnoreMember((member, side) => member.Name.StartsWith(""Raw"")); + // Transient properties just hold run-time information and are not persisted. + // Some of them (e.g. of the SkillEntry) can't be mapped by Mapster at all, because their types are interfaces with events. + Mapster.TypeAdapterConfig.GlobalSettings.Default.IgnoreMember( + (member, side) => member.GetCustomAttributes(true).OfType().Any()); + + // Collections of value types (e.g. ItemSlotType.ItemSlots) are only filled when the collection of the + // destination is used. Otherwise, Mapster creates a new, empty one and the values would be lost. + Mapster.TypeAdapterConfig.GlobalSettings.Default.UseDestinationValue( + member => member.Type.IsGenericType + && member.Type.GetGenericTypeDefinition() == typeof(ICollection<>) + && member.Type.GetGenericArguments()[0].IsValueType); + {configs} isConfigured = true; }} diff --git a/src/Persistence/SourceGenerator/StringExtensions.cs b/src/Persistence/SourceGenerator/StringExtensions.cs index 0315642fb9..117f1b2b69 100644 --- a/src/Persistence/SourceGenerator/StringExtensions.cs +++ b/src/Persistence/SourceGenerator/StringExtensions.cs @@ -18,4 +18,14 @@ internal static string ToCamelCase(this string name) { return name.Substring(0, 1).ToLowerInvariant() + name.Substring(1); } + + /// + /// Converts the name to pascal case. + /// + /// The name which should be converted. + /// The converted name in pascal case. + internal static string ToPascalCase(this string name) + { + return name.Substring(0, 1).ToUpperInvariant() + name.Substring(1); + } } \ No newline at end of file diff --git a/src/Startup/ChatServerContainer.cs b/src/Startup/ChatServerContainer.cs index 2bab31ef45..84414cd512 100644 --- a/src/Startup/ChatServerContainer.cs +++ b/src/Startup/ChatServerContainer.cs @@ -8,7 +8,7 @@ namespace MUnique.OpenMU.Startup; using Microsoft.Extensions.Logging; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.Persistence; -using MUnique.OpenMU.Web.AdminPanel.Services; +using MUnique.OpenMU.Persistence.Initialization; /// /// A container which takes care of the . diff --git a/src/Startup/ConnectServerContainer.cs b/src/Startup/ConnectServerContainer.cs index a2fa073b8c..0e7ecc4637 100644 --- a/src/Startup/ConnectServerContainer.cs +++ b/src/Startup/ConnectServerContainer.cs @@ -1,6 +1,7 @@ // // Licensed under the MIT License. See LICENSE file in the project root for full license information. // + namespace MUnique.OpenMU.Startup; using System.Collections; @@ -11,7 +12,7 @@ namespace MUnique.OpenMU.Startup; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Persistence; -using MUnique.OpenMU.Web.AdminPanel.Services; +using MUnique.OpenMU.Persistence.Initialization; /// /// A container which keeps all s in one . diff --git a/src/Startup/GameServerContainer.cs b/src/Startup/GameServerContainer.cs index e2c4bc59ee..315d31660f 100644 --- a/src/Startup/GameServerContainer.cs +++ b/src/Startup/GameServerContainer.cs @@ -14,8 +14,8 @@ namespace MUnique.OpenMU.Startup; using MUnique.OpenMU.Network; using MUnique.OpenMU.Network.PlugIns; using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Persistence.Initialization; using MUnique.OpenMU.PlugIns; -using MUnique.OpenMU.Web.AdminPanel.Services; /// /// A container which keeps all s in one . @@ -157,6 +157,14 @@ protected override async Task StopInnerAsync(CancellationToken cancellationToken foreach (var gameServer in this._gameServers.Values) { await gameServer.StopAsync(cancellationToken).ConfigureAwait(false); + + // The game servers are created again when this container is started, so we dispose them here. + // Otherwise, the periodic tasks of their game context (e.g. of the castle siege) would still run. + if (gameServer is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + this._servers.Remove(gameServer); } diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs index 8f90125fd6..c2dfb92073 100644 --- a/src/Startup/Program.cs +++ b/src/Startup/Program.cs @@ -26,15 +26,14 @@ namespace MUnique.OpenMU.Startup; using MUnique.OpenMU.Network; using MUnique.OpenMU.Network.Analyzer; using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Persistence.AdminAuth; using MUnique.OpenMU.Persistence.EntityFramework; using MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; using MUnique.OpenMU.Persistence.EntityFramework.Json; -using MUnique.OpenMU.Persistence.Initialization; using MUnique.OpenMU.Persistence.Initialization.Version075; using MUnique.OpenMU.Persistence.InMemory; using MUnique.OpenMU.PlugIns; using MUnique.OpenMU.Web.AdminPanel; -using MUnique.OpenMU.Web.AdminPanel.Services; using MUnique.OpenMU.Web.AdminPanel.API; using MUnique.OpenMU.Web.Map.Map; using MUnique.OpenMU.Web.Shared; @@ -258,6 +257,22 @@ private async Task CreateHostAsync(string[] args) // The storage of the admin panel users has to be registered before the panel itself, // which only adds a fallback when nothing else is registered. builder.Services.AddAdminUserRepository(); + builder.Services.AddSingleton(s => + { + var contextProvider = s.GetRequiredService(); + if (contextProvider is IPersistenceContextProvider persistenceContextProvider) + { + return new BackupService(persistenceContextProvider, s.GetRequiredService()); + } + + return new InMemoryBackupService(s.GetRequiredService(), s.GetRequiredService()); + }); + if (!args.Contains("-demo")) + { + // A snapshot of the database is only possible when there is a real database. + builder.Services.AddSingleton(); + } + builder.AddAdminPanel(includeMapApp: true); } diff --git a/src/Startup/ServerContainerBase.cs b/src/Startup/ServerContainerBase.cs index 1010414f1c..3bc89b67dd 100644 --- a/src/Startup/ServerContainerBase.cs +++ b/src/Startup/ServerContainerBase.cs @@ -7,7 +7,7 @@ namespace MUnique.OpenMU.Startup; using System.Threading; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using MUnique.OpenMU.Web.AdminPanel.Services; +using MUnique.OpenMU.Persistence; /// /// Base class for a server container, which reacts on database recreations. @@ -32,6 +32,7 @@ protected ServerContainerBase(SetupService setupService, ILogger logger) public async Task StartAsync(CancellationToken cancellationToken) { await this.StartInnerAsync(cancellationToken).ConfigureAwait(false); + this._setupService.DatabaseInitializing += this.OnDatabaseInitializingAsync; this._setupService.DatabaseInitialized += this.OnDatabaseInitializedAsync; } @@ -39,6 +40,7 @@ public async Task StartAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken) { await this.StopInnerAsync(cancellationToken).ConfigureAwait(false); + this._setupService.DatabaseInitializing -= this.OnDatabaseInitializingAsync; this._setupService.DatabaseInitialized -= this.OnDatabaseInitializedAsync; } @@ -82,6 +84,21 @@ protected virtual async ValueTask BeforeStartAsync(bool onDatabaseInit, Cancella // can be overwritten } + private async ValueTask OnDatabaseInitializingAsync() + { + try + { + // The database is dropped and created again. We stop the servers before that happens, + // so that they don't run into errors while they access the non-existing database. + // They are started again when the initialization finished. + await this.StopInnerAsync(default).ConfigureAwait(false); + } + catch (Exception exception) + { + this._logger.LogError(exception, "Unexpected error when stopping the servers for the database creation."); + } + } + private async ValueTask OnDatabaseInitializedAsync() { try diff --git a/src/Web/AdminPanel/API/BackupController.cs b/src/Web/AdminPanel/API/BackupController.cs new file mode 100644 index 0000000000..b9e388bc5f --- /dev/null +++ b/src/Web/AdminPanel/API/BackupController.cs @@ -0,0 +1,73 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.API; + +using System.IO; +using System.Threading; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Web.AdminPanel.Auth; + +/// +/// API controller to download a backup archive or a database snapshot. +/// The restore of a backup is done on the setup page, so that the database is re-created and +/// the admin panel is notified about the new data. +/// +[Route("admin/backup")] +[Authorize(Policy = AdminPolicies.Administrator)] +public class BackupController : Controller +{ + private readonly IBackupService _backupService; + private readonly IDatabaseSnapshotService? _snapshotService; + + /// + /// Initializes a new instance of the class. + /// + /// The backup service. + /// The snapshot service, if the used persistence supports it. + public BackupController(IBackupService backupService, IDatabaseSnapshotService? snapshotService = null) + { + this._backupService = backupService; + this._snapshotService = snapshotService; + } + + /// + /// Downloads a backup archive containing the configuration and account data. + /// + /// If set to false, the accounts are not part of the backup. + /// The cancellation token. + /// The backup zip archive as a file download. + [HttpGet] + public async Task DownloadBackupAsync([FromQuery] bool includeAccounts, CancellationToken cancellationToken) + { + var stream = new MemoryStream(); + var options = new BackupOptions { IncludeAccounts = includeAccounts }; + await this._backupService.CreateBackupAsync(stream, options, cancellationToken).ConfigureAwait(false); + stream.Position = 0; + var fileName = $"backup_{DateTime.UtcNow:yyyyMMdd_HHmmss}.zip"; + return this.File(stream, "application/zip", fileName); + } + + /// + /// Downloads a snapshot of the database, which can only be restored into a database with the same schema. + /// + /// The cancellation token. + /// The snapshot zip archive as a file download. + [HttpGet("snapshot")] + public async Task DownloadSnapshotAsync(CancellationToken cancellationToken) + { + if (this._snapshotService is not { } snapshotService) + { + return this.NotFound("The used persistence doesn't support database snapshots."); + } + + var stream = new MemoryStream(); + await snapshotService.CreateSnapshotAsync(stream, cancellationToken).ConfigureAwait(false); + stream.Position = 0; + var fileName = $"snapshot_{DateTime.UtcNow:yyyyMMdd_HHmmss}.zip"; + return this.File(stream, "application/zip", fileName); + } +} diff --git a/src/Web/AdminPanel/Components/Install.razor.cs b/src/Web/AdminPanel/Components/Install.razor.cs index 11267d829f..7f26dc8ea2 100644 --- a/src/Web/AdminPanel/Components/Install.razor.cs +++ b/src/Web/AdminPanel/Components/Install.razor.cs @@ -6,8 +6,7 @@ namespace MUnique.OpenMU.Web.AdminPanel.Components; using Microsoft.AspNetCore.Components; using MUnique.OpenMU.Interfaces; -using MUnique.OpenMU.Persistence.Initialization; -using MUnique.OpenMU.Web.AdminPanel.Services; +using MUnique.OpenMU.Persistence; /// /// The component which allows to initialize the database. diff --git a/src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor.cs b/src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor.cs index 4bce6af162..d8678bafbe 100644 --- a/src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor.cs +++ b/src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor.cs @@ -8,6 +8,7 @@ namespace MUnique.OpenMU.Web.AdminPanel.Components.Layout; using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.Logging; using MUnique.OpenMU.Web.AdminPanel.Services; +using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Web.Shared.Components; using MUnique.OpenMU.Web.Shared.Services; diff --git a/src/Web/AdminPanel/Components/Layout/NavMenu.razor.cs b/src/Web/AdminPanel/Components/Layout/NavMenu.razor.cs index 4358c54108..5e78b91629 100644 --- a/src/Web/AdminPanel/Components/Layout/NavMenu.razor.cs +++ b/src/Web/AdminPanel/Components/Layout/NavMenu.razor.cs @@ -10,7 +10,6 @@ namespace MUnique.OpenMU.Web.AdminPanel.Components.Layout; using MUnique.OpenMU.Network.Analyzer; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Persistence.Initialization.Updates; -using MUnique.OpenMU.Web.AdminPanel.Services; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/Setup.razor b/src/Web/AdminPanel/Pages/Setup.razor index 06009a6f50..0101b192c3 100644 --- a/src/Web/AdminPanel/Pages/Setup.razor +++ b/src/Web/AdminPanel/Pages/Setup.razor @@ -28,15 +28,85 @@ else if (this.SetupService.IsUpdateRequired) } else { -

@Resources.DatabaseStatus: @Resources.UpToDate

- @if (!this._isDataInitialized) - { -

@Resources.InitializedGameVersion: @Resources.NoInitializedDataFound

- } - else +

@Resources.DatabaseStatus: @Resources.UpToDate

+ @if (!this._isDataInitialized) + { +

@Resources.InitializedGameVersion: @Resources.NoInitializedDataFound

+ } + else + { +

@Resources.InitializedGameVersion: @this._gameClientVersion

+ } + + +} + + +@if (this.SetupService.IsInstalled) +{ +
+ +
+ @if (this.SnapshotService is not null) { -

@Resources.InitializedGameVersion: @this._gameClientVersion

+
+

+ +

+
+
+

@Resources.DatabaseSnapshotDescription

+ @Resources.ExportSnapshot +

@Resources.SelectZipFileToRestoreSnapshot

+ +
+
+
} - - +
+

+ +

+
+
+
@Resources.ExportJsonBackup
+
+ + +
+ @Resources.ExportJsonBackup +
+
@Resources.ImportJsonBackup
+ @if (this._isImporting) + { +
+ + @Resources.ImportingBackupPleaseWait +
+ } + else if (this._importMessage is not null) + { +

@this._importMessage

+ } + else + { +

@Resources.SelectZipFileToRestore

+ } + +
+
+
+
} diff --git a/src/Web/AdminPanel/Pages/Setup.razor.cs b/src/Web/AdminPanel/Pages/Setup.razor.cs index 4a2001622e..fc4ea35ca7 100644 --- a/src/Web/AdminPanel/Pages/Setup.razor.cs +++ b/src/Web/AdminPanel/Pages/Setup.razor.cs @@ -4,13 +4,16 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; +using System.IO; + using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; using Microsoft.JSInterop; using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Web.AdminPanel.Components; using MUnique.OpenMU.Web.AdminPanel.Properties; -using MUnique.OpenMU.Web.AdminPanel.Services; /// /// The set up page. @@ -21,6 +24,18 @@ public partial class Setup private ClientVersion? _gameClientVersion; + private bool _includeAccounts = true; + + private bool _isImporting; + + private string? _importMessage; + + private bool _showJsonBackup; + + private bool _showSnapshotBackup = true; + + private string _importMessageCssClass = string.Empty; + /// /// Gets or sets a value indicating whether to show the component. /// @@ -32,6 +47,18 @@ public partial class Setup [Inject] public SetupService SetupService { get; set; } = null!; + /// + /// Gets or sets the backup service. + /// + [Inject] + public IBackupService BackupService { get; set; } = null!; + + /// + /// Gets or sets the database snapshot service. It's only available for a real database. + /// + [Inject] + public IDatabaseSnapshotService? SnapshotService { get; set; } + /// /// Gets or sets the javascript runtime. /// @@ -48,6 +75,17 @@ protected override async Task OnInitializedAsync() } } + private static async Task ReadFileAsync(IBrowserFile file) + { + // BrowserFileStream doesn't support synchronous reads (which ZipArchive requires), + // so copy it into a MemoryStream first. Pre-size with file.Size to avoid reallocations. + var memoryStream = new MemoryStream((int)Math.Min(file.Size, int.MaxValue)); + await using var browserStream = file.OpenReadStream(maxAllowedSize: long.MaxValue); + await browserStream.CopyToAsync(memoryStream).ConfigureAwait(false); + memoryStream.Position = 0; + return memoryStream; + } + private Task OnUpdateClickAsync() { return this.SetupService.InstallUpdatesAsync(default); @@ -65,4 +103,75 @@ private async Task OnReInstallClickAsync() this.ShowInstall = true; } } -} \ No newline at end of file + + private async Task OnSnapshotFileChangeAsync(InputFileChangeEventArgs e) + { + if (this.SnapshotService is not { } snapshotService) + { + return; + } + + this._importMessage = null; + this._isImporting = true; + await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); + + try + { + using var memoryStream = await ReadFileAsync(e.File).ConfigureAwait(false); + if (await snapshotService.GetRestoreBlockingReasonAsync(memoryStream).ConfigureAwait(false) is { } blockingReason) + { + this._importMessage = blockingReason; + this._importMessageCssClass = "text-danger"; + return; + } + + await this.SetupService.RestoreDatabaseAsync(() => snapshotService.RestoreSnapshotAsync(memoryStream)).ConfigureAwait(false); + this._importMessage = Resources.BackupImportSucceeded; + this._importMessageCssClass = "text-success"; + } + catch (Exception ex) + { + this._importMessage = $"{Resources.BackupImportFailed} {ex.Message}"; + this._importMessageCssClass = "text-danger"; + } + finally + { + this._isImporting = false; + await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); + } + } + + private async Task OnImportFileChangeAsync(InputFileChangeEventArgs e) + { + var file = e.File; + this._importMessage = null; + this._isImporting = true; + await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); + + try + { + using var memoryStream = await ReadFileAsync(file).ConfigureAwait(false); + if (!this.BackupService.ContainsRestorableData(memoryStream)) + { + this._importMessage = Resources.SelectedFileIsNoBackup; + this._importMessageCssClass = "text-danger"; + return; + } + + await this.SetupService.CreateDatabaseAsync( + () => this.BackupService.RestoreBackupAsync(memoryStream)).ConfigureAwait(false); + this._importMessage = Resources.BackupImportSucceeded; + this._importMessageCssClass = "text-success"; + } + catch (Exception ex) + { + this._importMessage = $"{Resources.BackupImportFailed} {ex.Message}"; + this._importMessageCssClass = "text-danger"; + } + finally + { + this._isImporting = false; + await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); + } + } +} diff --git a/src/Web/AdminPanel/Pages/Updates.razor.cs b/src/Web/AdminPanel/Pages/Updates.razor.cs index 4c6a4dffd8..db41ffc78a 100644 --- a/src/Web/AdminPanel/Pages/Updates.razor.cs +++ b/src/Web/AdminPanel/Pages/Updates.razor.cs @@ -6,8 +6,8 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; +using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Persistence.Initialization.Updates; -using MUnique.OpenMU.Web.AdminPanel.Services; /// /// The set-up page. diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index ae45937b76..dda6a831f5 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -61,950 +61,950 @@ internal Resources() { } /// - /// Looks up a localized string similar to Create. + /// Looks up a localized string similar to About. /// - public static string Create { + public static string About { get { - return ResourceManager.GetString("Create", resourceCulture); + return ResourceManager.GetString("About", resourceCulture); } } /// - /// Looks up a localized string similar to Reload. + /// Looks up a localized string similar to Access denied. /// - public static string Reload { + public static string AccessDenied { get { - return ResourceManager.GetString("Reload", resourceCulture); + return ResourceManager.GetString("AccessDenied", resourceCulture); } } /// - /// Looks up a localized string similar to Admin Users. + /// Looks up a localized string similar to Your account is not permitted to open this page.. /// - public static string AdminUsers { + public static string AccessDeniedDescription { get { - return ResourceManager.GetString("AdminUsers", resourceCulture); + return ResourceManager.GetString("AccessDeniedDescription", resourceCulture); } } /// - /// Looks up a localized string similar to Actions. + /// Looks up a localized string similar to Too many failed attempts. Please try again later.. /// - public static string Actions { + public static string AccountLockedOut { get { - return ResourceManager.GetString("Actions", resourceCulture); + return ResourceManager.GetString("AccountLockedOut", resourceCulture); } } /// - /// Looks up a localized string similar to Change password. + /// Looks up a localized string similar to Accounts. /// - public static string ChangePassword { + public static string Accounts { get { - return ResourceManager.GetString("ChangePassword", resourceCulture); + return ResourceManager.GetString("Accounts", resourceCulture); } } /// - /// Looks up a localized string similar to Delete. + /// Looks up a localized string similar to Account security. /// - public static string Delete { + public static string AccountSecurity { get { - return ResourceManager.GetString("Delete", resourceCulture); + return ResourceManager.GetString("AccountSecurity", resourceCulture); } } /// - /// Looks up a localized string similar to Duplicate. + /// Looks up a localized string similar to Action. /// - public static string Duplicate { + public static string Action { get { - return ResourceManager.GetString("Duplicate", resourceCulture); + return ResourceManager.GetString("Action", resourceCulture); } } /// - /// Looks up a localized string similar to Duplicated '{0}' successfully.. + /// Looks up a localized string similar to Actions. /// - public static string DuplicatedSuccessfully { + public static string Actions { get { - return ResourceManager.GetString("DuplicatedSuccessfully", resourceCulture); + return ResourceManager.GetString("Actions", resourceCulture); } } /// - /// Looks up a localized string similar to Couldn't find '{0}' to duplicate.. + /// Looks up a localized string similar to Activate. /// - public static string CouldNotFindToDuplicate { + public static string Activate { get { - return ResourceManager.GetString("CouldNotFindToDuplicate", resourceCulture); + return ResourceManager.GetString("Activate", resourceCulture); } } /// - /// Looks up a localized string similar to Type '{0}' does not support cloning.. + /// Looks up a localized string similar to Active Offline Player. /// - public static string TypeDoesNotSupportCloning { + public static string ActiveOfflinePlayer { get { - return ResourceManager.GetString("TypeDoesNotSupportCloning", resourceCulture); + return ResourceManager.GetString("ActiveOfflinePlayer", resourceCulture); } } /// - /// Looks up a localized string similar to Failed to clone '{0}'.. + /// Looks up a localized string similar to Add New. /// - public static string FailedToClone { + public static string AddNew { get { - return ResourceManager.GetString("FailedToClone", resourceCulture); + return ResourceManager.GetString("AddNew", resourceCulture); } } /// - /// Looks up a localized string similar to Error duplicating '{0}': {1}. + /// Looks up a localized string similar to Admin Users. /// - public static string ErrorDuplicating { + public static string AdminUsers { get { - return ResourceManager.GetString("ErrorDuplicating", resourceCulture); + return ResourceManager.GetString("AdminUsers", resourceCulture); } } /// - /// Looks up a localized string similar to Create User. + /// Looks up a localized string similar to All. /// - public static string CreateUser { + public static string All { get { - return ResourceManager.GetString("CreateUser", resourceCulture); + return ResourceManager.GetString("All", resourceCulture); } } /// - /// Looks up a localized string similar to Loading .... + /// Looks up a localized string similar to All Game Servers. /// - public static string Loading { + public static string AllGameServers { get { - return ResourceManager.GetString("Loading", resourceCulture); + return ResourceManager.GetString("AllGameServers", resourceCulture); } } /// - /// Looks up a localized string similar to Create Connect Server. + /// Looks up a localized string similar to An error occurred while processing your request. /// - public static string CreateConnectServer { + public static string AnErrorOccurredWhileProcessingYourRequest { get { - return ResourceManager.GetString("CreateConnectServer", resourceCulture); + return ResourceManager.GetString("AnErrorOccurredWhileProcessingYourRequest", resourceCulture); } } /// - /// Looks up a localized string similar to Create Game Server. + /// Looks up a localized string similar to The API key has been created.. /// - public static string CreateGameServer { + public static string ApiKeyCreated { get { - return ResourceManager.GetString("CreateGameServer", resourceCulture); + return ResourceManager.GetString("ApiKeyCreated", resourceCulture); } } /// - /// Looks up a localized string similar to Server with Id {0} already exists. Please use another value.. + /// Looks up a localized string similar to The API key has been deleted.. /// - public static string ServerWithIdAlreadyExists { + public static string ApiKeyDeleted { get { - return ResourceManager.GetString("ServerWithIdAlreadyExists", resourceCulture); + return ResourceManager.GetString("ApiKeyDeleted", resourceCulture); } } /// - /// Looks up a localized string similar to A server with tcp port {0} already exists. Please use another tcp port.. + /// Looks up a localized string similar to The API key has been disabled.. /// - public static string ServerWithPortAlreadyExists { + public static string ApiKeyDisabled { get { - return ResourceManager.GetString("ServerWithPortAlreadyExists", resourceCulture); + return ResourceManager.GetString("ApiKeyDisabled", resourceCulture); } } /// - /// Looks up a localized string similar to Creating Configuration .... + /// Looks up a localized string similar to The API key has been enabled.. /// - public static string CreatingConfigurationInfo { + public static string ApiKeyEnabled { get { - return ResourceManager.GetString("CreatingConfigurationInfo", resourceCulture); + return ResourceManager.GetString("ApiKeyEnabled", resourceCulture); } } /// - /// Looks up a localized string similar to Saving Configuration .... + /// Looks up a localized string similar to Application. /// - public static string SavingConfigurationInfo { + public static string ApiKeyName { get { - return ResourceManager.GetString("SavingConfigurationInfo", resourceCulture); + return ResourceManager.GetString("ApiKeyName", resourceCulture); } } /// - /// Looks up a localized string similar to The connection server configuration has been saved. Initializing connect server .... + /// Looks up a localized string similar to Key. /// - public static string ConnectionServerConfigurationSaved { + public static string ApiKeyPrefix { get { - return ResourceManager.GetString("ConnectionServerConfigurationSaved", resourceCulture); + return ResourceManager.GetString("ApiKeyPrefix", resourceCulture); } } /// - /// Looks up a localized string similar to Initializing Connect Server .... + /// Looks up a localized string similar to API keys. /// - public static string InitializingConnectServerInfo { + public static string ApiKeys { get { - return ResourceManager.GetString("InitializingConnectServerInfo", resourceCulture); + return ResourceManager.GetString("ApiKeys", resourceCulture); } } /// - /// Looks up a localized string similar to No changes have been saved.. + /// Looks up a localized string similar to External applications like a game launcher or a website authenticate themselves at the public API under /api with one of these keys. Give each application its own key, so a single one can be revoked.. /// - public static string NoChangesSaved { + public static string ApiKeysDescription { get { - return ResourceManager.GetString("NoChangesSaved", resourceCulture); + return ResourceManager.GetString("ApiKeysDescription", resourceCulture); } } /// - /// Looks up a localized string similar to An unexpected error occurred: {0}.. + /// Looks up a localized string similar to Copy this key now. /// - public static string UnexpectedErrorOccurred { + public static string ApiKeyShownOnce { get { - return ResourceManager.GetString("UnexpectedErrorOccurred", resourceCulture); + return ResourceManager.GetString("ApiKeyShownOnce", resourceCulture); } } /// - /// Looks up a localized string similar to The game server configuration has been saved. Initializing game server .... + /// Looks up a localized string similar to Only the hash of the key is stored, so this is the only time it's shown. If it gets lost, delete the key and create a new one.. /// - public static string GameServerConfigurationSavedInfo { + public static string ApiKeyShownOnceDescription { get { - return ResourceManager.GetString("GameServerConfigurationSavedInfo", resourceCulture); + return ResourceManager.GetString("ApiKeyShownOnceDescription", resourceCulture); } } /// - /// Looks up a localized string similar to Initializing Game Server .... + /// Looks up a localized string similar to Applying updates .... /// - public static string InitializingGameServerInfo { + public static string ApplyingUpdates { get { - return ResourceManager.GetString("InitializingGameServerInfo", resourceCulture); + return ResourceManager.GetString("ApplyingUpdates", resourceCulture); } } /// - /// Looks up a localized string similar to Edit. + /// Looks up a localized string similar to Apply selected updates. /// - public static string Edit { + public static string ApplySelectedUpdates { get { - return ResourceManager.GetString("Edit", resourceCulture); + return ResourceManager.GetString("ApplySelectedUpdates", resourceCulture); } } /// - /// Looks up a localized string similar to Refresh. + /// Looks up a localized string similar to Authenticator code. /// - public static string Refresh { + public static string AuthenticatorCode { get { - return ResourceManager.GetString("Refresh", resourceCulture); + return ResourceManager.GetString("AuthenticatorCode", resourceCulture); } } /// - /// Looks up a localized string similar to The changes have been saved.. + /// Looks up a localized string similar to available updates. /// - public static string SavedChanges { + public static string AvailableUpdates { get { - return ResourceManager.GetString("SavedChanges", resourceCulture); + return ResourceManager.GetString("AvailableUpdates", resourceCulture); } } /// - /// Looks up a localized string similar to There were no changes to save.. + /// Looks up a localized string similar to Back. /// - public static string NoChangesToSave { + public static string Back { get { - return ResourceManager.GetString("NoChangesToSave", resourceCulture); + return ResourceManager.GetString("Back", resourceCulture); } } /// - /// Looks up a localized string similar to Failed, context not initialized. + /// Looks up a localized string similar to Backup import failed.. /// - public static string FailedByUninitializedContext { + public static string BackupImportFailed { get { - return ResourceManager.GetString("FailedByUninitializedContext", resourceCulture); + return ResourceManager.GetString("BackupImportFailed", resourceCulture); } } /// - /// Looks up a localized string similar to There are unsaved changes. Are you sure you want to discard them?. + /// Looks up a localized string similar to Backup import succeeded. Please restart the server process to apply the changes.. /// - public static string UnsavedChangesQuestion { + public static string BackupImportSucceeded { get { - return ResourceManager.GetString("UnsavedChangesQuestion", resourceCulture); + return ResourceManager.GetString("BackupImportSucceeded", resourceCulture); } } /// - /// Looks up a localized string similar to Download as JSON. + /// Looks up a localized string similar to Cancel. /// - public static string DownloadAsJson { + public static string Cancel { get { - return ResourceManager.GetString("DownloadAsJson", resourceCulture); + return ResourceManager.GetString("Cancel", resourceCulture); } } /// - /// Looks up a localized string similar to Could not load the data. Check the logs for details.. + /// Looks up a localized string similar to The last remaining user can't be deleted.. /// - public static string LoadingErrorCheckLog { + public static string CannotDeleteLastUser { get { - return ResourceManager.GetString("LoadingErrorCheckLog", resourceCulture); + return ResourceManager.GetString("CannotDeleteLastUser", resourceCulture); } } /// - /// Looks up a localized string similar to Error. + /// Looks up a localized string similar to The bootstrap user is defined by the configuration and can't be changed here.. /// - public static string Error { + public static string CannotModifyBootstrapUser { get { - return ResourceManager.GetString("Error", resourceCulture); + return ResourceManager.GetString("CannotModifyBootstrapUser", resourceCulture); } } /// - /// Looks up a localized string similar to Map Editor. + /// Looks up a localized string similar to Can't connect to the database. Probably not created yet.. /// - public static string MapEditor { + public static string CantConnectToTheDatabaseProbablyNotCreatedYet { get { - return ResourceManager.GetString("MapEditor", resourceCulture); + return ResourceManager.GetString("CantConnectToTheDatabaseProbablyNotCreatedYet", resourceCulture); } } /// - /// Looks up a localized string similar to Search. + /// Looks up a localized string similar to Captured Packets. /// - public static string Search { + public static string CapturedPackets { get { - return ResourceManager.GetString("Search", resourceCulture); + return ResourceManager.GetString("CapturedPackets", resourceCulture); } } /// - /// Looks up a localized string similar to Add New. + /// Looks up a localized string similar to Change password. /// - public static string AddNew { + public static string ChangePassword { get { - return ResourceManager.GetString("AddNew", resourceCulture); + return ResourceManager.GetString("ChangePassword", resourceCulture); } } /// - /// Looks up a localized string similar to Could not load the map data. Check the logs for details.. + /// Looks up a localized string similar to Character. /// - public static string CouldNotLoadMapDataCheckTheLogs { + public static string Character { get { - return ResourceManager.GetString("CouldNotLoadMapDataCheckTheLogs", resourceCulture); + return ResourceManager.GetString("Character", resourceCulture); } } /// - /// Looks up a localized string similar to An unexpected error occurred: {0}. See logs for more details.. + /// Looks up a localized string similar to Character classes. /// - public static string UnexpectedErrorCheckLogs { + public static string CharacterClasses { get { - return ResourceManager.GetString("UnexpectedErrorCheckLogs", resourceCulture); + return ResourceManager.GetString("CharacterClasses", resourceCulture); } } /// - /// Looks up a localized string similar to An unhandled error has occurred.. + /// Looks up a localized string similar to Chat commands. /// - public static string UnhandledErrorOccurred { + public static string ChatCommands { get { - return ResourceManager.GetString("UnhandledErrorOccurred", resourceCulture); + return ResourceManager.GetString("ChatCommands", resourceCulture); } } /// - /// Looks up a localized string similar to <strong>The Development environment shouldn't be enabled for deployed applications.</strong> - /// It can result in displaying sensitive information from exceptions to end users. - /// For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong> - /// and restarting the app.. + /// Looks up a localized string similar to Chat Server. /// - public static string DevelopmentEnvironmentWarning { + public static string ChatServer { get { - return ResourceManager.GetString("DevelopmentEnvironmentWarning", resourceCulture); + return ResourceManager.GetString("ChatServer", resourceCulture); } } /// - /// Looks up a localized string similar to Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.. + /// Looks up a localized string similar to Clear. /// - public static string SwappingToDevForMoreInformation { + public static string ClearPackets { get { - return ResourceManager.GetString("SwappingToDevForMoreInformation", resourceCulture); + return ResourceManager.GetString("ClearPackets", resourceCulture); } } /// - /// Looks up a localized string similar to An error occurred while processing your request. + /// Looks up a localized string similar to Close. /// - public static string AnErrorOccurredWhileProcessingYourRequest { + public static string Close { get { - return ResourceManager.GetString("AnErrorOccurredWhileProcessingYourRequest", resourceCulture); + return ResourceManager.GetString("Close", resourceCulture); } } /// - /// Looks up a localized string similar to Development Mode. + /// Looks up a localized string similar to Command. /// - public static string DevelopmentMode { + public static string CommandColumn { get { - return ResourceManager.GetString("DevelopmentMode", resourceCulture); + return ResourceManager.GetString("CommandColumn", resourceCulture); } } /// - /// Looks up a localized string similar to Game Server. + /// Looks up a localized string similar to Description. /// - public static string GameServer { + public static string CommandDescription { get { - return ResourceManager.GetString("GameServer", resourceCulture); + return ResourceManager.GetString("CommandDescription", resourceCulture); } } /// - /// Looks up a localized string similar to Welcome to the admin panel of OpenMU.. + /// Looks up a localized string similar to Usage. /// - public static string WelcomeMessage { + public static string CommandUsage { get { - return ResourceManager.GetString("WelcomeMessage", resourceCulture); + return ResourceManager.GetString("CommandUsage", resourceCulture); } } /// - /// Looks up a localized string similar to OpenMU AdminPanel. + /// Looks up a localized string similar to To finish, enter the code which your app shows now.. /// - public static string OpenMUAdminPanel { + public static string ConfirmSetupHint { get { - return ResourceManager.GetString("OpenMUAdminPanel", resourceCulture); + return ResourceManager.GetString("ConfirmSetupHint", resourceCulture); } } /// - /// Looks up a localized string similar to About. + /// Looks up a localized string similar to Connections. /// - public static string About { + public static string Connections { get { - return ResourceManager.GetString("About", resourceCulture); + return ResourceManager.GetString("Connections", resourceCulture); } } /// - /// Looks up a localized string similar to Log Files. + /// Looks up a localized string similar to The connection server configuration has been saved. Initializing connect server .... /// - public static string LogFiles { + public static string ConnectionServerConfigurationSaved { get { - return ResourceManager.GetString("LogFiles", resourceCulture); + return ResourceManager.GetString("ConnectionServerConfigurationSaved", resourceCulture); } } /// - /// Looks up a localized string similar to Logs. + /// Looks up a localized string similar to Connect Server. /// - public static string Logs { + public static string ConnectServer { get { - return ResourceManager.GetString("Logs", resourceCulture); + return ResourceManager.GetString("ConnectServer", resourceCulture); } } /// - /// Looks up a localized string similar to Metrics. + /// Looks up a localized string similar to The API key has been copied to the clipboard.. /// - public static string Metrics { + public static string CopiedToClipboard { get { - return ResourceManager.GetString("Metrics", resourceCulture); + return ResourceManager.GetString("CopiedToClipboard", resourceCulture); } } /// - /// Looks up a localized string similar to Tracing. + /// Looks up a localized string similar to Copy. /// - public static string Tracing { + public static string CopyToClipboard { get { - return ResourceManager.GetString("Tracing", resourceCulture); + return ResourceManager.GetString("CopyToClipboard", resourceCulture); } } /// - /// Looks up a localized string similar to File name. + /// Looks up a localized string similar to The API key could not be copied. Please select and copy it by hand.. /// - public static string FileName { + public static string CopyToClipboardFailed { get { - return ResourceManager.GetString("FileName", resourceCulture); + return ResourceManager.GetString("CopyToClipboardFailed", resourceCulture); } } /// - /// Looks up a localized string similar to Last update. + /// Looks up a localized string similar to Couldn't find '{0}' to delete.. /// - public static string LastUpdate { + public static string CouldNotFindToDelete { get { - return ResourceManager.GetString("LastUpdate", resourceCulture); + return ResourceManager.GetString("CouldNotFindToDelete", resourceCulture); } } /// - /// Looks up a localized string similar to Size. + /// Looks up a localized string similar to Couldn't find '{0}' to duplicate.. /// - public static string Size { + public static string CouldNotFindToDuplicate { get { - return ResourceManager.GetString("Size", resourceCulture); + return ResourceManager.GetString("CouldNotFindToDuplicate", resourceCulture); } } /// - /// Looks up a localized string similar to Server-ID. + /// Looks up a localized string similar to Could not load the map data. Check the logs for details.. /// - public static string ServerID { + public static string CouldNotLoadMapDataCheckTheLogs { get { - return ResourceManager.GetString("ServerID", resourceCulture); + return ResourceManager.GetString("CouldNotLoadMapDataCheckTheLogs", resourceCulture); } } /// - /// Looks up a localized string similar to Disconnect. + /// Looks up a localized string similar to Create. /// - public static string Disconnect { + public static string Create { get { - return ResourceManager.GetString("Disconnect", resourceCulture); + return ResourceManager.GetString("Create", resourceCulture); } } /// - /// Looks up a localized string similar to Live Map. + /// Looks up a localized string similar to Create API key. /// - public static string LiveMap { + public static string CreateApiKey { get { - return ResourceManager.GetString("LiveMap", resourceCulture); + return ResourceManager.GetString("CreateApiKey", resourceCulture); } } /// - /// Looks up a localized string similar to All. + /// Looks up a localized string similar to Create Connect Server. /// - public static string All { + public static string CreateConnectServer { get { - return ResourceManager.GetString("All", resourceCulture); + return ResourceManager.GetString("CreateConnectServer", resourceCulture); } } /// - /// Looks up a localized string similar to Merchants. + /// Looks up a localized string similar to New object successfully created.. /// - public static string Merchants { + public static string CreatedSuccessfully { get { - return ResourceManager.GetString("Merchants", resourceCulture); + return ResourceManager.GetString("CreatedSuccessfully", resourceCulture); } } /// - /// Looks up a localized string similar to Back. + /// Looks up a localized string similar to Create the first user. /// - public static string Back { + public static string CreateFirstUser { get { - return ResourceManager.GetString("Back", resourceCulture); + return ResourceManager.GetString("CreateFirstUser", resourceCulture); } } /// - /// Looks up a localized string similar to Discard changes. + /// Looks up a localized string similar to Create Game Server. /// - public static string DiscardChanges { + public static string CreateGameServer { get { - return ResourceManager.GetString("DiscardChanges", resourceCulture); + return ResourceManager.GetString("CreateGameServer", resourceCulture); } } /// - /// Looks up a localized string similar to Save changes. + /// Looks up a localized string similar to Create User. /// - public static string SaveChanges { + public static string CreateUser { get { - return ResourceManager.GetString("SaveChanges", resourceCulture); + return ResourceManager.GetString("CreateUser", resourceCulture); } } /// - /// Looks up a localized string similar to Extension Point. + /// Looks up a localized string similar to Creating Configuration .... /// - public static string ExtensionPoint { + public static string CreatingConfigurationInfo { get { - return ResourceManager.GetString("ExtensionPoint", resourceCulture); + return ResourceManager.GetString("CreatingConfigurationInfo", resourceCulture); } } /// - /// Looks up a localized string similar to Plugin Name. + /// Looks up a localized string similar to Current State. /// - public static string PluginName { + public static string CurrentState { get { - return ResourceManager.GetString("PluginName", resourceCulture); + return ResourceManager.GetString("CurrentState", resourceCulture); } } /// - /// Looks up a localized string similar to Plugin Type. + /// Looks up a localized string similar to Database Snapshot. /// - public static string PluginType { + public static string DatabaseSnapshot { get { - return ResourceManager.GetString("PluginType", resourceCulture); + return ResourceManager.GetString("DatabaseSnapshot", resourceCulture); } } /// - /// Looks up a localized string similar to Plugins. + /// Looks up a localized string similar to A snapshot contains all data of the database and is created and restored a lot faster. It can be restored by this or a newer version of the server, which applies its database updates to the restored data. To transfer data to an older version, use the backup above.. /// - public static string Plugins { + public static string DatabaseSnapshotDescription { get { - return ResourceManager.GetString("Plugins", resourceCulture); + return ResourceManager.GetString("DatabaseSnapshotDescription", resourceCulture); } } /// - /// Looks up a localized string similar to Deactivate. + /// Looks up a localized string similar to Database status. /// - public static string Deactivate { + public static string DatabaseStatus { get { - return ResourceManager.GetString("Deactivate", resourceCulture); + return ResourceManager.GetString("DatabaseStatus", resourceCulture); } } /// - /// Looks up a localized string similar to Activate. + /// Looks up a localized string similar to Deactivate. /// - public static string Activate { + public static string Deactivate { get { - return ResourceManager.GetString("Activate", resourceCulture); + return ResourceManager.GetString("Deactivate", resourceCulture); } } /// - /// Looks up a localized string similar to Servers. + /// Looks up a localized string similar to Delete. /// - public static string Servers { + public static string Delete { get { - return ResourceManager.GetString("Servers", resourceCulture); + return ResourceManager.GetString("Delete", resourceCulture); } } /// - /// Looks up a localized string similar to Server Name. + /// Looks up a localized string similar to Delete API key. /// - public static string ServerName { + public static string DeleteApiKey { get { - return ResourceManager.GetString("ServerName", resourceCulture); + return ResourceManager.GetString("DeleteApiKey", resourceCulture); } } /// - /// Looks up a localized string similar to Players. + /// Looks up a localized string similar to Do you really want to delete the API key of '{0}'? The application which uses it stops working immediately.. /// - public static string PlayerCount { + public static string DeleteApiKeyQuestion { get { - return ResourceManager.GetString("PlayerCount", resourceCulture); + return ResourceManager.GetString("DeleteApiKeyQuestion", resourceCulture); } } /// - /// Looks up a localized string similar to Current State. + /// Looks up a localized string similar to Couldn't delete '{0}', probably because it's referenced by another object. For details, see log. /// - public static string CurrentState { + public static string DeleteFailedReferenced { get { - return ResourceManager.GetString("CurrentState", resourceCulture); + return ResourceManager.GetString("DeleteFailedReferenced", resourceCulture); } } /// - /// Looks up a localized string similar to Connect Server. + /// Looks up a localized string similar to <strong>The Development environment shouldn't be enabled for deployed applications.</strong> + /// It can result in displaying sensitive information from exceptions to end users. + /// For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong> + /// and restarting the app.. /// - public static string ConnectServer { + public static string DevelopmentEnvironmentWarning { get { - return ResourceManager.GetString("ConnectServer", resourceCulture); + return ResourceManager.GetString("DevelopmentEnvironmentWarning", resourceCulture); } } /// - /// Looks up a localized string similar to Reload configuration and restart all Game Servers. + /// Looks up a localized string similar to Development Mode. /// - public static string ReloadConfigurationAndRestartAllGameServers { + public static string DevelopmentMode { get { - return ResourceManager.GetString("ReloadConfigurationAndRestartAllGameServers", resourceCulture); + return ResourceManager.GetString("DevelopmentMode", resourceCulture); } } /// - /// Looks up a localized string similar to Database status. + /// Looks up a localized string similar to Direction. /// - public static string DatabaseStatus { + public static string Direction { get { - return ResourceManager.GetString("DatabaseStatus", resourceCulture); + return ResourceManager.GetString("Direction", resourceCulture); } } /// - /// Looks up a localized string similar to Can't connect to the database. Probably not created yet.. + /// Looks up a localized string similar to Disable. /// - public static string CantConnectToTheDatabaseProbablyNotCreatedYet { + public static string Disable { get { - return ResourceManager.GetString("CantConnectToTheDatabaseProbablyNotCreatedYet", resourceCulture); + return ResourceManager.GetString("Disable", resourceCulture); } } /// - /// Looks up a localized string similar to Initialized game version. + /// Looks up a localized string similar to Disabled. /// - public static string InitializedGameVersion { + public static string Disabled { get { - return ResourceManager.GetString("InitializedGameVersion", resourceCulture); + return ResourceManager.GetString("Disabled", resourceCulture); } } /// - /// Looks up a localized string similar to No initialized data found!. + /// Looks up a localized string similar to Disable two-factor authentication. /// - public static string NoInitializedDataFound { + public static string DisableTwoFactor { get { - return ResourceManager.GetString("NoInitializedDataFound", resourceCulture); + return ResourceManager.GetString("DisableTwoFactor", resourceCulture); } } /// - /// Looks up a localized string similar to Re-install. + /// Looks up a localized string similar to Discard changes. /// - public static string ReInstall { + public static string DiscardChanges { get { - return ResourceManager.GetString("ReInstall", resourceCulture); + return ResourceManager.GetString("DiscardChanges", resourceCulture); } } /// - /// Looks up a localized string similar to Update. + /// Looks up a localized string similar to Disconnect. /// - public static string Update { + public static string Disconnect { get { - return ResourceManager.GetString("Update", resourceCulture); + return ResourceManager.GetString("Disconnect", resourceCulture); } } /// - /// Looks up a localized string similar to Up-to-date. + /// Looks up a localized string similar to Download as JSON. /// - public static string UpToDate { + public static string DownloadAsJson { get { - return ResourceManager.GetString("UpToDate", resourceCulture); + return ResourceManager.GetString("DownloadAsJson", resourceCulture); } } /// - /// Looks up a localized string similar to Not created. + /// Looks up a localized string similar to Download File. /// - public static string NotCreated { + public static string DownloadFile { get { - return ResourceManager.GetString("NotCreated", resourceCulture); + return ResourceManager.GetString("DownloadFile", resourceCulture); } } /// - /// Looks up a localized string similar to Update required. + /// Looks up a localized string similar to Drop item groups. /// - public static string UpdateRequired { + public static string DropItemGroups { get { - return ResourceManager.GetString("UpdateRequired", resourceCulture); + return ResourceManager.GetString("DropItemGroups", resourceCulture); } } /// - /// Looks up a localized string similar to Setup. + /// Looks up a localized string similar to Duplicate. /// - public static string Setup { + public static string Duplicate { get { - return ResourceManager.GetString("Setup", resourceCulture); + return ResourceManager.GetString("Duplicate", resourceCulture); } } /// - /// Looks up a localized string similar to Home. + /// Looks up a localized string similar to Duplicated '{0}' successfully.. /// - public static string Home { + public static string DuplicatedSuccessfully { get { - return ResourceManager.GetString("Home", resourceCulture); + return ResourceManager.GetString("DuplicatedSuccessfully", resourceCulture); } } /// - /// Looks up a localized string similar to Accounts. + /// Looks up a localized string similar to Edit. /// - public static string Accounts { + public static string Edit { get { - return ResourceManager.GetString("Accounts", resourceCulture); + return ResourceManager.GetString("Edit", resourceCulture); } } /// - /// Looks up a localized string similar to Online Accounts. + /// Looks up a localized string similar to Enable. /// - public static string OnlineAccounts { + public static string Enable { get { - return ResourceManager.GetString("OnlineAccounts", resourceCulture); + return ResourceManager.GetString("Enable", resourceCulture); } } /// - /// Looks up a localized string similar to Updates. + /// Looks up a localized string similar to Enabled. /// - public static string Updates { + public static string Enabled { get { - return ResourceManager.GetString("Updates", resourceCulture); + return ResourceManager.GetString("Enabled", resourceCulture); } } /// - /// Looks up a localized string similar to available updates. + /// Looks up a localized string similar to Error. /// - public static string AvailableUpdates { + public static string Error { get { - return ResourceManager.GetString("AvailableUpdates", resourceCulture); + return ResourceManager.GetString("Error", resourceCulture); } } /// - /// Looks up a localized string similar to Users. + /// Looks up a localized string similar to Error duplicating '{0}': {1}. /// - public static string Users { + public static string ErrorDuplicating { get { - return ResourceManager.GetString("Users", resourceCulture); + return ResourceManager.GetString("ErrorDuplicating", resourceCulture); } } /// - /// Looks up a localized string similar to System. + /// Looks up a localized string similar to Export JSON Backup. /// - public static string System { + public static string ExportJsonBackup { get { - return ResourceManager.GetString("System", resourceCulture); + return ResourceManager.GetString("ExportJsonBackup", resourceCulture); } } /// - /// Looks up a localized string similar to Game clients. + /// Looks up a localized string similar to Export Snapshot. /// - public static string GameClients { + public static string ExportSnapshot { get { - return ResourceManager.GetString("GameClients", resourceCulture); + return ResourceManager.GetString("ExportSnapshot", resourceCulture); } } /// - /// Looks up a localized string similar to Monsters. + /// Looks up a localized string similar to Extension Point. /// - public static string Monsters { + public static string ExtensionPoint { get { - return ResourceManager.GetString("Monsters", resourceCulture); + return ResourceManager.GetString("ExtensionPoint", resourceCulture); } } /// - /// Looks up a localized string similar to Merchant stores. + /// Looks up a localized string similar to Extracted Information. /// - public static string MerchantStores { + public static string ExtractedInformation { get { - return ResourceManager.GetString("MerchantStores", resourceCulture); + return ResourceManager.GetString("ExtractedInformation", resourceCulture); } } /// - /// Looks up a localized string similar to Character classes. + /// Looks up a localized string similar to Failed, context not initialized. /// - public static string CharacterClasses { + public static string FailedByUninitializedContext { get { - return ResourceManager.GetString("CharacterClasses", resourceCulture); + return ResourceManager.GetString("FailedByUninitializedContext", resourceCulture); } } /// - /// Looks up a localized string similar to Skills. + /// Looks up a localized string similar to Failed to clone '{0}'.. /// - public static string Skills { + public static string FailedToClone { get { - return ResourceManager.GetString("Skills", resourceCulture); + return ResourceManager.GetString("FailedToClone", resourceCulture); } } /// - /// Looks up a localized string similar to Items. + /// Looks up a localized string similar to File name. /// - public static string Items { + public static string FileName { get { - return ResourceManager.GetString("Items", resourceCulture); + return ResourceManager.GetString("FileName", resourceCulture); } } /// - /// Looks up a localized string similar to Drop item groups. + /// Looks up a localized string similar to Filter log entries.... /// - public static string DropItemGroups { + public static string FilterLogEntries { get { - return ResourceManager.GetString("DropItemGroups", resourceCulture); + return ResourceManager.GetString("FilterLogEntries", resourceCulture); } } /// - /// Looks up a localized string similar to Game maps. + /// Looks up a localized string similar to Filter by code or message. /// - public static string GameMaps { + public static string FilterPackets { get { - return ResourceManager.GetString("GameMaps", resourceCulture); + return ResourceManager.GetString("FilterPackets", resourceCulture); } } /// - /// Looks up a localized string similar to Mini games. + /// Looks up a localized string similar to Finished! Have fun :). /// - public static string MiniGames { + public static string FinishedHaveFun { get { - return ResourceManager.GetString("MiniGames", resourceCulture); + return ResourceManager.GetString("FinishedHaveFun", resourceCulture); } } /// - /// Looks up a localized string similar to Warp list. + /// Looks up a localized string similar to First, close all connections to the server.. /// - public static string WarpList { + public static string FirstCloseAllConnectionsToTheServer { get { - return ResourceManager.GetString("WarpList", resourceCulture); + return ResourceManager.GetString("FirstCloseAllConnectionsToTheServer", resourceCulture); } } /// - /// Looks up a localized string similar to Jewel mixes. + /// Looks up a localized string similar to Follow. /// - public static string JewelMixes { + public static string FollowNewPackets { get { - return ResourceManager.GetString("JewelMixes", resourceCulture); + return ResourceManager.GetString("FollowNewPackets", resourceCulture); } } /// - /// Looks up a localized string similar to General. + /// Looks up a localized string similar to Shows the newest packets and scrolls to them. It's turned off while you scroll up, so that the view doesn't move away.. /// - public static string General { + public static string FollowNewPacketsHint { get { - return ResourceManager.GetString("General", resourceCulture); + return ResourceManager.GetString("FollowNewPacketsHint", resourceCulture); } } @@ -1018,560 +1018,560 @@ public static string FullConfiguration { } /// - /// Looks up a localized string similar to Game configuration. + /// Looks up a localized string similar to Game clients. /// - public static string GameConfiguration { + public static string GameClients { get { - return ResourceManager.GetString("GameConfiguration", resourceCulture); + return ResourceManager.GetString("GameClients", resourceCulture); } } /// - /// Looks up a localized string similar to Are you sure? All the current data is getting deleted and freshly installed.. + /// Looks up a localized string similar to Game configuration. /// - public static string ReinstallConfirmation { + public static string GameConfiguration { get { - return ResourceManager.GetString("ReinstallConfirmation", resourceCulture); + return ResourceManager.GetString("GameConfiguration", resourceCulture); } } /// - /// Looks up a localized string similar to Please, first install the database updates on the setup page.. + /// Looks up a localized string similar to Game maps. /// - public static string PleaseFirstInstallTheDatabaseUpdatesOnTheSetupPage { + public static string GameMaps { get { - return ResourceManager.GetString("PleaseFirstInstallTheDatabaseUpdatesOnTheSetupPage", resourceCulture); + return ResourceManager.GetString("GameMaps", resourceCulture); } } /// - /// Looks up a localized string similar to No configuration data update available.. + /// Looks up a localized string similar to Game Server. /// - public static string NoConfigurationDataUpdateAvailable { + public static string GameServer { get { - return ResourceManager.GetString("NoConfigurationDataUpdateAvailable", resourceCulture); + return ResourceManager.GetString("GameServer", resourceCulture); } } /// - /// Looks up a localized string similar to New updates for the configuration data are available. You can select the ones which should be applied to your configuration.. + /// Looks up a localized string similar to The game server configuration has been saved. Initializing game server .... /// - public static string NewConfigurationUpdatesAvailable { + public static string GameServerConfigurationSavedInfo { get { - return ResourceManager.GetString("NewConfigurationUpdatesAvailable", resourceCulture); + return ResourceManager.GetString("GameServerConfigurationSavedInfo", resourceCulture); } } /// - /// Looks up a localized string similar to Mandatory updates are always applied and cannot be deselected.. + /// Looks up a localized string similar to Game server count. /// - public static string MandatoryUpdatesAreAlwaysAppliedAndCannotBeDeselected { + public static string GameServerCount { get { - return ResourceManager.GetString("MandatoryUpdatesAreAlwaysAppliedAndCannotBeDeselected", resourceCulture); + return ResourceManager.GetString("GameServerCount", resourceCulture); } } /// - /// Looks up a localized string similar to The updates require a restart of the server process to take effect.. + /// Looks up a localized string similar to General. /// - public static string TheUpdatesRequireARestartOfTheServerProcessToTakeEffect { + public static string General { get { - return ResourceManager.GetString("TheUpdatesRequireARestartOfTheServerProcessToTakeEffect", resourceCulture); + return ResourceManager.GetString("General", resourceCulture); } } /// - /// Looks up a localized string similar to Updating.... + /// Looks up a localized string similar to Generate new recovery codes. /// - public static string Updating { + public static string GenerateNewRecoveryCodes { get { - return ResourceManager.GetString("Updating", resourceCulture); + return ResourceManager.GetString("GenerateNewRecoveryCodes", resourceCulture); } } /// - /// Looks up a localized string similar to Applying updates .... + /// Looks up a localized string similar to Global Message. /// - public static string ApplyingUpdates { + public static string GlobalMessage { get { - return ResourceManager.GetString("ApplyingUpdates", resourceCulture); + return ResourceManager.GetString("GlobalMessage", resourceCulture); } } /// - /// Looks up a localized string similar to Update Failed!. + /// Looks up a localized string similar to No running game server to send the message to.. /// - public static string UpdateFailed { + public static string GlobalMessageNoTarget { get { - return ResourceManager.GetString("UpdateFailed", resourceCulture); + return ResourceManager.GetString("GlobalMessageNoTarget", resourceCulture); } } /// - /// Looks up a localized string similar to Apply selected updates. + /// Looks up a localized string similar to Failed to send the message to {0}: {1}. /// - public static string ApplySelectedUpdates { + public static string GlobalMessageSendFailed { get { - return ResourceManager.GetString("ApplySelectedUpdates", resourceCulture); + return ResourceManager.GetString("GlobalMessageSendFailed", resourceCulture); } } /// - /// Looks up a localized string similar to Patch-Version. + /// Looks up a localized string similar to Message sent.. /// - public static string PatchVersion { + public static string GlobalMessageSent { get { - return ResourceManager.GetString("PatchVersion", resourceCulture); + return ResourceManager.GetString("GlobalMessageSent", resourceCulture); } } /// - /// Looks up a localized string similar to Major. + /// Looks up a localized string similar to Hide entry form. /// - public static string MajorVersion { + public static string HideEntryForm { get { - return ResourceManager.GetString("MajorVersion", resourceCulture); + return ResourceManager.GetString("HideEntryForm", resourceCulture); } } /// - /// Looks up a localized string similar to Patch-Address. + /// Looks up a localized string similar to Home. /// - public static string PatchAddress { + public static string Home { get { - return ResourceManager.GetString("PatchAddress", resourceCulture); + return ResourceManager.GetString("Home", resourceCulture); } } /// - /// Looks up a localized string similar to seconds. + /// Looks up a localized string similar to How many game servers do you want?. /// - public static string Seconds { + public static string HowManyGameServersQuestion { get { - return ResourceManager.GetString("Seconds", resourceCulture); + return ResourceManager.GetString("HowManyGameServersQuestion", resourceCulture); } } /// - /// Looks up a localized string similar to Minor. + /// Looks up a localized string similar to Importing backup, please wait .... /// - public static string MinorVersion { + public static string ImportingBackupPleaseWait { get { - return ResourceManager.GetString("MinorVersion", resourceCulture); + return ResourceManager.GetString("ImportingBackupPleaseWait", resourceCulture); } } /// - /// Looks up a localized string similar to Patch. + /// Looks up a localized string similar to Import JSON Backup. /// - public static string Patch { + public static string ImportJsonBackup { get { - return ResourceManager.GetString("Patch", resourceCulture); + return ResourceManager.GetString("ImportJsonBackup", resourceCulture); } } /// - /// Looks up a localized string similar to Save. + /// Looks up a localized string similar to Include the accounts (takes longer). /// - public static string Save { + public static string IncludeAccounts { get { - return ResourceManager.GetString("Save", resourceCulture); + return ResourceManager.GetString("IncludeAccounts", resourceCulture); } } /// - /// Looks up a localized string similar to Cancel. + /// Looks up a localized string similar to Initialized game version. /// - public static string Cancel { + public static string InitializedGameVersion { get { - return ResourceManager.GetString("Cancel", resourceCulture); + return ResourceManager.GetString("InitializedGameVersion", resourceCulture); } } /// - /// Looks up a localized string similar to Installing, please wait .... + /// Looks up a localized string similar to Initializing Connect Server .... /// - public static string InstallingPleaseWait { + public static string InitializingConnectServerInfo { get { - return ResourceManager.GetString("InstallingPleaseWait", resourceCulture); + return ResourceManager.GetString("InitializingConnectServerInfo", resourceCulture); } } /// - /// Looks up a localized string similar to Please restart the connect and game server containers.. + /// Looks up a localized string similar to Initializing Game Server .... /// - public static string PleaseRestartTheConnectAndGameServerContainers { + public static string InitializingGameServerInfo { get { - return ResourceManager.GetString("PleaseRestartTheConnectAndGameServerContainers", resourceCulture); + return ResourceManager.GetString("InitializingGameServerInfo", resourceCulture); } } /// - /// Looks up a localized string similar to Finished! Have fun :). + /// Looks up a localized string similar to Installing, please wait .... /// - public static string FinishedHaveFun { + public static string InstallingPleaseWait { get { - return ResourceManager.GetString("FinishedHaveFun", resourceCulture); + return ResourceManager.GetString("InstallingPleaseWait", resourceCulture); } } /// - /// Looks up a localized string similar to First, close all connections to the server.. + /// Looks up a localized string similar to The login name or password is wrong.. /// - public static string FirstCloseAllConnectionsToTheServer { + public static string InvalidCredentials { get { - return ResourceManager.GetString("FirstCloseAllConnectionsToTheServer", resourceCulture); + return ResourceManager.GetString("InvalidCredentials", resourceCulture); } } /// - /// Looks up a localized string similar to OK. + /// Looks up a localized string similar to The code is not valid.. /// - public static string OK { + public static string InvalidTwoFactorCode { get { - return ResourceManager.GetString("OK", resourceCulture); + return ResourceManager.GetString("InvalidTwoFactorCode", resourceCulture); } } /// - /// Looks up a localized string similar to Select the game version. + /// Looks up a localized string similar to Items. /// - public static string SelectTheGameVersion { + public static string Items { get { - return ResourceManager.GetString("SelectTheGameVersion", resourceCulture); + return ResourceManager.GetString("Items", resourceCulture); } } /// - /// Looks up a localized string similar to How many game servers do you want?. + /// Looks up a localized string similar to Jewel mixes. /// - public static string HowManyGameServersQuestion { + public static string JewelMixes { get { - return ResourceManager.GetString("HowManyGameServersQuestion", resourceCulture); + return ResourceManager.GetString("JewelMixes", resourceCulture); } } /// - /// Looks up a localized string similar to Do you want test accounts?. + /// Looks up a localized string similar to JSON Backup (slow!). /// - public static string TestAccountsQuestion { + public static string JsonBackup { get { - return ResourceManager.GetString("TestAccountsQuestion", resourceCulture); + return ResourceManager.GetString("JsonBackup", resourceCulture); } } /// - /// Looks up a localized string similar to Yes, create test accounts. + /// Looks up a localized string similar to Last login. /// - public static string YesCreateTestAccounts { + public static string LastLogin { get { - return ResourceManager.GetString("YesCreateTestAccounts", resourceCulture); + return ResourceManager.GetString("LastLogin", resourceCulture); } } - + /// - /// Looks up a localized string similar to Start install. + /// Looks up a localized string similar to Last update. /// - public static string StartInstall { + public static string LastUpdate { get { - return ResourceManager.GetString("StartInstall", resourceCulture); + return ResourceManager.GetString("LastUpdate", resourceCulture); } } /// - /// Looks up a localized string similar to Remove. + /// Looks up a localized string similar to Live. /// - public static string Remove { + public static string Live { get { - return ResourceManager.GetString("Remove", resourceCulture); + return ResourceManager.GetString("Live", resourceCulture); } } /// - /// Looks up a localized string similar to Yes. + /// Looks up a localized string similar to Live Map. /// - public static string Yes { + public static string LiveMap { get { - return ResourceManager.GetString("Yes", resourceCulture); + return ResourceManager.GetString("LiveMap", resourceCulture); } } /// - /// Looks up a localized string similar to No. + /// Looks up a localized string similar to Loading .... /// - public static string No { + public static string Loading { get { - return ResourceManager.GetString("No", resourceCulture); + return ResourceManager.GetString("Loading", resourceCulture); } } /// - /// Looks up a localized string similar to Start. + /// Looks up a localized string similar to Could not load the data. Check the logs for details.. /// - public static string Start { + public static string LoadingErrorCheckLog { get { - return ResourceManager.GetString("Start", resourceCulture); + return ResourceManager.GetString("LoadingErrorCheckLog", resourceCulture); } } /// - /// Looks up a localized string similar to Stop. + /// Looks up a localized string similar to Log Files. /// - public static string Stop { + public static string LogFiles { get { - return ResourceManager.GetString("Stop", resourceCulture); + return ResourceManager.GetString("LogFiles", resourceCulture); } } /// - /// Looks up a localized string similar to Remove Server. + /// Looks up a localized string similar to Login. /// - public static string RemoveServer { + public static string Login { get { - return ResourceManager.GetString("RemoveServer", resourceCulture); + return ResourceManager.GetString("Login", resourceCulture); } } /// - /// Looks up a localized string similar to The server will be deleted from the database. Are you sure to proceed?. + /// Looks up a localized string similar to Login name. /// - public static string ServerDeleteProceedQuestion { + public static string LoginName { get { - return ResourceManager.GetString("ServerDeleteProceedQuestion", resourceCulture); + return ResourceManager.GetString("LoginName", resourceCulture); } } /// - /// Looks up a localized string similar to Server control. + /// Looks up a localized string similar to Logout. /// - public static string ServerControl { + public static string Logout { get { - return ResourceManager.GetString("ServerControl", resourceCulture); + return ResourceManager.GetString("Logout", resourceCulture); } } /// - /// Looks up a localized string similar to Total Players. + /// Looks up a localized string similar to Logs. /// - public static string TotalPlayers { + public static string Logs { get { - return ResourceManager.GetString("TotalPlayers", resourceCulture); + return ResourceManager.GetString("Logs", resourceCulture); } } /// - /// Looks up a localized string similar to Socket {0}. + /// Looks up a localized string similar to Log Viewer. /// - public static string SocketNumber { + public static string LogViewer { get { - return ResourceManager.GetString("SocketNumber", resourceCulture); + return ResourceManager.GetString("LogViewer", resourceCulture); } } /// - /// Looks up a localized string similar to Action. + /// Looks up a localized string similar to Major. /// - public static string Action { + public static string MajorVersion { get { - return ResourceManager.GetString("Action", resourceCulture); + return ResourceManager.GetString("MajorVersion", resourceCulture); } } /// - /// Looks up a localized string similar to Game server count. + /// Looks up a localized string similar to Mandatory updates are always applied and cannot be deselected.. /// - public static string GameServerCount { + public static string MandatoryUpdatesAreAlwaysAppliedAndCannotBeDeselected { get { - return ResourceManager.GetString("GameServerCount", resourceCulture); + return ResourceManager.GetString("MandatoryUpdatesAreAlwaysAppliedAndCannotBeDeselected", resourceCulture); } } /// - /// Looks up a localized string similar to Character. + /// Looks up a localized string similar to Can't scan it? Enter this key manually:. /// - public static string Character { + public static string ManualKeyHint { get { - return ResourceManager.GetString("Character", resourceCulture); + return ResourceManager.GetString("ManualKeyHint", resourceCulture); } } /// - /// Looks up a localized string similar to Started At. + /// Looks up a localized string similar to Map Editor. /// - public static string StartedAt { + public static string MapEditor { get { - return ResourceManager.GetString("StartedAt", resourceCulture); + return ResourceManager.GetString("MapEditor", resourceCulture); } } /// - /// Looks up a localized string similar to Active Offline Player. + /// Looks up a localized string similar to Merchants. /// - public static string ActiveOfflinePlayer { + public static string Merchants { get { - return ResourceManager.GetString("ActiveOfflinePlayer", resourceCulture); + return ResourceManager.GetString("Merchants", resourceCulture); } } /// - /// Looks up a localized string similar to Couldn't find '{0}' to delete.. + /// Looks up a localized string similar to Merchant stores. /// - public static string CouldNotFindToDelete { + public static string MerchantStores { get { - return ResourceManager.GetString("CouldNotFindToDelete", resourceCulture); + return ResourceManager.GetString("MerchantStores", resourceCulture); } } /// - /// Looks up a localized string similar to Couldn't delete '{0}', probably because it's referenced by another object. For details, see log. + /// Looks up a localized string similar to Enter message.... /// - public static string DeleteFailedReferenced { + public static string MessagePlaceholder { get { - return ResourceManager.GetString("DeleteFailedReferenced", resourceCulture); + return ResourceManager.GetString("MessagePlaceholder", resourceCulture); } } /// - /// Looks up a localized string similar to New object successfully created.. + /// Looks up a localized string similar to Metrics. /// - public static string CreatedSuccessfully { + public static string Metrics { get { - return ResourceManager.GetString("CreatedSuccessfully", resourceCulture); + return ResourceManager.GetString("Metrics", resourceCulture); } } /// - /// Looks up a localized string similar to Show entry form. + /// Looks up a localized string similar to Mini games. /// - public static string ShowEntryForm { + public static string MiniGames { get { - return ResourceManager.GetString("ShowEntryForm", resourceCulture); + return ResourceManager.GetString("MiniGames", resourceCulture); } } /// - /// Looks up a localized string similar to Hide entry form. + /// Looks up a localized string similar to Required status. /// - public static string HideEntryForm { + public static string MinimumCharacterStatus { get { - return ResourceManager.GetString("HideEntryForm", resourceCulture); + return ResourceManager.GetString("MinimumCharacterStatus", resourceCulture); } } /// - /// Looks up a localized string similar to All Game Servers. + /// Looks up a localized string similar to Minor. /// - public static string AllGameServers { + public static string MinorVersion { get { - return ResourceManager.GetString("AllGameServers", resourceCulture); + return ResourceManager.GetString("MinorVersion", resourceCulture); } } /// - /// Looks up a localized string similar to Global Message. + /// Looks up a localized string similar to Monsters. /// - public static string GlobalMessage { + public static string Monsters { get { - return ResourceManager.GetString("GlobalMessage", resourceCulture); + return ResourceManager.GetString("Monsters", resourceCulture); } } /// - /// Looks up a localized string similar to No running game server to send the message to.. + /// Looks up a localized string similar to Network Analyzer. /// - public static string GlobalMessageNoTarget { + public static string NetworkAnalyzer { get { - return ResourceManager.GetString("GlobalMessageNoTarget", resourceCulture); + return ResourceManager.GetString("NetworkAnalyzer", resourceCulture); } } /// - /// Looks up a localized string similar to Failed to send the message to {0}: {1}. + /// Looks up a localized string similar to The network analyzer is only available in the all-in-one deployment, because it needs the servers in the same process.. /// - public static string GlobalMessageSendFailed { + public static string NetworkAnalyzerNotAvailable { get { - return ResourceManager.GetString("GlobalMessageSendFailed", resourceCulture); + return ResourceManager.GetString("NetworkAnalyzerNotAvailable", resourceCulture); } } /// - /// Looks up a localized string similar to Message sent.. + /// Looks up a localized string similar to New updates for the configuration data are available. You can select the ones which should be applied to your configuration.. /// - public static string GlobalMessageSent { + public static string NewConfigurationUpdatesAvailable { get { - return ResourceManager.GetString("GlobalMessageSent", resourceCulture); + return ResourceManager.GetString("NewConfigurationUpdatesAvailable", resourceCulture); } } /// - /// Looks up a localized string similar to Enter message.... + /// Looks up a localized string similar to No. /// - public static string MessagePlaceholder { + public static string No { get { - return ResourceManager.GetString("MessagePlaceholder", resourceCulture); + return ResourceManager.GetString("No", resourceCulture); } } /// - /// Looks up a localized string similar to {0} online. + /// Looks up a localized string similar to No admin panel user exists yet, so the panel is currently reachable without a login. Create the first user as soon as the database is set up, or configure a bootstrap user.. /// - public static string OnlineCount { + public static string NoAdminUserWarning { get { - return ResourceManager.GetString("OnlineCount", resourceCulture); + return ResourceManager.GetString("NoAdminUserWarning", resourceCulture); } } /// - /// Looks up a localized string similar to Send. + /// Looks up a localized string similar to No API key has been created yet.. /// - public static string Send { + public static string NoApiKeys { get { - return ResourceManager.GetString("Send", resourceCulture); + return ResourceManager.GetString("NoApiKeys", resourceCulture); } } /// - /// Looks up a localized string similar to Target. + /// Looks up a localized string similar to No changes have been saved.. /// - public static string Target { + public static string NoChangesSaved { get { - return ResourceManager.GetString("Target", resourceCulture); + return ResourceManager.GetString("NoChangesSaved", resourceCulture); } } /// - /// Looks up a localized string similar to Log Viewer. + /// Looks up a localized string similar to There were no changes to save.. /// - public static string LogViewer { + public static string NoChangesToSave { get { - return ResourceManager.GetString("LogViewer", resourceCulture); + return ResourceManager.GetString("NoChangesToSave", resourceCulture); } } /// - /// Looks up a localized string similar to Live. + /// Looks up a localized string similar to No configuration data update available.. /// - public static string Live { + public static string NoConfigurationDataUpdateAvailable { get { - return ResourceManager.GetString("Live", resourceCulture); + return ResourceManager.GetString("NoConfigurationDataUpdateAvailable", resourceCulture); } } /// - /// Looks up a localized string similar to Close. + /// Looks up a localized string similar to No connections found.. /// - public static string Close { + public static string NoConnectionsFound { get { - return ResourceManager.GetString("Close", resourceCulture); + return ResourceManager.GetString("NoConnectionsFound", resourceCulture); } } /// - /// Looks up a localized string similar to Filter log entries.... + /// Looks up a localized string similar to No initialized data found!. /// - public static string FilterLogEntries { + public static string NoInitializedDataFound { get { - return ResourceManager.GetString("FilterLogEntries", resourceCulture); + return ResourceManager.GetString("NoInitializedDataFound", resourceCulture); } } @@ -1594,92 +1594,92 @@ public static string NoLogEntriesMatchFilter { } /// - /// Looks up a localized string similar to Showing {0} of {1} lines (Last {2} lines loaded).. + /// Looks up a localized string similar to No packets captured yet.. /// - public static string ShowingXOfYLines { + public static string NoPacketsCaptured { get { - return ResourceManager.GetString("ShowingXOfYLines", resourceCulture); + return ResourceManager.GetString("NoPacketsCaptured", resourceCulture); } } /// - /// Looks up a localized string similar to Scroll to Bottom. + /// Looks up a localized string similar to This command has no parameters.. /// - public static string ScrollToBottom { + public static string NoParameters { get { - return ResourceManager.GetString("ScrollToBottom", resourceCulture); + return ResourceManager.GetString("NoParameters", resourceCulture); } } /// - /// Looks up a localized string similar to Reload File List. + /// Looks up a localized string similar to Not created. /// - public static string ReloadFileList { + public static string NotCreated { get { - return ResourceManager.GetString("ReloadFileList", resourceCulture); + return ResourceManager.GetString("NotCreated", resourceCulture); } } /// - /// Looks up a localized string similar to Download File. + /// Looks up a localized string similar to OK. /// - public static string DownloadFile { + public static string OK { get { - return ResourceManager.GetString("DownloadFile", resourceCulture); + return ResourceManager.GetString("OK", resourceCulture); } } /// - /// Looks up a localized string similar to Chat commands. + /// Looks up a localized string similar to Online Accounts. /// - public static string ChatCommands { + public static string OnlineAccounts { get { - return ResourceManager.GetString("ChatCommands", resourceCulture); + return ResourceManager.GetString("OnlineAccounts", resourceCulture); } } /// - /// Looks up a localized string similar to Command. + /// Looks up a localized string similar to {0} online. /// - public static string CommandColumn { + public static string OnlineCount { get { - return ResourceManager.GetString("CommandColumn", resourceCulture); + return ResourceManager.GetString("OnlineCount", resourceCulture); } } /// - /// Looks up a localized string similar to Description. + /// Looks up a localized string similar to OpenMU AdminPanel. /// - public static string CommandDescription { + public static string OpenMUAdminPanel { get { - return ResourceManager.GetString("CommandDescription", resourceCulture); + return ResourceManager.GetString("OpenMUAdminPanel", resourceCulture); } } /// - /// Looks up a localized string similar to Usage. + /// Looks up a localized string similar to Code. /// - public static string CommandUsage { + public static string PacketCode { get { - return ResourceManager.GetString("CommandUsage", resourceCulture); + return ResourceManager.GetString("PacketCode", resourceCulture); } } /// - /// Looks up a localized string similar to Required status. + /// Looks up a localized string similar to Packet Details. /// - public static string MinimumCharacterStatus { + public static string PacketDetails { get { - return ResourceManager.GetString("MinimumCharacterStatus", resourceCulture); + return ResourceManager.GetString("PacketDetails", resourceCulture); } } /// - /// Looks up a localized string similar to This command has no parameters.. + /// Looks up a localized string similar to Message. /// - public static string NoParameters { + public static string PacketMessage { get { - return ResourceManager.GetString("NoParameters", resourceCulture); + return ResourceManager.GetString("PacketMessage", resourceCulture); } } @@ -1701,6 +1701,15 @@ public static string ParameterShortName { } } + /// + /// Looks up a localized string similar to Parameters of {0}. + /// + public static string ParametersOf { + get { + return ResourceManager.GetString("ParametersOf", resourceCulture); + } + } + /// /// Looks up a localized string similar to Type. /// @@ -1720,833 +1729,939 @@ public static string ParameterValidValues { } /// - /// Looks up a localized string similar to Parameters of {0}. + /// Looks up a localized string similar to Password. /// - public static string ParametersOf { + public static string Password { get { - return ResourceManager.GetString("ParametersOf", resourceCulture); + return ResourceManager.GetString("Password", resourceCulture); } } /// - /// Looks up a localized string similar to Required. + /// Looks up a localized string similar to The password has been changed.. /// - public static string Required { + public static string PasswordChanged { get { - return ResourceManager.GetString("Required", resourceCulture); + return ResourceManager.GetString("PasswordChanged", resourceCulture); } } /// - /// Looks up a localized string similar to Login. + /// Looks up a localized string similar to Patch. /// - public static string Login { + public static string Patch { get { - return ResourceManager.GetString("Login", resourceCulture); + return ResourceManager.GetString("Patch", resourceCulture); } } /// - /// Looks up a localized string similar to Logout. + /// Looks up a localized string similar to Patch-Address. /// - public static string Logout { + public static string PatchAddress { get { - return ResourceManager.GetString("Logout", resourceCulture); + return ResourceManager.GetString("PatchAddress", resourceCulture); } } /// - /// Looks up a localized string similar to Login name. + /// Looks up a localized string similar to Patch-Version. /// - public static string LoginName { + public static string PatchVersion { get { - return ResourceManager.GetString("LoginName", resourceCulture); + return ResourceManager.GetString("PatchVersion", resourceCulture); } } /// - /// Looks up a localized string similar to Password. + /// Looks up a localized string similar to Players. /// - public static string Password { + public static string PlayerCount { get { - return ResourceManager.GetString("Password", resourceCulture); + return ResourceManager.GetString("PlayerCount", resourceCulture); } } /// - /// Looks up a localized string similar to Sign in. + /// Looks up a localized string similar to Please, first install the database updates on the setup page.. /// - public static string SignIn { + public static string PleaseFirstInstallTheDatabaseUpdatesOnTheSetupPage { get { - return ResourceManager.GetString("SignIn", resourceCulture); + return ResourceManager.GetString("PleaseFirstInstallTheDatabaseUpdatesOnTheSetupPage", resourceCulture); } } /// - /// Looks up a localized string similar to Signed in as. + /// Looks up a localized string similar to Please restart the connect and game server containers.. /// - public static string SignedInAs { + public static string PleaseRestartTheConnectAndGameServerContainers { get { - return ResourceManager.GetString("SignedInAs", resourceCulture); + return ResourceManager.GetString("PleaseRestartTheConnectAndGameServerContainers", resourceCulture); } } /// - /// Looks up a localized string similar to Keep me signed in. + /// Looks up a localized string similar to Plugin Name. /// - public static string RememberMe { + public static string PluginName { get { - return ResourceManager.GetString("RememberMe", resourceCulture); + return ResourceManager.GetString("PluginName", resourceCulture); } } /// - /// Looks up a localized string similar to The login name or password is wrong.. + /// Looks up a localized string similar to Plugins. /// - public static string InvalidCredentials { + public static string Plugins { get { - return ResourceManager.GetString("InvalidCredentials", resourceCulture); + return ResourceManager.GetString("Plugins", resourceCulture); } } /// - /// Looks up a localized string similar to Too many failed attempts. Please try again later.. + /// Looks up a localized string similar to Plugin Type. /// - public static string AccountLockedOut { + public static string PluginType { get { - return ResourceManager.GetString("AccountLockedOut", resourceCulture); + return ResourceManager.GetString("PluginType", resourceCulture); } } /// - /// Looks up a localized string similar to Two-factor authentication. + /// Looks up a localized string similar to Raw Data. /// - public static string TwoFactorTitle { + public static string RawData { get { - return ResourceManager.GetString("TwoFactorTitle", resourceCulture); + return ResourceManager.GetString("RawData", resourceCulture); } } /// - /// Looks up a localized string similar to Enter the code from your authenticator app.. + /// Looks up a localized string similar to Recovery code. /// - public static string TwoFactorPrompt { + public static string RecoveryCode { get { - return ResourceManager.GetString("TwoFactorPrompt", resourceCulture); + return ResourceManager.GetString("RecoveryCode", resourceCulture); } } /// - /// Looks up a localized string similar to Authenticator code. + /// Looks up a localized string similar to Recovery codes. /// - public static string AuthenticatorCode { + public static string RecoveryCodes { get { - return ResourceManager.GetString("AuthenticatorCode", resourceCulture); + return ResourceManager.GetString("RecoveryCodes", resourceCulture); } } /// - /// Looks up a localized string similar to Recovery code. + /// Looks up a localized string similar to Store these codes somewhere safe. Each of them can be used once to sign in when you don't have your authenticator app. They are shown only now.. /// - public static string RecoveryCode { + public static string RecoveryCodesHint { get { - return ResourceManager.GetString("RecoveryCode", resourceCulture); + return ResourceManager.GetString("RecoveryCodesHint", resourceCulture); } } /// - /// Looks up a localized string similar to Use a recovery code instead. + /// Looks up a localized string similar to Refresh. /// - public static string UseRecoveryCode { + public static string Refresh { get { - return ResourceManager.GetString("UseRecoveryCode", resourceCulture); + return ResourceManager.GetString("Refresh", resourceCulture); } } /// - /// Looks up a localized string similar to Use an authenticator code instead. + /// Looks up a localized string similar to Re-install. /// - public static string UseAuthenticatorCode { + public static string ReInstall { get { - return ResourceManager.GetString("UseAuthenticatorCode", resourceCulture); + return ResourceManager.GetString("ReInstall", resourceCulture); } } /// - /// Looks up a localized string similar to The code is not valid.. + /// Looks up a localized string similar to Are you sure? All the current data is getting deleted and freshly installed.. /// - public static string InvalidTwoFactorCode { + public static string ReinstallConfirmation { get { - return ResourceManager.GetString("InvalidTwoFactorCode", resourceCulture); + return ResourceManager.GetString("ReinstallConfirmation", resourceCulture); } } /// - /// Looks up a localized string similar to Verify. + /// Looks up a localized string similar to Reload. /// - public static string Verify { + public static string Reload { get { - return ResourceManager.GetString("Verify", resourceCulture); + return ResourceManager.GetString("Reload", resourceCulture); } } /// - /// Looks up a localized string similar to Access denied. + /// Looks up a localized string similar to Reload configuration and restart all Game Servers. /// - public static string AccessDenied { + public static string ReloadConfigurationAndRestartAllGameServers { get { - return ResourceManager.GetString("AccessDenied", resourceCulture); + return ResourceManager.GetString("ReloadConfigurationAndRestartAllGameServers", resourceCulture); } } /// - /// Looks up a localized string similar to Your account is not permitted to open this page.. + /// Looks up a localized string similar to Reload File List. /// - public static string AccessDeniedDescription { + public static string ReloadFileList { get { - return ResourceManager.GetString("AccessDeniedDescription", resourceCulture); + return ResourceManager.GetString("ReloadFileList", resourceCulture); } } /// - /// Looks up a localized string similar to Account security. + /// Looks up a localized string similar to Remaining recovery codes. /// - public static string AccountSecurity { + public static string RemainingRecoveryCodes { get { - return ResourceManager.GetString("AccountSecurity", resourceCulture); + return ResourceManager.GetString("RemainingRecoveryCodes", resourceCulture); } } /// - /// Looks up a localized string similar to Two-factor authentication. + /// Looks up a localized string similar to Keep me signed in. /// - public static string TwoFactorAuthentication { + public static string RememberMe { get { - return ResourceManager.GetString("TwoFactorAuthentication", resourceCulture); + return ResourceManager.GetString("RememberMe", resourceCulture); } } /// - /// Looks up a localized string similar to Two-factor authentication is enabled.. + /// Looks up a localized string similar to Remove. /// - public static string TwoFactorEnabled { + public static string Remove { get { - return ResourceManager.GetString("TwoFactorEnabled", resourceCulture); + return ResourceManager.GetString("Remove", resourceCulture); } } /// - /// Looks up a localized string similar to Two-factor authentication is not set up yet.. + /// Looks up a localized string similar to Remove Server. /// - public static string TwoFactorDisabled { + public static string RemoveServer { get { - return ResourceManager.GetString("TwoFactorDisabled", resourceCulture); + return ResourceManager.GetString("RemoveServer", resourceCulture); } } /// - /// Looks up a localized string similar to Set up authenticator app. + /// Looks up a localized string similar to Required. /// - public static string SetUpAuthenticator { + public static string Required { get { - return ResourceManager.GetString("SetUpAuthenticator", resourceCulture); + return ResourceManager.GetString("Required", resourceCulture); } } /// - /// Looks up a localized string similar to Disable two-factor authentication. + /// Looks up a localized string similar to Reset second factor. /// - public static string DisableTwoFactor { + public static string ResetTwoFactor { get { - return ResourceManager.GetString("DisableTwoFactor", resourceCulture); + return ResourceManager.GetString("ResetTwoFactor", resourceCulture); } } /// - /// Looks up a localized string similar to Scan this QR code with your authenticator app, for example Microsoft Authenticator.. + /// Looks up a localized string similar to Role. /// - public static string ScanQrCode { + public static string Role { get { - return ResourceManager.GetString("ScanQrCode", resourceCulture); + return ResourceManager.GetString("Role", resourceCulture); } } /// - /// Looks up a localized string similar to Can't scan it? Enter this key manually:. + /// Looks up a localized string similar to Roles. /// - public static string ManualKeyHint { + public static string Roles { get { - return ResourceManager.GetString("ManualKeyHint", resourceCulture); + return ResourceManager.GetString("Roles", resourceCulture); } } /// - /// Looks up a localized string similar to To finish, enter the code which your app shows now.. + /// Looks up a localized string similar to Save. /// - public static string ConfirmSetupHint { + public static string Save { get { - return ResourceManager.GetString("ConfirmSetupHint", resourceCulture); + return ResourceManager.GetString("Save", resourceCulture); } } /// - /// Looks up a localized string similar to Recovery codes. + /// Looks up a localized string similar to Save changes. /// - public static string RecoveryCodes { + public static string SaveChanges { get { - return ResourceManager.GetString("RecoveryCodes", resourceCulture); + return ResourceManager.GetString("SaveChanges", resourceCulture); } } /// - /// Looks up a localized string similar to Store these codes somewhere safe. Each of them can be used once to sign in when you don't have your authenticator app. They are shown only now.. + /// Looks up a localized string similar to The changes have been saved.. /// - public static string RecoveryCodesHint { + public static string SavedChanges { get { - return ResourceManager.GetString("RecoveryCodesHint", resourceCulture); + return ResourceManager.GetString("SavedChanges", resourceCulture); } } /// - /// Looks up a localized string similar to Remaining recovery codes. + /// Looks up a localized string similar to Saving Configuration .... /// - public static string RemainingRecoveryCodes { + public static string SavingConfigurationInfo { get { - return ResourceManager.GetString("RemainingRecoveryCodes", resourceCulture); + return ResourceManager.GetString("SavingConfigurationInfo", resourceCulture); } } /// - /// Looks up a localized string similar to Generate new recovery codes. + /// Looks up a localized string similar to Scan this QR code with your authenticator app, for example Microsoft Authenticator.. /// - public static string GenerateNewRecoveryCodes { + public static string ScanQrCode { get { - return ResourceManager.GetString("GenerateNewRecoveryCodes", resourceCulture); + return ResourceManager.GetString("ScanQrCode", resourceCulture); } } /// - /// Looks up a localized string similar to Two-factor authentication is required. + /// Looks up a localized string similar to Scroll to Bottom. /// - public static string TwoFactorRequiredTitle { + public static string ScrollToBottom { get { - return ResourceManager.GetString("TwoFactorRequiredTitle", resourceCulture); + return ResourceManager.GetString("ScrollToBottom", resourceCulture); } } /// - /// Looks up a localized string similar to The server configuration requires every admin panel user to use a second factor. Please set up your authenticator app to continue.. + /// Looks up a localized string similar to Search. /// - public static string TwoFactorRequiredDescription { + public static string Search { get { - return ResourceManager.GetString("TwoFactorRequiredDescription", resourceCulture); + return ResourceManager.GetString("Search", resourceCulture); } } /// - /// Looks up a localized string similar to Roles. + /// Looks up a localized string similar to seconds. /// - public static string Roles { + public static string Seconds { get { - return ResourceManager.GetString("Roles", resourceCulture); + return ResourceManager.GetString("Seconds", resourceCulture); } } /// - /// Looks up a localized string similar to Role. + /// Looks up a localized string similar to Select a connection to see its network traffic.. /// - public static string Role { + public static string SelectAConnection { get { - return ResourceManager.GetString("Role", resourceCulture); + return ResourceManager.GetString("SelectAConnection", resourceCulture); } } /// - /// Looks up a localized string similar to Second factor. + /// Looks up a localized string similar to Select a packet to see its content.. /// - public static string TwoFactor { + public static string SelectAPacket { get { - return ResourceManager.GetString("TwoFactor", resourceCulture); + return ResourceManager.GetString("SelectAPacket", resourceCulture); } } /// - /// Looks up a localized string similar to Last login. + /// Looks up a localized string similar to The selected file is no backup archive. The database was left untouched.. /// - public static string LastLogin { + public static string SelectedFileIsNoBackup { get { - return ResourceManager.GetString("LastLogin", resourceCulture); + return ResourceManager.GetString("SelectedFileIsNoBackup", resourceCulture); } } /// - /// Looks up a localized string similar to Enabled. + /// Looks up a localized string similar to Select the game version. /// - public static string Enabled { + public static string SelectTheGameVersion { get { - return ResourceManager.GetString("Enabled", resourceCulture); + return ResourceManager.GetString("SelectTheGameVersion", resourceCulture); } } /// - /// Looks up a localized string similar to Disabled. + /// Looks up a localized string similar to Select a .zip backup file to restore. /// - public static string Disabled { + public static string SelectZipFileToRestore { get { - return ResourceManager.GetString("Disabled", resourceCulture); + return ResourceManager.GetString("SelectZipFileToRestore", resourceCulture); } } /// - /// Looks up a localized string similar to No admin panel user exists yet, so the panel is currently reachable without a login. Create the first user as soon as the database is set up, or configure a bootstrap user.. + /// Looks up a localized string similar to Select a .zip snapshot file to restore. /// - public static string NoAdminUserWarning { + public static string SelectZipFileToRestoreSnapshot { get { - return ResourceManager.GetString("NoAdminUserWarning", resourceCulture); + return ResourceManager.GetString("SelectZipFileToRestoreSnapshot", resourceCulture); } } /// - /// Looks up a localized string similar to Create the first user. + /// Looks up a localized string similar to Send. /// - public static string CreateFirstUser { + public static string Send { get { - return ResourceManager.GetString("CreateFirstUser", resourceCulture); + return ResourceManager.GetString("Send", resourceCulture); } } /// - /// Looks up a localized string similar to The user has been created.. + /// Looks up a localized string similar to Server control. /// - public static string UserCreated { + public static string ServerControl { get { - return ResourceManager.GetString("UserCreated", resourceCulture); + return ResourceManager.GetString("ServerControl", resourceCulture); } } /// - /// Looks up a localized string similar to The user has been deleted.. + /// Looks up a localized string similar to The server will be deleted from the database. Are you sure to proceed?. /// - public static string UserDeleted { + public static string ServerDeleteProceedQuestion { get { - return ResourceManager.GetString("UserDeleted", resourceCulture); + return ResourceManager.GetString("ServerDeleteProceedQuestion", resourceCulture); } } /// - /// Looks up a localized string similar to The password has been changed.. + /// Looks up a localized string similar to Server-ID. /// - public static string PasswordChanged { + public static string ServerID { get { - return ResourceManager.GetString("PasswordChanged", resourceCulture); + return ResourceManager.GetString("ServerID", resourceCulture); } } /// - /// Looks up a localized string similar to The second factor of the user has been reset.. + /// Looks up a localized string similar to Server Name. /// - public static string TwoFactorResetForUser { + public static string ServerName { get { - return ResourceManager.GetString("TwoFactorResetForUser", resourceCulture); + return ResourceManager.GetString("ServerName", resourceCulture); } } /// - /// Looks up a localized string similar to Reset second factor. + /// Looks up a localized string similar to Servers. /// - public static string ResetTwoFactor { + public static string Servers { get { - return ResourceManager.GetString("ResetTwoFactor", resourceCulture); + return ResourceManager.GetString("Servers", resourceCulture); } } /// - /// Looks up a localized string similar to The last remaining user can't be deleted.. + /// Looks up a localized string similar to Server with Id {0} already exists. Please use another value.. /// - public static string CannotDeleteLastUser { + public static string ServerWithIdAlreadyExists { get { - return ResourceManager.GetString("CannotDeleteLastUser", resourceCulture); + return ResourceManager.GetString("ServerWithIdAlreadyExists", resourceCulture); } } /// - /// Looks up a localized string similar to The bootstrap user is defined by the configuration and can't be changed here.. + /// Looks up a localized string similar to A server with tcp port {0} already exists. Please use another tcp port.. /// - public static string CannotModifyBootstrapUser { + public static string ServerWithPortAlreadyExists { get { - return ResourceManager.GetString("CannotModifyBootstrapUser", resourceCulture); + return ResourceManager.GetString("ServerWithPortAlreadyExists", resourceCulture); } } /// - /// Looks up a localized string similar to API keys. + /// Looks up a localized string similar to Setup. /// - public static string ApiKeys { + public static string Setup { get { - return ResourceManager.GetString("ApiKeys", resourceCulture); + return ResourceManager.GetString("Setup", resourceCulture); } } /// - /// Looks up a localized string similar to External applications like a game launcher or a website authenticate themselves at the public API under /api with one of these keys. Give each application its own key, so a single one can be revoked.. + /// Looks up a localized string similar to Set up authenticator app. /// - public static string ApiKeysDescription { + public static string SetUpAuthenticator { get { - return ResourceManager.GetString("ApiKeysDescription", resourceCulture); + return ResourceManager.GetString("SetUpAuthenticator", resourceCulture); } } /// - /// Looks up a localized string similar to Application. + /// Looks up a localized string similar to Show entry form. /// - public static string ApiKeyName { + public static string ShowEntryForm { get { - return ResourceManager.GetString("ApiKeyName", resourceCulture); + return ResourceManager.GetString("ShowEntryForm", resourceCulture); } } /// - /// Looks up a localized string similar to Key. + /// Looks up a localized string similar to Showing {0} of {1} lines (Last {2} lines loaded).. /// - public static string ApiKeyPrefix { + public static string ShowingXOfYLines { get { - return ResourceManager.GetString("ApiKeyPrefix", resourceCulture); + return ResourceManager.GetString("ShowingXOfYLines", resourceCulture); } } /// - /// Looks up a localized string similar to Create API key. + /// Looks up a localized string similar to Signed in as. /// - public static string CreateApiKey { + public static string SignedInAs { get { - return ResourceManager.GetString("CreateApiKey", resourceCulture); + return ResourceManager.GetString("SignedInAs", resourceCulture); } } /// - /// Looks up a localized string similar to Delete API key. + /// Looks up a localized string similar to Sign in. /// - public static string DeleteApiKey { + public static string SignIn { get { - return ResourceManager.GetString("DeleteApiKey", resourceCulture); + return ResourceManager.GetString("SignIn", resourceCulture); } } /// - /// Looks up a localized string similar to Do you really want to delete the API key of '{0}'? The application which uses it stops working immediately.. + /// Looks up a localized string similar to Size. /// - public static string DeleteApiKeyQuestion { + public static string Size { get { - return ResourceManager.GetString("DeleteApiKeyQuestion", resourceCulture); + return ResourceManager.GetString("Size", resourceCulture); } } /// - /// Looks up a localized string similar to The API key has been created.. + /// Looks up a localized string similar to Skills. /// - public static string ApiKeyCreated { + public static string Skills { get { - return ResourceManager.GetString("ApiKeyCreated", resourceCulture); + return ResourceManager.GetString("Skills", resourceCulture); } } /// - /// Looks up a localized string similar to The API key has been deleted.. + /// Looks up a localized string similar to Socket {0}. /// - public static string ApiKeyDeleted { + public static string SocketNumber { get { - return ResourceManager.GetString("ApiKeyDeleted", resourceCulture); + return ResourceManager.GetString("SocketNumber", resourceCulture); } } /// - /// Looks up a localized string similar to The API key has been enabled.. + /// Looks up a localized string similar to Start. /// - public static string ApiKeyEnabled { + public static string Start { get { - return ResourceManager.GetString("ApiKeyEnabled", resourceCulture); + return ResourceManager.GetString("Start", resourceCulture); } } /// - /// Looks up a localized string similar to The API key has been disabled.. + /// Looks up a localized string similar to Started At. /// - public static string ApiKeyDisabled { + public static string StartedAt { get { - return ResourceManager.GetString("ApiKeyDisabled", resourceCulture); + return ResourceManager.GetString("StartedAt", resourceCulture); } } /// - /// Looks up a localized string similar to Copy this key now. + /// Looks up a localized string similar to Start install. /// - public static string ApiKeyShownOnce { + public static string StartInstall { get { - return ResourceManager.GetString("ApiKeyShownOnce", resourceCulture); + return ResourceManager.GetString("StartInstall", resourceCulture); } } /// - /// Looks up a localized string similar to Only the hash of the key is stored, so this is the only time it's shown. If it gets lost, delete the key and create a new one.. + /// Looks up a localized string similar to Stop. /// - public static string ApiKeyShownOnceDescription { + public static string Stop { get { - return ResourceManager.GetString("ApiKeyShownOnceDescription", resourceCulture); + return ResourceManager.GetString("Stop", resourceCulture); } } /// - /// Looks up a localized string similar to No API key has been created yet.. + /// Looks up a localized string similar to Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.. /// - public static string NoApiKeys { + public static string SwappingToDevForMoreInformation { get { - return ResourceManager.GetString("NoApiKeys", resourceCulture); + return ResourceManager.GetString("SwappingToDevForMoreInformation", resourceCulture); } } /// - /// Looks up a localized string similar to Enable. + /// Looks up a localized string similar to System. /// - public static string Enable { + public static string System { get { - return ResourceManager.GetString("Enable", resourceCulture); + return ResourceManager.GetString("System", resourceCulture); } } /// - /// Looks up a localized string similar to Disable. + /// Looks up a localized string similar to Target. /// - public static string Disable { + public static string Target { get { - return ResourceManager.GetString("Disable", resourceCulture); + return ResourceManager.GetString("Target", resourceCulture); } } /// - /// Looks up a localized string similar to Copy. + /// Looks up a localized string similar to Do you want test accounts?. /// - public static string CopyToClipboard { + public static string TestAccountsQuestion { get { - return ResourceManager.GetString("CopyToClipboard", resourceCulture); + return ResourceManager.GetString("TestAccountsQuestion", resourceCulture); } } /// - /// Looks up a localized string similar to The API key has been copied to the clipboard.. + /// Looks up a localized string similar to The updates require a restart of the server process to take effect.. /// - public static string CopiedToClipboard { + public static string TheUpdatesRequireARestartOfTheServerProcessToTakeEffect { get { - return ResourceManager.GetString("CopiedToClipboard", resourceCulture); + return ResourceManager.GetString("TheUpdatesRequireARestartOfTheServerProcessToTakeEffect", resourceCulture); } } /// - /// Looks up a localized string similar to The API key could not be copied. Please select and copy it by hand.. + /// Looks up a localized string similar to Timestamp. /// - public static string CopyToClipboardFailed { + public static string Timestamp { get { - return ResourceManager.GetString("CopyToClipboardFailed", resourceCulture); + return ResourceManager.GetString("Timestamp", resourceCulture); } } /// - /// Looks up a localized string similar to Network Analyzer. + /// Looks up a localized string similar to To Client. /// - public static string NetworkAnalyzer { + public static string ToClient { get { - return ResourceManager.GetString("NetworkAnalyzer", resourceCulture); + return ResourceManager.GetString("ToClient", resourceCulture); } } /// - /// Looks up a localized string similar to Chat Server. + /// Looks up a localized string similar to To Server. /// - public static string ChatServer { + public static string ToServer { get { - return ResourceManager.GetString("ChatServer", resourceCulture); + return ResourceManager.GetString("ToServer", resourceCulture); } } /// - /// Looks up a localized string similar to Connections. + /// Looks up a localized string similar to Total Players. /// - public static string Connections { + public static string TotalPlayers { get { - return ResourceManager.GetString("Connections", resourceCulture); + return ResourceManager.GetString("TotalPlayers", resourceCulture); } } /// - /// Looks up a localized string similar to No connections found.. + /// Looks up a localized string similar to Tracing. /// - public static string NoConnectionsFound { + public static string Tracing { get { - return ResourceManager.GetString("NoConnectionsFound", resourceCulture); + return ResourceManager.GetString("Tracing", resourceCulture); } } /// - /// Looks up a localized string similar to Select a connection to see its network traffic.. + /// Looks up a localized string similar to Second factor. /// - public static string SelectAConnection { + public static string TwoFactor { get { - return ResourceManager.GetString("SelectAConnection", resourceCulture); + return ResourceManager.GetString("TwoFactor", resourceCulture); } } /// - /// Looks up a localized string similar to The network analyzer is only available in the all-in-one deployment, because it needs the servers in the same process.. + /// Looks up a localized string similar to Two-factor authentication. /// - public static string NetworkAnalyzerNotAvailable { + public static string TwoFactorAuthentication { get { - return ResourceManager.GetString("NetworkAnalyzerNotAvailable", resourceCulture); + return ResourceManager.GetString("TwoFactorAuthentication", resourceCulture); } } /// - /// Looks up a localized string similar to Captured Packets. + /// Looks up a localized string similar to Two-factor authentication is not set up yet.. /// - public static string CapturedPackets { + public static string TwoFactorDisabled { get { - return ResourceManager.GetString("CapturedPackets", resourceCulture); + return ResourceManager.GetString("TwoFactorDisabled", resourceCulture); } } /// - /// Looks up a localized string similar to Timestamp. + /// Looks up a localized string similar to Two-factor authentication is enabled.. /// - public static string Timestamp { + public static string TwoFactorEnabled { get { - return ResourceManager.GetString("Timestamp", resourceCulture); + return ResourceManager.GetString("TwoFactorEnabled", resourceCulture); } } /// - /// Looks up a localized string similar to Direction. + /// Looks up a localized string similar to Enter the code from your authenticator app.. /// - public static string Direction { + public static string TwoFactorPrompt { get { - return ResourceManager.GetString("Direction", resourceCulture); + return ResourceManager.GetString("TwoFactorPrompt", resourceCulture); } } /// - /// Looks up a localized string similar to Code. + /// Looks up a localized string similar to The server configuration requires every admin panel user to use a second factor. Please set up your authenticator app to continue.. /// - public static string PacketCode { + public static string TwoFactorRequiredDescription { get { - return ResourceManager.GetString("PacketCode", resourceCulture); + return ResourceManager.GetString("TwoFactorRequiredDescription", resourceCulture); } } /// - /// Looks up a localized string similar to Message. + /// Looks up a localized string similar to Two-factor authentication is required. /// - public static string PacketMessage { + public static string TwoFactorRequiredTitle { get { - return ResourceManager.GetString("PacketMessage", resourceCulture); + return ResourceManager.GetString("TwoFactorRequiredTitle", resourceCulture); } } /// - /// Looks up a localized string similar to Packet Details. + /// Looks up a localized string similar to The second factor of the user has been reset.. /// - public static string PacketDetails { + public static string TwoFactorResetForUser { get { - return ResourceManager.GetString("PacketDetails", resourceCulture); + return ResourceManager.GetString("TwoFactorResetForUser", resourceCulture); } } /// - /// Looks up a localized string similar to Extracted Information. + /// Looks up a localized string similar to Two-factor authentication. /// - public static string ExtractedInformation { + public static string TwoFactorTitle { get { - return ResourceManager.GetString("ExtractedInformation", resourceCulture); + return ResourceManager.GetString("TwoFactorTitle", resourceCulture); } } /// - /// Looks up a localized string similar to Raw Data. + /// Looks up a localized string similar to Type '{0}' does not support cloning.. /// - public static string RawData { + public static string TypeDoesNotSupportCloning { get { - return ResourceManager.GetString("RawData", resourceCulture); + return ResourceManager.GetString("TypeDoesNotSupportCloning", resourceCulture); } } /// - /// Looks up a localized string similar to Clear. + /// Looks up a localized string similar to An unexpected error occurred: {0}. See logs for more details.. /// - public static string ClearPackets { + public static string UnexpectedErrorCheckLogs { get { - return ResourceManager.GetString("ClearPackets", resourceCulture); + return ResourceManager.GetString("UnexpectedErrorCheckLogs", resourceCulture); } } /// - /// Looks up a localized string similar to To Server. + /// Looks up a localized string similar to An unexpected error occurred: {0}.. /// - public static string ToServer { + public static string UnexpectedErrorOccurred { get { - return ResourceManager.GetString("ToServer", resourceCulture); + return ResourceManager.GetString("UnexpectedErrorOccurred", resourceCulture); } } /// - /// Looks up a localized string similar to To Client. + /// Looks up a localized string similar to An unhandled error has occurred.. /// - public static string ToClient { + public static string UnhandledErrorOccurred { get { - return ResourceManager.GetString("ToClient", resourceCulture); + return ResourceManager.GetString("UnhandledErrorOccurred", resourceCulture); } } /// - /// Looks up a localized string similar to Filter by code or message. + /// Looks up a localized string similar to There are unsaved changes. Are you sure you want to discard them?. /// - public static string FilterPackets { + public static string UnsavedChangesQuestion { get { - return ResourceManager.GetString("FilterPackets", resourceCulture); + return ResourceManager.GetString("UnsavedChangesQuestion", resourceCulture); } } /// - /// Looks up a localized string similar to No packets captured yet.. + /// Looks up a localized string similar to Update. /// - public static string NoPacketsCaptured { + public static string Update { get { - return ResourceManager.GetString("NoPacketsCaptured", resourceCulture); + return ResourceManager.GetString("Update", resourceCulture); } } /// - /// Looks up a localized string similar to Select a packet to see its content.. + /// Looks up a localized string similar to Update Failed!. /// - public static string SelectAPacket { + public static string UpdateFailed { get { - return ResourceManager.GetString("SelectAPacket", resourceCulture); + return ResourceManager.GetString("UpdateFailed", resourceCulture); } } /// - /// Looks up a localized string similar to Follow. + /// Looks up a localized string similar to Update required. /// - public static string FollowNewPackets { + public static string UpdateRequired { get { - return ResourceManager.GetString("FollowNewPackets", resourceCulture); + return ResourceManager.GetString("UpdateRequired", resourceCulture); } } /// - /// Looks up a localized string similar to Shows the newest packets and scrolls to them. It's turned off while you scroll up, so that the view doesn't move away.. + /// Looks up a localized string similar to Updates. /// - public static string FollowNewPacketsHint { + public static string Updates { get { - return ResourceManager.GetString("FollowNewPacketsHint", resourceCulture); + return ResourceManager.GetString("Updates", resourceCulture); } } - + /// + /// Looks up a localized string similar to Updating.... + /// + public static string Updating { + get { + return ResourceManager.GetString("Updating", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Up-to-date. + /// + public static string UpToDate { + get { + return ResourceManager.GetString("UpToDate", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Use an authenticator code instead. + /// + public static string UseAuthenticatorCode { + get { + return ResourceManager.GetString("UseAuthenticatorCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The user has been created.. + /// + public static string UserCreated { + get { + return ResourceManager.GetString("UserCreated", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The user has been deleted.. + /// + public static string UserDeleted { + get { + return ResourceManager.GetString("UserDeleted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Use a recovery code instead. + /// + public static string UseRecoveryCode { + get { + return ResourceManager.GetString("UseRecoveryCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Users. + /// + public static string Users { + get { + return ResourceManager.GetString("Users", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Verify. + /// + public static string Verify { + get { + return ResourceManager.GetString("Verify", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Warp list. + /// + public static string WarpList { + get { + return ResourceManager.GetString("WarpList", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Welcome to the admin panel of OpenMU.. + /// + public static string WelcomeMessage { + get { + return ResourceManager.GetString("WelcomeMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Yes. + /// + public static string Yes { + get { + return ResourceManager.GetString("Yes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Yes, create test accounts. + /// + public static string YesCreateTestAccounts { + get { + return ResourceManager.GetString("YesCreateTestAccounts", resourceCulture); + } + } } } diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx index f2436659d7..1460c51c45 100644 --- a/src/Web/AdminPanel/Properties/Resources.resx +++ b/src/Web/AdminPanel/Properties/Resources.resx @@ -561,6 +561,45 @@ Game server count + + JSON Backup (slow!) + + + Export JSON Backup + + + Import JSON Backup + + + Importing backup, please wait ... + + + Backup import succeeded. Please restart the server process to apply the changes. + + + Backup import failed. + + + Select a .zip backup file to restore + + + The selected file is no backup archive. The database was left untouched. + + + Database Snapshot + + + A snapshot contains all data of the database and is created and restored a lot faster. It can be restored by this or a newer version of the server, which applies its database updates to the restored data. To transfer data to an older version, use the backup above. + + + Export Snapshot + + + Select a .zip snapshot file to restore + + + Include the accounts (takes longer) + Character diff --git a/src/Web/AdminPanel/Services/ConfigurationSearchIndexCache.cs b/src/Web/AdminPanel/Services/ConfigurationSearchIndexCache.cs index 594f0753c9..68a3dd5470 100644 --- a/src/Web/AdminPanel/Services/ConfigurationSearchIndexCache.cs +++ b/src/Web/AdminPanel/Services/ConfigurationSearchIndexCache.cs @@ -8,7 +8,6 @@ namespace MUnique.OpenMU.Web.AdminPanel.Services; using System.Threading; using Microsoft.Extensions.Logging; using MUnique.OpenMU.DataModel.Configuration; -using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Persistence; /// diff --git a/src/Web/AdminPanel/WebApplicationExtensions.cs b/src/Web/AdminPanel/WebApplicationExtensions.cs index 96052f57d0..8cb0c570e3 100644 --- a/src/Web/AdminPanel/WebApplicationExtensions.cs +++ b/src/Web/AdminPanel/WebApplicationExtensions.cs @@ -4,7 +4,6 @@ namespace MUnique.OpenMU.Web.AdminPanel; -using System.IO; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.StaticWebAssets; using Microsoft.Extensions.DependencyInjection; @@ -15,6 +14,7 @@ namespace MUnique.OpenMU.Web.AdminPanel; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.Network.Analyzer; using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Persistence.AdminAuth; using MUnique.OpenMU.Persistence.Initialization.Updates; using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix; using MUnique.OpenMU.Web.AdminPanel.Auth; @@ -23,6 +23,7 @@ namespace MUnique.OpenMU.Web.AdminPanel; using MUnique.OpenMU.Web.Shared.Components.Modal; using MUnique.OpenMU.Web.Shared.Models; using MUnique.OpenMU.Web.Shared.Services; +using System.IO; /// /// Extensions for the . diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/BackupServiceEfCoreTests.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/BackupServiceEfCoreTests.cs new file mode 100644 index 0000000000..50625ae597 --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/BackupServiceEfCoreTests.cs @@ -0,0 +1,91 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Tests; + +using System.IO; +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence.EntityFramework; +using MUnique.OpenMU.Persistence.EntityFramework.Json; +using MUnique.OpenMU.PlugIns; + +/// +/// Tests the backup with the entity framework core, which requires a running postgres database. +/// +[TestFixture] +internal class BackupServiceEfCoreTests +{ + /// + /// Creates the initial data, exports a backup, re-creates the database, restores the backup + /// and checks if the data is there again. + /// + [Test] + [Ignore("This is not a real test which should run automatically. It requires a database.")] + public async Task ExportAndRestoreRoundTripAsync() + { + // These converters are registered by the host application (see Startup/Program.cs). + JsonConverterRegistry.ClearConverters(); + JsonConverterRegistry.RegisterConverter(new LocalizedStringJsonConverter()); + JsonConverterRegistry.RegisterConverter(new BinaryAsHexJsonConverter()); + + await ReCreateDatabaseAsync().ConfigureAwait(false); + var sourceProvider = new PersistenceContextProvider(new NullLoggerFactory(), null); + await new VersionSeasonSix.DataInitialization(sourceProvider, new NullLoggerFactory()) + .CreateInitialDataAsync(1, true).ConfigureAwait(false); + + var expected = await GetCountsAsync(sourceProvider).ConfigureAwait(false); + + using var backup = new MemoryStream(); + await new BackupService(sourceProvider, new InMemoryAdminUserRepository()).CreateBackupAsync(backup).ConfigureAwait(false); + Assert.That(backup.Length, Is.GreaterThan(0)); + + await ReCreateDatabaseAsync().ConfigureAwait(false); + backup.Position = 0; + var targetProvider = new PersistenceContextProvider(new NullLoggerFactory(), null); + await new BackupService(targetProvider, new InMemoryAdminUserRepository()).RestoreBackupAsync(backup).ConfigureAwait(false); + + var actual = await GetCountsAsync(new PersistenceContextProvider(new NullLoggerFactory(), null)).ConfigureAwait(false); + Assert.That(actual, Is.EqualTo(expected)); + } + + private static async ValueTask ReCreateDatabaseAsync() + { + var contextProvider = new PersistenceContextProvider(new NullLoggerFactory(), null); + using var update = await contextProvider.ReCreateDatabaseAsync().ConfigureAwait(false); + } + + private static async ValueTask> GetCountsAsync(IPersistenceContextProvider contextProvider) + { + using var context = contextProvider.CreateNewContext(); + var configuration = (await context.GetAsync().ConfigureAwait(false)).Single(); + + return new Dictionary + { + [nameof(GameConfiguration.Maps)] = configuration.Maps.Count, + [nameof(GameConfiguration.Items)] = configuration.Items.Count, + [nameof(GameConfiguration.Monsters)] = configuration.Monsters.Count, + [nameof(GameConfiguration.Skills)] = configuration.Skills.Count, + [nameof(GameConfiguration.CharacterClasses)] = configuration.CharacterClasses.Count, + [nameof(GameConfiguration.Attributes)] = configuration.Attributes.Count, + [nameof(GameConfiguration.ItemSlotTypes)] = configuration.ItemSlotTypes.Sum(slotType => slotType.ItemSlots.Count), + + // The character classes hold const value attributes, whose value can only be set by their constructor: + ["BaseAttributeValues"] = configuration.CharacterClasses.Sum(c => c.BaseAttributeValues.Count(a => a.Value != 0)), + + // Many-to-many relations, which are stored in join entities by the entity framework: + ["QualifiedCharacters"] = configuration.Items.Sum(item => item.QualifiedCharacters.Count), + ["ItemDropGroups"] = configuration.Maps.Sum(map => map.DropItemGroups.Count), + + [nameof(Account)] = (await context.GetAsync().ConfigureAwait(false)).Count(), + [nameof(GameServerDefinition)] = (await context.GetAsync().ConfigureAwait(false)).Count(), + [nameof(ConnectServerDefinition)] = (await context.GetAsync().ConfigureAwait(false)).Count(), + [nameof(ChatServerDefinition)] = (await context.GetAsync().ConfigureAwait(false)).Count(), + [nameof(SystemConfiguration)] = (await context.GetAsync().ConfigureAwait(false)).Count(), + ["AppliedUpdates"] = (await context.GetAsync().ConfigureAwait(false)).Count(), + }; + } +} diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/BackupServiceTests.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/BackupServiceTests.cs new file mode 100644 index 0000000000..d72e8e0c84 --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/BackupServiceTests.cs @@ -0,0 +1,203 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Tests; + +using System.IO; +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Persistence.AdminAuth; +using MUnique.OpenMU.Persistence.InMemory; + +/// +/// Tests for the . +/// +[TestFixture] +public class BackupServiceTests +{ + /// + /// Tests if the data of an exported backup can be restored again. + /// + [Test] + public async Task ExportAndRestoreRoundTripAsync() + { + var sourceProvider = new InMemoryPersistenceContextProvider(); + var dataInitialization = new VersionSeasonSix.DataInitialization(sourceProvider, new NullLoggerFactory()); + await dataInitialization.CreateInitialDataAsync(1, true).ConfigureAwait(false); + + var sourceAdminUsers = new InMemoryAdminUserRepository(); + await sourceAdminUsers.AddAsync(new AdminUser + { + Id = Guid.NewGuid(), + LoginName = "Admin", + NormalizedLoginName = "ADMIN", + PasswordHash = "hash", + Roles = AdminRoles.Administrator, + }).ConfigureAwait(false); + + using var backupStream = new MemoryStream(); + await new BackupService(sourceProvider, sourceAdminUsers).CreateBackupAsync(backupStream).ConfigureAwait(false); + Assert.That(backupStream.Length, Is.GreaterThan(0)); + + backupStream.Position = 0; + var targetProvider = new InMemoryPersistenceContextProvider(); + var targetAdminUsers = new InMemoryAdminUserRepository(); + var targetBackupService = new BackupService(targetProvider, targetAdminUsers); + Assert.That(targetBackupService.ContainsRestorableData(backupStream), Is.True); + Assert.That(backupStream.Position, Is.Zero, "The stream position should be restored after the check."); + await targetBackupService.RestoreBackupAsync(backupStream).ConfigureAwait(false); + + await AssertSameCountAsync(sourceProvider, targetProvider).ConfigureAwait(false); + await AssertSameCountAsync(sourceProvider, targetProvider).ConfigureAwait(false); + await AssertSameCountAsync(sourceProvider, targetProvider).ConfigureAwait(false); + await AssertSameCountAsync(sourceProvider, targetProvider).ConfigureAwait(false); + await AssertSameCountAsync(sourceProvider, targetProvider).ConfigureAwait(false); + + using var sourceContext = sourceProvider.CreateNewContext(); + using var targetContext = targetProvider.CreateNewContext(); + var sourceConfig = (await sourceContext.GetAsync().ConfigureAwait(false)).Single(); + var targetConfig = (await targetContext.GetAsync().ConfigureAwait(false)).Single(); + + Assert.Multiple(() => + { + Assert.That(GetId(targetConfig), Is.EqualTo(GetId(sourceConfig))); + Assert.That(targetConfig.ExperienceRate, Is.EqualTo(sourceConfig.ExperienceRate)); + Assert.That(targetConfig.Maps, Has.Count.EqualTo(sourceConfig.Maps.Count)); + Assert.That(targetConfig.Items, Has.Count.EqualTo(sourceConfig.Items.Count)); + Assert.That(targetConfig.CharacterClasses, Has.Count.EqualTo(sourceConfig.CharacterClasses.Count)); + Assert.That(targetConfig.Attributes, Has.Count.EqualTo(sourceConfig.Attributes.Count)); + Assert.That(targetConfig.Monsters, Has.Count.EqualTo(sourceConfig.Monsters.Count)); + Assert.That( + targetConfig.ItemSlotTypes.Sum(s => s.ItemSlots.Count), + Is.EqualTo(sourceConfig.ItemSlotTypes.Sum(s => s.ItemSlots.Count)), + "The item slots (a collection of value types) were not restored."); + }); + + var sourceAccount = (await sourceContext.GetAsync().ConfigureAwait(false)).OrderBy(a => a.LoginName).First(); + var targetAccount = (await targetContext.GetAsync().ConfigureAwait(false)).OrderBy(a => a.LoginName).First(); + Assert.Multiple(() => + { + Assert.That(targetAccount.LoginName, Is.EqualTo(sourceAccount.LoginName)); + Assert.That(targetAccount.PasswordHash, Is.EqualTo(sourceAccount.PasswordHash)); + Assert.That(targetAccount.Characters, Has.Count.EqualTo(sourceAccount.Characters.Count)); + }); + + var restoredAdminUsers = await targetAdminUsers.GetAllAsync().ConfigureAwait(false); + Assert.That(restoredAdminUsers, Has.Count.EqualTo(1)); + Assert.That(restoredAdminUsers[0].LoginName, Is.EqualTo("Admin")); + + var sourceCharacter = sourceAccount.Characters.OrderBy(c => c.Name).First(); + var targetCharacter = targetAccount.Characters.OrderBy(c => c.Name).First(); + Assert.Multiple(() => + { + Assert.That(targetCharacter.Name, Is.EqualTo(sourceCharacter.Name)); + Assert.That(GetId(targetCharacter.CharacterClass!), Is.EqualTo(GetId(sourceCharacter.CharacterClass!))); + Assert.That(targetCharacter.Inventory?.Items, Has.Count.EqualTo(sourceCharacter.Inventory?.Items.Count)); + Assert.That(targetCharacter.Attributes, Has.Count.EqualTo(sourceCharacter.Attributes.Count)); + + // References between the different backup files must point to the same restored instances. + Assert.That( + targetCharacter.CharacterClass, + Is.SameAs(targetConfig.CharacterClasses.First(c => GetId(c) == GetId(targetCharacter.CharacterClass!)))); + }); + } + + /// + /// Tests if all types which hold data are covered by the backup. + /// It's easy to forget to add a new type of the data model to the backup process, + /// so this test compares the objects of all types before and after a round trip. + /// + [Test] + public async Task AllTypesOfTheDataModelAreCoveredAsync() + { + var sourceProvider = new InMemoryPersistenceContextProvider(); + await new VersionSeasonSix.DataInitialization(sourceProvider, new NullLoggerFactory()) + .CreateInitialDataAsync(1, true).ConfigureAwait(false); + + using var backupStream = new MemoryStream(); + var adminUsers = new InMemoryAdminUserRepository(); + await new BackupService(sourceProvider, adminUsers).CreateBackupAsync(backupStream).ConfigureAwait(false); + + backupStream.Position = 0; + var targetProvider = new InMemoryPersistenceContextProvider(); + await new BackupService(targetProvider, new InMemoryAdminUserRepository()).RestoreBackupAsync(backupStream).ConfigureAwait(false); + + var expected = await GetObjectCountsAsync(sourceProvider).ConfigureAwait(false); + var actual = await GetObjectCountsAsync(targetProvider).ConfigureAwait(false); + + // We don't compare the exact numbers here: the data initialization leaves some objects behind + // which are not referenced by anything (e.g. items without an item storage). They can't be + // reached from the exported root objects, so they are not part of the backup. + // A type which is not covered at all doesn't have any object after the restore. + var missingTypes = expected + .Where(pair => !actual.ContainsKey(pair.Key)) + .Select(pair => $"{pair.Key} ({pair.Value} objects)") + .ToList(); + + Assert.That( + missingTypes, + Is.Empty, + "These types are not covered by the backup. If you added a type to the data model, add it to the BackupService, too."); + } + + /// + /// Tests if a file which is no backup archive is detected as such, so that the database isn't dropped for nothing. + /// + [Test] + public void ContainsRestorableDataReturnsFalseForOtherFiles() + { + var backupService = new BackupService(new InMemoryPersistenceContextProvider(), new InMemoryAdminUserRepository()); + using var noZipStream = new MemoryStream("This is not a zip archive."u8.ToArray()); + + Assert.That(backupService.ContainsRestorableData(noZipStream), Is.False); + Assert.That(noZipStream.Position, Is.Zero); + } + + /// + /// Counts the objects of every type of the data model which is known to the persistence. + /// + /// The context provider. + /// The number of objects, by the name of their type. + private static async Task> GetObjectCountsAsync(IPersistenceContextProvider contextProvider) + { + using var context = contextProvider.CreateNewContext(); + var result = new Dictionary(); + var dataModelTypes = typeof(GameConfiguration).Assembly.GetTypes() + .Where(type => type is { IsClass: true, IsAbstract: false, IsPublic: true } + && type.Namespace?.StartsWith("MUnique.OpenMU.DataModel", StringComparison.Ordinal) is true) + .OrderBy(type => type.FullName, StringComparer.Ordinal); + + foreach (var type in dataModelTypes) + { + try + { + var objects = await context.GetAsync(type).ConfigureAwait(false); + var count = objects.Cast().Count(); + if (count > 0) + { + result.Add(type.FullName!, count); + } + } + catch + { + // Not every type has a repository - these can't hold data on their own. + } + } + + return result; + } + + private static Guid GetId(object obj) => ((IIdentifiable)obj).Id; + + private static async Task AssertSameCountAsync(IPersistenceContextProvider source, IPersistenceContextProvider target) + where T : class + { + using var sourceContext = source.CreateNewContext(); + using var targetContext = target.CreateNewContext(); + var sourceCount = (await sourceContext.GetAsync().ConfigureAwait(false)).Count(); + var targetCount = (await targetContext.GetAsync().ConfigureAwait(false)).Count(); + Assert.That(targetCount, Is.EqualTo(sourceCount), $"Unexpected number of restored {typeof(T).Name} objects."); + } +} diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/BinaryAsHexJsonConverterTests.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/BinaryAsHexJsonConverterTests.cs new file mode 100644 index 0000000000..13b03d09cb --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/BinaryAsHexJsonConverterTests.cs @@ -0,0 +1,40 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Tests; + +using System.Text.Json; +using MUnique.OpenMU.Persistence.EntityFramework.Json; + +/// +/// Tests for the . +/// +[TestFixture] +public class BinaryAsHexJsonConverterTests +{ + /// + /// Tests if a byte array is read back completely. + /// It previously lost its last two bytes, because the prefix was subtracted twice. + /// + /// The length of the tested byte array. + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(10)] + [TestCase(65537)] // the size of the terrain data of a game map + public void ByteArrayIsReadCompletely(int length) + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new BinaryAsHexJsonConverter()); + + var data = new byte[length]; + new Random(42).NextBytes(data); + + var json = JsonSerializer.Serialize(data, options); + var readData = JsonSerializer.Deserialize(json, options); + + Assert.That(readData, Is.EqualTo(data)); + } +} diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DatabaseSnapshotServiceTests.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DatabaseSnapshotServiceTests.cs new file mode 100644 index 0000000000..8cf7ef64aa --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DatabaseSnapshotServiceTests.cs @@ -0,0 +1,137 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Tests; + +using System.IO; +using System.IO.Compression; +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence.EntityFramework; +using MUnique.OpenMU.Persistence.EntityFramework.Json; +using MUnique.OpenMU.PlugIns; + +/// +/// Tests the database snapshot, which requires a running postgres database. +/// +[TestFixture] +internal class DatabaseSnapshotServiceTests +{ + /// + /// Creates the initial data, creates a snapshot, re-creates the database, restores the snapshot + /// and checks if the data is there again. + /// + [Test] + [Ignore("This is not a real test which should run automatically. It requires a database.")] + public async Task SnapshotRoundTripAsync() + { + // These converters are registered by the host application (see Startup/Program.cs). + JsonConverterRegistry.ClearConverters(); + JsonConverterRegistry.RegisterConverter(new LocalizedStringJsonConverter()); + JsonConverterRegistry.RegisterConverter(new BinaryAsHexJsonConverter()); + + await ReCreateDatabaseAsync().ConfigureAwait(false); + var sourceProvider = new PersistenceContextProvider(new NullLoggerFactory(), null); + await new VersionSeasonSix.DataInitialization(sourceProvider, new NullLoggerFactory()) + .CreateInitialDataAsync(1, true).ConfigureAwait(false); + + var expected = await GetCountsAsync(sourceProvider).ConfigureAwait(false); + + var snapshotService = new DatabaseSnapshotService(); + using var snapshot = new MemoryStream(); + await snapshotService.CreateSnapshotAsync(snapshot).ConfigureAwait(false); + Assert.That(snapshot.Length, Is.GreaterThan(0)); + + snapshot.Position = 0; + Assert.That(await snapshotService.GetRestoreBlockingReasonAsync(snapshot).ConfigureAwait(false), Is.Null); + Assert.That(snapshot.Position, Is.Zero, "The stream position should be restored after the check."); + + // The restore re-creates the database on its own. + await snapshotService.RestoreSnapshotAsync(snapshot).ConfigureAwait(false); + + var actual = await GetCountsAsync(new PersistenceContextProvider(new NullLoggerFactory(), null)).ConfigureAwait(false); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// + /// Tests if a snapshot of a newer server is rejected - we don't know how to create its schema. + /// + [Test] + [Ignore("This is not a real test which should run automatically. It requires a database.")] + public async Task SnapshotOfNewerServerIsRejectedAsync() + { + await ReCreateDatabaseAsync().ConfigureAwait(false); + var snapshotService = new DatabaseSnapshotService(); + using var snapshot = new MemoryStream(); + await snapshotService.CreateSnapshotAsync(snapshot).ConfigureAwait(false); + + // Pretend that the snapshot was created by a server which has one more migration: + snapshot.Position = 0; + using (var archive = new ZipArchive(snapshot, ZipArchiveMode.Update, leaveOpen: true)) + { + var manifestEntry = archive.GetEntry("manifest.json")!; + string manifest; + await using (var readStream = manifestEntry.Open()) + { + using var reader = new StreamReader(readStream); + manifest = await reader.ReadToEndAsync().ConfigureAwait(false); + } + + manifest = manifest.Replace("\"Migrations\":[", "\"Migrations\":[\"99999999999999_FromTheFuture\",", StringComparison.Ordinal); + manifestEntry.Delete(); + var newEntry = archive.CreateEntry("manifest.json"); + await using var writeStream = newEntry.Open(); + await using var writer = new StreamWriter(writeStream); + await writer.WriteAsync(manifest).ConfigureAwait(false); + } + + snapshot.Position = 0; + var reason = await snapshotService.GetRestoreBlockingReasonAsync(snapshot).ConfigureAwait(false); + Assert.That(reason, Does.Contain("99999999999999_FromTheFuture")); + } + + /// + /// Tests if a file which is no snapshot is rejected, so that the database isn't dropped for nothing. + /// + [Test] + [Ignore("This is not a real test which should run automatically. It requires a database.")] + public async Task OtherFilesAreRejectedAsync() + { + var snapshotService = new DatabaseSnapshotService(); + using var noZipStream = new MemoryStream("This is not a zip archive."u8.ToArray()); + + Assert.That(await snapshotService.GetRestoreBlockingReasonAsync(noZipStream).ConfigureAwait(false), Is.Not.Null); + Assert.That(noZipStream.Position, Is.Zero); + } + + private static async ValueTask ReCreateDatabaseAsync() + { + var contextProvider = new PersistenceContextProvider(new NullLoggerFactory(), null); + using var update = await contextProvider.ReCreateDatabaseAsync().ConfigureAwait(false); + } + + private static async ValueTask> GetCountsAsync(IPersistenceContextProvider contextProvider) + { + using var context = contextProvider.CreateNewContext(); + var configuration = (await context.GetAsync().ConfigureAwait(false)).Single(); + + return new Dictionary + { + [nameof(GameConfiguration.Maps)] = configuration.Maps.Count, + [nameof(GameConfiguration.Items)] = configuration.Items.Count, + [nameof(GameConfiguration.Monsters)] = configuration.Monsters.Count, + [nameof(GameConfiguration.Skills)] = configuration.Skills.Count, + [nameof(GameConfiguration.CharacterClasses)] = configuration.CharacterClasses.Count, + [nameof(GameConfiguration.Attributes)] = configuration.Attributes.Count, + [nameof(GameConfiguration.ItemSlotTypes)] = configuration.ItemSlotTypes.Sum(slotType => slotType.ItemSlots.Count), + ["BaseAttributeValues"] = configuration.CharacterClasses.Sum(c => c.BaseAttributeValues.Count(a => a.Value != 0)), + ["QualifiedCharacters"] = configuration.Items.Sum(item => item.QualifiedCharacters.Count), + [nameof(Account)] = (await context.GetAsync().ConfigureAwait(false)).Count(), + [nameof(SystemConfiguration)] = (await context.GetAsync().ConfigureAwait(false)).Count(), + ["AppliedUpdates"] = (await context.GetAsync().ConfigureAwait(false)).Count(), + }; + } +} diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/InMemoryAdminUserRepository.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/InMemoryAdminUserRepository.cs new file mode 100644 index 0000000000..e72e429f82 --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/InMemoryAdminUserRepository.cs @@ -0,0 +1,55 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Tests; + +using System.Threading; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// An in-memory for the tests. +/// +internal class InMemoryAdminUserRepository : IAdminUserRepository +{ + private readonly Dictionary _users = new(); + + /// + public ValueTask EnsureStorageAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult(true); + + /// + public ValueTask GetCountAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult(this._users.Count); + + /// + public ValueTask> GetAllAsync(CancellationToken cancellationToken = default) + => ValueTask.FromResult>(this._users.Values.OrderBy(u => u.LoginName).ToList()); + + /// + public ValueTask GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => ValueTask.FromResult(this._users.GetValueOrDefault(id)); + + /// + public ValueTask GetByNormalizedLoginNameAsync(string normalizedLoginName, CancellationToken cancellationToken = default) + => ValueTask.FromResult(this._users.Values.FirstOrDefault(u => u.NormalizedLoginName == normalizedLoginName)); + + /// + public ValueTask AddAsync(AdminUser user, CancellationToken cancellationToken = default) + { + this._users[user.Id] = user; + return ValueTask.CompletedTask; + } + + /// + public ValueTask UpdateAsync(AdminUser user, CancellationToken cancellationToken = default) + { + this._users[user.Id] = user; + return ValueTask.CompletedTask; + } + + /// + public ValueTask DeleteAsync(AdminUser user, CancellationToken cancellationToken = default) + { + this._users.Remove(user.Id); + return ValueTask.CompletedTask; + } +}