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 @@ - +