From cc46b22d49b8ce5387244da95ec4e6bbea66d2d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 16:47:43 +0000 Subject: [PATCH] Add backup export/import to the admin panel setup page This takes over the work of PR #715 (copilot/add-backup-restore-functionality), rebased onto the current master, and fixes the issues which were left open. Taken over from the pull request: * IBackupService/BackupService in the Persistence project, without any dependency to the entity framework - it works with the repositories which are available through IPersistenceContextProvider/IContext. * InMemoryBackupService which only supports the export. * BackupController and the export/import UI on the setup page. Fixed on top of it: * The restore didn't restore anything but the root objects: the exported json writes collections in the reference-preserving form ({ "$id", "$values" }), which the ReferenceResolvingConverter silently skipped, because it only accepted plain arrays. It now reads both forms. * Mapster ignores properties which are marked with the TransientAttribute, instead of scanning the member types for IElement. That's what the transient properties (e.g. of the SkillEntry) actually are, and it also covers the IAttributeSystem ones. Verified that the previously reported Mapster CompileException is reproducible without the rule and gone with it. * The object copy of the restore doesn't swallow exceptions anymore, so a failing restore is visible instead of resulting in missing data. Indexer properties are skipped (they caused a TargetParameterCountException) and the Add-method is taken from the ICollection interface. * The import checks the uploaded file before the database is re-created, so that selecting a wrong file doesn't drop the data. * Removed the unused upload action of the BackupController - it re-created the database before it knew if the upload was a backup at all, and skipped the DatabaseInitialized event of the SetupService. * Fixed the merge conflict with the current master, some StyleCop warnings and missing newlines at the end of files. Added a round trip test (export, restore into another context provider, compare) which covers the data of the season six data initialization, including that references between the backup files point to the same restored instances. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LhfyPthjrRZ6K9CgVd26RT --- src/Dapr/Common/Extensions.cs | 3 +- src/Persistence/BackupService.cs | 415 ++++++++++++++++++ .../Model/ExtendedTypes.Custom.cs | 20 +- .../Model/MapsterConfigurator.Generated.cs | 6 + src/Persistence/IBackupService.cs | 41 ++ .../InMemory/InMemoryBackupService.cs | 30 ++ .../Json/JsonObjectDeserializer.cs | 7 +- src/Persistence/Json/JsonObjectSerializer.cs | 55 ++- .../Json/ReferenceResolvingConverter.cs | 71 ++- .../SourceGenerator/EfCoreModelGenerator.cs | 6 + src/Startup/Program.cs | 10 + src/Web/AdminPanel/API/BackupController.cs | 45 ++ src/Web/AdminPanel/Pages/Setup.razor | 21 + src/Web/AdminPanel/Pages/Setup.razor.cs | 58 ++- .../Properties/Resources.Designer.cs | 63 +++ src/Web/AdminPanel/Properties/Resources.resx | 23 +- .../BackupServiceTests.cs | 111 +++++ 17 files changed, 945 insertions(+), 40 deletions(-) create mode 100644 src/Persistence/BackupService.cs create mode 100644 src/Persistence/IBackupService.cs create mode 100644 src/Persistence/InMemory/InMemoryBackupService.cs create mode 100644 src/Web/AdminPanel/API/BackupController.cs create mode 100644 tests/MUnique.OpenMU.Persistence.Initialization.Tests/BackupServiceTests.cs diff --git a/src/Dapr/Common/Extensions.cs b/src/Dapr/Common/Extensions.cs index efae0cb1a5..f3ef1c95b1 100644 --- a/src/Dapr/Common/Extensions.cs +++ b/src/Dapr/Common/Extensions.cs @@ -53,7 +53,8 @@ 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)) + .AddSingleton(s => new BackupService(s.GetRequiredService())); } /// diff --git a/src/Persistence/BackupService.cs b/src/Persistence/BackupService.cs new file mode 100644 index 0000000000..17af4f364b --- /dev/null +++ b/src/Persistence/BackupService.cs @@ -0,0 +1,415 @@ +// +// 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.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 = + [ + ("GameConfiguration_", typeof(BasicModel.GameConfiguration)), + ("ChatServerDefinition_", typeof(BasicModel.ChatServerDefinition)), + ("ConnectServerDefinition_", typeof(BasicModel.ConnectServerDefinition)), + ("GameServerDefinition_", typeof(BasicModel.GameServerDefinition)), + ("Account_", typeof(BasicModel.Account)), + ]; + + private readonly IPersistenceContextProvider _contextProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The persistence context provider. + public BackupService(IPersistenceContextProvider contextProvider) + { + this._contextProvider = contextProvider; + } + + /// + public async Task CreateBackupAsync(Stream outputStream, CancellationToken cancellationToken = default) + { + 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(); + + // 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, "ChatServerDefinition_", context, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportAsync(archive, "ConnectServerDefinition_", context, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportAsync(archive, "GameServerDefinition_", context, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportAsync(archive, "Account_", 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) + { + 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 = entry.Open(); + var basicModelObj = await DeserializeAsync(entryStream, typeInfo.Value.BasicModelType, sharedHandler, cancellationToken).ConfigureAwait(false); + if (basicModelObj is null) + { + continue; + } + + this.GetOrCreateObject(context, basicModelObj, createdObjects); + } + + await context.SaveChangesAsync(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(); + if (item is not IConvertibleTo convertible) + { + continue; + } + + if (item is not IIdentifiable identifiable) + { + continue; + } + + var basicModel = convertible.Convert(); + var entryName = $"{filePrefix}{identifiable.Id}.json"; + var entry = archive.CreateEntry(entryName); + await using var stream = entry.Open(); + 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.Account)) + { + 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? 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 dataModelBaseType = FindDataModelBaseType(basicModelObj.GetType()); + var newObj = context.CreateNew(dataModelBaseType); + + if (basicModelObj is IIdentifiable id2) + { + createdObjects[id2.Id] = newObj; + SetId(newObj, id2.Id); + } + + this.CopyProperties(basicModelObj, newObj, dataModelBaseType, context, createdObjects); + this.CopyRawCollectionProperties(basicModelObj, newObj, context, createdObjects); + + return newObj; + } + + 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 + || IsCollectionType(prop.PropertyType) + || IsTransient(prop)) + { + 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 sourceType = source.GetType(); + var targetType = target.GetType(); + + var rawCollectionProps = sourceType.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) + { + if (rawProp.GetValue(source) is not System.Collections.IEnumerable sourceEnumerable) + { + continue; + } + + var targetProp = targetType.GetProperty( + rawProp.Name, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy); + if (targetProp?.GetValue(target) is not { } targetCollection) + { + continue; + } + + var addMethod = FindCollectionAddMethod(targetProp.PropertyType) + ?? throw new InvalidOperationException($"Can't restore '{sourceType.Name}.{rawProp.Name}': the target collection '{targetProp.PropertyType}' has no Add-method."); + + foreach (var item in sourceEnumerable) + { + 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/Model/ExtendedTypes.Custom.cs b/src/Persistence/EntityFramework/Model/ExtendedTypes.Custom.cs index 624270cb25..c24416665f 100644 --- a/src/Persistence/EntityFramework/Model/ExtendedTypes.Custom.cs +++ b/src/Persistence/EntityFramework/Model/ExtendedTypes.Custom.cs @@ -255,4 +255,22 @@ internal partial class LetterHeader /// Gets or sets the receiver identifier. /// public Guid ReceiverId { get; set; } -} \ No newline at end of file +} + +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(); + } +} diff --git a/src/Persistence/EntityFramework/Model/MapsterConfigurator.Generated.cs b/src/Persistence/EntityFramework/Model/MapsterConfigurator.Generated.cs index 928d2579f8..008f473183 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,11 @@ 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()); + Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); diff --git a/src/Persistence/IBackupService.cs b/src/Persistence/IBackupService.cs new file mode 100644 index 0000000000..dbf070aa5e --- /dev/null +++ b/src/Persistence/IBackupService.cs @@ -0,0 +1,41 @@ +// +// 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); + + /// + /// 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/InMemory/InMemoryBackupService.cs b/src/Persistence/InMemory/InMemoryBackupService.cs new file mode 100644 index 0000000000..6703b68339 --- /dev/null +++ b/src/Persistence/InMemory/InMemoryBackupService.cs @@ -0,0 +1,30 @@ +// +// 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; + +/// +/// 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. + public InMemoryBackupService(IPersistenceContextProvider contextProvider) + : base(contextProvider) + { + } + + /// + 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..a6e023731b 100644 --- a/src/Persistence/Json/ReferenceResolvingConverter.cs +++ b/src/Persistence/Json/ReferenceResolvingConverter.cs @@ -163,7 +163,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 +174,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"); - } + reader.Skip(); + continue; + } - if (reader.TokenType == JsonTokenType.EndArray) - { - break; - } + var isValues = reader.ValueTextEquals("$values"u8); + if (!reader.Read()) + { + throw new JsonException("Bad JSON"); + } - if (JsonSerializer.Deserialize(ref reader, handler.PropertyType, options) is { } collectionItem) - { - handler.Adder!(item, collectionItem); - } - } + if (isValues && reader.TokenType == JsonTokenType.StartArray) + { + ReadCollection(ref reader, options, item, handler); } else { @@ -206,6 +220,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/Persistence/SourceGenerator/EfCoreModelGenerator.cs b/src/Persistence/SourceGenerator/EfCoreModelGenerator.cs index 9f975b0d70..ceaeab1a43 100644 --- a/src/Persistence/SourceGenerator/EfCoreModelGenerator.cs +++ b/src/Persistence/SourceGenerator/EfCoreModelGenerator.cs @@ -216,6 +216,7 @@ private string GenerateMapsterConfigurator() namespace MUnique.OpenMU.Persistence.EntityFramework.Model; +using MUnique.OpenMU.DataModel.Composition; using MUnique.OpenMU.Persistence; using Mapster; @@ -239,6 +240,11 @@ 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()); + {configs} isConfigured = true; }} diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs index 351eae8e14..8157e89889 100644 --- a/src/Startup/Program.cs +++ b/src/Startup/Program.cs @@ -274,6 +274,16 @@ private async Task CreateHostAsync(string[] args) .WaitAndUnwrapException()) .AddSingleton(s => s.GetService()!) .AddSingleton>(s => new(() => s.GetService()!)) + .AddSingleton(s => + { + var contextProvider = s.GetRequiredService(); + if (contextProvider is PersistenceContextProvider) + { + return new BackupService(s.GetRequiredService()); + } + + return new InMemoryBackupService(s.GetRequiredService()); + }) .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/Web/AdminPanel/API/BackupController.cs b/src/Web/AdminPanel/API/BackupController.cs new file mode 100644 index 0000000000..a01d1b3c09 --- /dev/null +++ b/src/Web/AdminPanel/API/BackupController.cs @@ -0,0 +1,45 @@ +// +// 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.Mvc; +using MUnique.OpenMU.Persistence; + +/// +/// API controller to download a backup archive. +/// 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")] +public class BackupController : Controller +{ + private readonly IBackupService _backupService; + + /// + /// Initializes a new instance of the class. + /// + /// The backup service. + public BackupController(IBackupService backupService) + { + this._backupService = backupService; + } + + /// + /// Downloads a backup archive containing all configuration and account data. + /// + /// The cancellation token. + /// The backup zip archive as a file download. + [HttpGet] + public async Task DownloadBackupAsync(CancellationToken cancellationToken) + { + var stream = new MemoryStream(); + await this._backupService.CreateBackupAsync(stream, cancellationToken).ConfigureAwait(false); + stream.Position = 0; + var fileName = $"backup_{DateTime.UtcNow:yyyyMMdd_HHmmss}.zip"; + return this.File(stream, "application/zip", fileName); + } +} diff --git a/src/Web/AdminPanel/Pages/Setup.razor b/src/Web/AdminPanel/Pages/Setup.razor index 558b508529..8aab56b46f 100644 --- a/src/Web/AdminPanel/Pages/Setup.razor +++ b/src/Web/AdminPanel/Pages/Setup.razor @@ -38,4 +38,25 @@ else } +
+
@Resources.ExportBackup
+ @Resources.ExportBackup +
+
@Resources.ImportBackup
+ @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..d34df6b184 100644 --- a/src/Web/AdminPanel/Pages/Setup.razor.cs +++ b/src/Web/AdminPanel/Pages/Setup.razor.cs @@ -4,10 +4,14 @@ 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; @@ -21,6 +25,12 @@ public partial class Setup private ClientVersion? _gameClientVersion; + private bool _isImporting; + + private string? _importMessage; + + private string _importMessageCssClass = string.Empty; + /// /// Gets or sets a value indicating whether to show the component. /// @@ -32,6 +42,12 @@ 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 javascript runtime. /// @@ -65,4 +81,44 @@ private async Task OnReInstallClickAsync() this.ShowInstall = true; } } -} \ No newline at end of file + + private async Task OnImportFileChangeAsync(InputFileChangeEventArgs e) + { + var file = e.File; + this._importMessage = null; + this._isImporting = true; + await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); + + try + { + // BrowserFileStream doesn't support synchronous reads (which ZipArchive requires), + // so copy it into a MemoryStream first. Pre-size with file.Size to avoid reallocations. + using 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; + + 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/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index 1b469d4b16..b5bf05a059 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -1736,5 +1736,68 @@ public static string YesCreateTestAccounts { return ResourceManager.GetString("YesCreateTestAccounts", resourceCulture); } } + + /// + /// Looks up a localized string similar to Export Backup. + /// + internal static string ExportBackup { + get { + return ResourceManager.GetString("ExportBackup", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Import Backup. + /// + internal static string ImportBackup { + get { + return ResourceManager.GetString("ImportBackup", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Importing backup, please wait.... + /// + internal static string ImportingBackupPleaseWait { + get { + return ResourceManager.GetString("ImportingBackupPleaseWait", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Backup import succeeded. + /// + internal static string BackupImportSucceeded { + get { + return ResourceManager.GetString("BackupImportSucceeded", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Backup import failed. + /// + internal static string BackupImportFailed { + get { + return ResourceManager.GetString("BackupImportFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select a .zip backup file to restore. + /// + internal static string SelectZipFileToRestore { + get { + return ResourceManager.GetString("SelectZipFileToRestore", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The selected file is no backup archive. The database was left untouched.. + /// + internal static string SelectedFileIsNoBackup { + get { + return ResourceManager.GetString("SelectedFileIsNoBackup", resourceCulture); + } + } } } diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx index b1af2d2fbc..1187dbbc41 100644 --- a/src/Web/AdminPanel/Properties/Resources.resx +++ b/src/Web/AdminPanel/Properties/Resources.resx @@ -1,4 +1,4 @@ - +