From d161ba220028719801f1f1becad07066e2016a5b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:33:31 +0000 Subject: [PATCH 1/7] Initial plan From 38fe8f68da254817d22cd9c1f7a958b198963bcc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:27:36 +0000 Subject: [PATCH 2/7] Add Export/Import backup functionality to the admin panel Setup page Co-authored-by: sven-n <5238610+sven-n@users.noreply.github.com> Agent-Logs-Url: https://github.com/MUnique/OpenMU/sessions/fa43bd29-5da9-403d-81e2-28502a6c8273 --- src/Dapr/Common/Extensions.cs | 3 +- .../EntityFramework/EfBackupService.cs | 481 ++++++++++++++++++ .../Model/ExtendedTypes.Custom.cs | 18 + src/Persistence/IBackupService.cs | 33 ++ .../InMemory/InMemoryBackupService.cs | 25 + src/Persistence/Json/JsonObjectSerializer.cs | 21 +- src/Startup/Program.cs | 10 + src/Web/AdminPanel/API/BackupController.cs | 67 +++ src/Web/AdminPanel/Pages/Setup.razor | 18 + src/Web/AdminPanel/Pages/Setup.razor.cs | 41 ++ src/Web/AdminPanel/Properties/Resources.resx | 18 + 11 files changed, 733 insertions(+), 2 deletions(-) create mode 100644 src/Persistence/EntityFramework/EfBackupService.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 diff --git a/src/Dapr/Common/Extensions.cs b/src/Dapr/Common/Extensions.cs index 56ce959d4..93be2df53 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 EfBackupService(s.GetRequiredService())); } /// diff --git a/src/Persistence/EntityFramework/EfBackupService.cs b/src/Persistence/EntityFramework/EfBackupService.cs new file mode 100644 index 000000000..77ef227de --- /dev/null +++ b/src/Persistence/EntityFramework/EfBackupService.cs @@ -0,0 +1,481 @@ +// +// 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.Reflection; +using System.Threading; +using Microsoft.EntityFrameworkCore; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Persistence.EntityFramework.Json; +using MUnique.OpenMU.Persistence.EntityFramework.Model; +using MUnique.OpenMU.Persistence.Json; + +/// +/// Implementation of for the EntityFramework persistence layer. +/// +public class EfBackupService : IBackupService +{ + 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 EfBackupService(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(); + + await using var dbContext = new EntityDataContext(); + await dbContext.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + try + { + await ExportGameConfigurationsAsync(archive, dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportChatServerDefinitionsAsync(archive, dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportConnectServerDefinitionsAsync(archive, dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportGameServerDefinitionsAsync(archive, dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportAccountsAsync(archive, dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); + } + finally + { + await dbContext.Database.CloseConnectionAsync().ConfigureAwait(false); + } + } + + /// + public 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 it) + 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 stream = entry.Open(); + var basicModelObj = await DeserializeAsync(stream, typeInfo.Value.BasicModelType, sharedHandler, cancellationToken).ConfigureAwait(false); + if (basicModelObj is null) + { + continue; + } + + this.GetOrCreateEfObject(context, basicModelObj, createdObjects); + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + + private static async Task DeserializeAsync(Stream stream, Type basicModelType, IdReferenceHandler referenceHandler, CancellationToken cancellationToken) + { + // Read stream to memory first (ZipArchive streams don't support seeking) + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); + ms.Position = 0; + + var deserializer = new Persistence.Json.JsonObjectDeserializer(); + + // Invoke the generic Deserialize method via reflection + var method = typeof(Persistence.Json.JsonObjectDeserializer) + .GetMethod(nameof(Persistence.Json.JsonObjectDeserializer.Deserialize), BindingFlags.Public | BindingFlags.Instance)! + .MakeGenericMethod(basicModelType); + + return method.Invoke(deserializer, [ms, referenceHandler]); + } + + 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 async ValueTask WriteJsonEntryAsync( + ZipArchive archive, + string entryName, + T obj, + IdReferenceHandler referenceHandler, + CancellationToken cancellationToken) + where T : class + { + var entry = archive.CreateEntry(entryName); + await using var stream = entry.Open(); + var serializer = new JsonObjectSerializer(); + await serializer.SerializeAsync(obj, stream, referenceHandler, cancellationToken).ConfigureAwait(false); + } + + private static async Task ExportGameConfigurationsAsync( + ZipArchive archive, + EntityDataContext dbContext, + IdReferenceHandler sharedHandler, + CancellationToken cancellationToken) + { + var loader = new GameConfigurationJsonObjectLoader(); + var gameConfigs = await loader.LoadAllObjectsAsync(dbContext, cancellationToken).ConfigureAwait(false); + MapsterConfigurator.EnsureConfigured(); + foreach (var config in gameConfigs) + { + cancellationToken.ThrowIfCancellationRequested(); + var basicModel = config.Convert(); + await WriteJsonEntryAsync(archive, $"GameConfiguration_{config.Id}.json", basicModel, sharedHandler, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task ExportChatServerDefinitionsAsync( + ZipArchive archive, + EntityDataContext dbContext, + IdReferenceHandler sharedHandler, + CancellationToken cancellationToken) + { + var chatServers = await dbContext.Set() + .Include(c => c.RawEndpoints) + .ThenInclude(e => e.RawClient) + .ToListAsync(cancellationToken).ConfigureAwait(false); + MapsterConfigurator.EnsureConfigured(); + foreach (var server in chatServers) + { + cancellationToken.ThrowIfCancellationRequested(); + var basicModel = server.Convert(); + await WriteJsonEntryAsync(archive, $"ChatServerDefinition_{server.Id}.json", basicModel, sharedHandler, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task ExportConnectServerDefinitionsAsync( + ZipArchive archive, + EntityDataContext dbContext, + IdReferenceHandler sharedHandler, + CancellationToken cancellationToken) + { + var connectServers = await dbContext.Set() + .Include(c => c.RawClient) + .ToListAsync(cancellationToken).ConfigureAwait(false); + MapsterConfigurator.EnsureConfigured(); + foreach (var server in connectServers) + { + cancellationToken.ThrowIfCancellationRequested(); + var basicModel = server.Convert(); + await WriteJsonEntryAsync(archive, $"ConnectServerDefinition_{server.Id}.json", basicModel, sharedHandler, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task ExportGameServerDefinitionsAsync( + ZipArchive archive, + EntityDataContext dbContext, + IdReferenceHandler sharedHandler, + CancellationToken cancellationToken) + { + var gameServers = await dbContext.Set() + .Include(g => g.RawEndpoints) + .ThenInclude(e => e.RawClient) + .Include(g => g.RawServerConfiguration) + .Include(g => g.RawGameConfiguration) + .ToListAsync(cancellationToken).ConfigureAwait(false); + MapsterConfigurator.EnsureConfigured(); + foreach (var server in gameServers) + { + cancellationToken.ThrowIfCancellationRequested(); + var basicModel = server.Convert(); + await WriteJsonEntryAsync(archive, $"GameServerDefinition_{server.Id}.json", basicModel, sharedHandler, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task ExportAccountsAsync( + ZipArchive archive, + EntityDataContext dbContext, + IdReferenceHandler sharedHandler, + CancellationToken cancellationToken) + { + var loader = new AccountJsonObjectLoader(); + var accounts = await loader.LoadAllObjectsAsync(dbContext, cancellationToken).ConfigureAwait(false); + MapsterConfigurator.EnsureConfigured(); + foreach (var account in accounts) + { + cancellationToken.ThrowIfCancellationRequested(); + var basicModel = account.Convert(); + await WriteJsonEntryAsync(archive, $"Account_{account.Id}.json", basicModel, sharedHandler, cancellationToken).ConfigureAwait(false); + } + } + + private object GetOrCreateEfObject(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 efObj = context.CreateNew(dataModelBaseType); + + if (basicModelObj is IIdentifiable id2) + { + createdObjects[id2.Id] = efObj; + SetId(efObj, id2.Id); + } + + this.CopyBaseTypeProperties(basicModelObj, efObj, dataModelBaseType, context, createdObjects); + this.CopyRawCollectionProperties(basicModelObj, efObj, context, createdObjects); + + return efObj; + } + + 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 efObj, Guid id) + { + var idProp = efObj.GetType().GetProperty("Id", BindingFlags.Public | BindingFlags.Instance); + idProp?.SetValue(efObj, id); + } + + private void CopyBaseTypeProperties( + 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 || IsCollectionType(prop.PropertyType)) + { + continue; + } + + object? value; + try + { + value = prop.GetValue(source); + } + catch + { + continue; + } + + if (value is null) + { + continue; + } + + var targetProp = FindWritableProperty(target.GetType(), prop.Name); + if (targetProp is null) + { + continue; + } + + if (value is IIdentifiable) + { + var efChild = this.GetOrCreateEfObject(context, value, createdObjects); + try + { + targetProp.SetValue(target, efChild); + } + catch + { + // ignore type incompatibility + } + } + else + { + try + { + targetProp.SetValue(target, value); + } + catch + { + // ignore + } + } + } + + // Recurse into MUnique parent base types + if (baseType.BaseType is { } parentBase + && parentBase != typeof(object) + && parentBase.Namespace?.StartsWith("MUnique", StringComparison.Ordinal) is true) + { + this.CopyBaseTypeProperties(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); + + foreach (var rawProp in rawCollectionProps) + { + object? sourceCollection; + try + { + sourceCollection = rawProp.GetValue(source); + } + catch + { + continue; + } + + if (sourceCollection is not System.Collections.IEnumerable sourceEnumerable) + { + continue; + } + + var targetProp = targetType.GetProperty( + rawProp.Name, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy); + if (targetProp is null) + { + continue; + } + + object? targetCollection; + try + { + targetCollection = targetProp.GetValue(target); + } + catch + { + continue; + } + + if (targetCollection is null) + { + continue; + } + + var addMethod = targetCollection.GetType().GetMethod("Add"); + if (addMethod is null) + { + continue; + } + + foreach (var item in sourceEnumerable) + { + if (item is null) + { + continue; + } + + try + { + if (item is IIdentifiable) + { + var efItem = this.GetOrCreateEfObject(context, item, createdObjects); + addMethod.Invoke(targetCollection, [efItem]); + } + else + { + addMethod.Invoke(targetCollection, [item]); + } + } + catch + { + // ignore individual item errors + } + } + } + } + + 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<>)); + } + + 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; + } +} diff --git a/src/Persistence/EntityFramework/Model/ExtendedTypes.Custom.cs b/src/Persistence/EntityFramework/Model/ExtendedTypes.Custom.cs index 624270cb2..6f7273ebf 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; } +} + +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(); + } } \ No newline at end of file diff --git a/src/Persistence/IBackupService.cs b/src/Persistence/IBackupService.cs new file mode 100644 index 000000000..d61c5cff9 --- /dev/null +++ b/src/Persistence/IBackupService.cs @@ -0,0 +1,33 @@ +// +// 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); + + /// + /// 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 000000000..ebc49d806 --- /dev/null +++ b/src/Persistence/InMemory/InMemoryBackupService.cs @@ -0,0 +1,25 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.InMemory; + +/// +/// A stub implementation of for the in-memory persistence layer. +/// Backup creation is not meaningful for an in-memory store; restore is not supported. +/// +public class InMemoryBackupService : IBackupService +{ + /// + public Task CreateBackupAsync(Stream outputStream, CancellationToken cancellationToken = default) + { + // In-memory persistence has no persistent data to back up. + return Task.CompletedTask; + } + + /// + public 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/JsonObjectSerializer.cs b/src/Persistence/Json/JsonObjectSerializer.cs index 160b1ab46..a76f7e6cf 100644 --- a/src/Persistence/Json/JsonObjectSerializer.cs +++ b/src/Persistence/Json/JsonObjectSerializer.cs @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.Persistence.Json; using System.IO; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; /// @@ -13,6 +14,19 @@ namespace MUnique.OpenMU.Persistence.Json; /// 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,10 +35,15 @@ 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); + } + + private async ValueTask SerializeInternalAsync(T obj, Stream stream, ReferenceHandler referenceHandler, CancellationToken cancellationToken) { var options = new JsonSerializerOptions { - ReferenceHandler = new IdReferenceHandler(), + ReferenceHandler = referenceHandler, WriteIndented = true, Converters = { diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs index 84a8b64e8..dab6fecf9 100644 --- a/src/Startup/Program.cs +++ b/src/Startup/Program.cs @@ -264,6 +264,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 EfBackupService(s.GetRequiredService()); + } + + return new MUnique.OpenMU.Persistence.InMemory.InMemoryBackupService(); + }) .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/Web/AdminPanel/API/BackupController.cs b/src/Web/AdminPanel/API/BackupController.cs new file mode 100644 index 000000000..490d6e421 --- /dev/null +++ b/src/Web/AdminPanel/API/BackupController.cs @@ -0,0 +1,67 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.API; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using MUnique.OpenMU.Persistence; + +/// +/// API controller to download and upload backup archives. +/// +[Route("admin/backup")] +public class BackupController : Controller +{ + private readonly IBackupService _backupService; + private readonly IMigratableDatabaseContextProvider _migratableDatabaseContextProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The backup service. + /// The migratable database context provider. + public BackupController(IBackupService backupService, IMigratableDatabaseContextProvider migratableDatabaseContextProvider) + { + this._backupService = backupService; + this._migratableDatabaseContextProvider = migratableDatabaseContextProvider; + } + + /// + /// 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); + } + + /// + /// Restores the database from an uploaded backup archive. + /// + /// The backup zip archive to restore from. + /// The cancellation token. + /// Ok on success. + [HttpPost] + [RequestFormLimits(MultipartBodyLengthLimit = long.MaxValue)] + [RequestSizeLimit(long.MaxValue)] + public async Task UploadBackupAsync(IFormFile? file, CancellationToken cancellationToken) + { + if (file is null || file.Length == 0) + { + return this.BadRequest("No backup file provided."); + } + + using var update = await this._migratableDatabaseContextProvider.ReCreateDatabaseAsync().ConfigureAwait(false); + await using var stream = file.OpenReadStream(); + await this._backupService.RestoreBackupAsync(stream, cancellationToken).ConfigureAwait(false); + return this.Ok(); + } +} diff --git a/src/Web/AdminPanel/Pages/Setup.razor b/src/Web/AdminPanel/Pages/Setup.razor index 14f5e670f..9eb9138d2 100644 --- a/src/Web/AdminPanel/Pages/Setup.razor +++ b/src/Web/AdminPanel/Pages/Setup.razor @@ -38,4 +38,22 @@ 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 4a2001622..52026aff1 100644 --- a/src/Web/AdminPanel/Pages/Setup.razor.cs +++ b/src/Web/AdminPanel/Pages/Setup.razor.cs @@ -5,9 +5,11 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; 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 +23,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 +40,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 +79,31 @@ private async Task OnReInstallClickAsync() this.ShowInstall = true; } } + + private async Task OnImportFileChangeAsync(InputFileChangeEventArgs e) + { + var file = e.File; + this._importMessage = null; + this._isImporting = true; + await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); + + try + { + await using var stream = file.OpenReadStream(maxAllowedSize: long.MaxValue); + await this.SetupService.CreateDatabaseAsync( + () => this.BackupService.RestoreBackupAsync(stream)).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); + } + } } \ No newline at end of file diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx index 0571a7c82..3798a34c1 100644 --- a/src/Web/AdminPanel/Properties/Resources.resx +++ b/src/Web/AdminPanel/Properties/Resources.resx @@ -540,4 +540,22 @@ Game server count + + Export Backup + + + Import 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 + \ No newline at end of file From 145e33ce8428a6fb55dab491807882c744bc8e7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 15:44:09 +0000 Subject: [PATCH 3/7] Fix build errors: rename EfBackupService to BackupService, use JsonObjectLoader for all types, fix ambiguous type references Co-authored-by: sven-n <5238610+sven-n@users.noreply.github.com> Agent-Logs-Url: https://github.com/MUnique/OpenMU/sessions/f54d8be1-3e2f-429c-8ae6-779e5215fe8e --- src/Dapr/Common/Extensions.cs | 2 +- .../{EfBackupService.cs => BackupService.cs} | 153 +++++++----------- .../InMemory/InMemoryBackupService.cs | 3 + src/Startup/Program.cs | 2 +- src/Web/AdminPanel/API/BackupController.cs | 2 + src/Web/AdminPanel/Pages/Setup.razor | 2 +- .../Properties/Resources.Designer.cs | 54 +++++++ 7 files changed, 122 insertions(+), 96 deletions(-) rename src/Persistence/EntityFramework/{EfBackupService.cs => BackupService.cs} (71%) diff --git a/src/Dapr/Common/Extensions.cs b/src/Dapr/Common/Extensions.cs index 93be2df53..5afbcbd38 100644 --- a/src/Dapr/Common/Extensions.cs +++ b/src/Dapr/Common/Extensions.cs @@ -54,7 +54,7 @@ public static IServiceCollection AddPeristenceProvider(this IServiceCollection s .AddSingleton(s => (PersistenceContextProvider)s.GetService()!) .AddSingleton(s => (IPersistenceContextProvider)s.GetService()!) .AddSingleton(s => new Lazy(s.GetRequiredService)) - .AddSingleton(s => new EfBackupService(s.GetRequiredService())); + .AddSingleton(s => new BackupService(s.GetRequiredService())); } /// diff --git a/src/Persistence/EntityFramework/EfBackupService.cs b/src/Persistence/EntityFramework/BackupService.cs similarity index 71% rename from src/Persistence/EntityFramework/EfBackupService.cs rename to src/Persistence/EntityFramework/BackupService.cs index 77ef227de..2ad2bb114 100644 --- a/src/Persistence/EntityFramework/EfBackupService.cs +++ b/src/Persistence/EntityFramework/BackupService.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -9,8 +9,6 @@ namespace MUnique.OpenMU.Persistence.EntityFramework; using System.Reflection; using System.Threading; using Microsoft.EntityFrameworkCore; -using MUnique.OpenMU.DataModel.Configuration; -using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.Persistence.EntityFramework.Json; using MUnique.OpenMU.Persistence.EntityFramework.Model; using MUnique.OpenMU.Persistence.Json; @@ -18,7 +16,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework; /// /// Implementation of for the EntityFramework persistence layer. /// -public class EfBackupService : IBackupService +public class BackupService : IBackupService { private static readonly (string Prefix, Type BasicModelType)[] EntryTypeInfos = [ @@ -32,10 +30,10 @@ private static readonly (string Prefix, Type BasicModelType)[] EntryTypeInfos = private readonly IPersistenceContextProvider _contextProvider; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The persistence context provider. - public EfBackupService(IPersistenceContextProvider contextProvider) + public BackupService(IPersistenceContextProvider contextProvider) { this._contextProvider = contextProvider; } @@ -45,7 +43,7 @@ public async Task CreateBackupAsync(Stream outputStream, CancellationToken cance { using var archive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true); - // A single shared reference handler ensures cross-type references are written as $ref + // A single shared reference handler ensures cross-type references are written as $ref. var sharedHandler = new IdReferenceHandler(); await using var dbContext = new EntityDataContext(); @@ -53,10 +51,10 @@ public async Task CreateBackupAsync(Stream outputStream, CancellationToken cance try { await ExportGameConfigurationsAsync(archive, dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); - await ExportChatServerDefinitionsAsync(archive, dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); - await ExportConnectServerDefinitionsAsync(archive, dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); - await ExportGameServerDefinitionsAsync(archive, dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); - await ExportAccountsAsync(archive, dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportByLoaderAsync(archive, "ChatServerDefinition_", dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportByLoaderAsync(archive, "ConnectServerDefinition_", dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportByLoaderAsync(archive, "GameServerDefinition_", dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); + await ExportByLoaderAsync(archive, "Account_", dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); } finally { @@ -69,11 +67,11 @@ public async Task RestoreBackupAsync(Stream inputStream, CancellationToken cance { using var archive = new ZipArchive(inputStream, ZipArchiveMode.Read, leaveOpen: true); - // A single shared handler accumulates deserialized objects so cross-file $ref references resolve correctly + // 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 it) + // 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) @@ -91,8 +89,8 @@ public async Task RestoreBackupAsync(Stream inputStream, CancellationToken cance continue; } - await using var stream = entry.Open(); - var basicModelObj = await DeserializeAsync(stream, typeInfo.Value.BasicModelType, sharedHandler, cancellationToken).ConfigureAwait(false); + await using var entryStream = entry.Open(); + var basicModelObj = await DeserializeAsync(entryStream, typeInfo.Value.BasicModelType, sharedHandler, cancellationToken).ConfigureAwait(false); if (basicModelObj is null) { continue; @@ -105,21 +103,45 @@ public async Task RestoreBackupAsync(Stream inputStream, CancellationToken cance } } - private static async Task DeserializeAsync(Stream stream, Type basicModelType, IdReferenceHandler referenceHandler, CancellationToken cancellationToken) + private static async Task DeserializeAsync( + Stream stream, + Type basicModelType, + IdReferenceHandler referenceHandler, + CancellationToken cancellationToken) { - // Read stream to memory first (ZipArchive streams don't support seeking) + // 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 Persistence.Json.JsonObjectDeserializer(); - // Invoke the generic Deserialize method via reflection - var method = typeof(Persistence.Json.JsonObjectDeserializer) - .GetMethod(nameof(Persistence.Json.JsonObjectDeserializer.Deserialize), BindingFlags.Public | BindingFlags.Instance)! - .MakeGenericMethod(basicModelType); + 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); + } - return method.Invoke(deserializer, [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) @@ -168,6 +190,7 @@ private static async Task ExportGameConfigurationsAsync( IdReferenceHandler sharedHandler, CancellationToken cancellationToken) { + // Use the specialised query builder so that maps (which depend on all other data) come last. var loader = new GameConfigurationJsonObjectLoader(); var gameConfigs = await loader.LoadAllObjectsAsync(dbContext, cancellationToken).ConfigureAwait(false); MapsterConfigurator.EnsureConfigured(); @@ -179,78 +202,23 @@ private static async Task ExportGameConfigurationsAsync( } } - private static async Task ExportChatServerDefinitionsAsync( - ZipArchive archive, - EntityDataContext dbContext, - IdReferenceHandler sharedHandler, - CancellationToken cancellationToken) - { - var chatServers = await dbContext.Set() - .Include(c => c.RawEndpoints) - .ThenInclude(e => e.RawClient) - .ToListAsync(cancellationToken).ConfigureAwait(false); - MapsterConfigurator.EnsureConfigured(); - foreach (var server in chatServers) - { - cancellationToken.ThrowIfCancellationRequested(); - var basicModel = server.Convert(); - await WriteJsonEntryAsync(archive, $"ChatServerDefinition_{server.Id}.json", basicModel, sharedHandler, cancellationToken).ConfigureAwait(false); - } - } - - private static async Task ExportConnectServerDefinitionsAsync( - ZipArchive archive, - EntityDataContext dbContext, - IdReferenceHandler sharedHandler, - CancellationToken cancellationToken) - { - var connectServers = await dbContext.Set() - .Include(c => c.RawClient) - .ToListAsync(cancellationToken).ConfigureAwait(false); - MapsterConfigurator.EnsureConfigured(); - foreach (var server in connectServers) - { - cancellationToken.ThrowIfCancellationRequested(); - var basicModel = server.Convert(); - await WriteJsonEntryAsync(archive, $"ConnectServerDefinition_{server.Id}.json", basicModel, sharedHandler, cancellationToken).ConfigureAwait(false); - } - } - - private static async Task ExportGameServerDefinitionsAsync( + private static async Task ExportByLoaderAsync( ZipArchive archive, + string filePrefix, EntityDataContext dbContext, IdReferenceHandler sharedHandler, CancellationToken cancellationToken) + where TEfModel : class, IIdentifiable, IConvertibleTo + where TBasicModel : class { - var gameServers = await dbContext.Set() - .Include(g => g.RawEndpoints) - .ThenInclude(e => e.RawClient) - .Include(g => g.RawServerConfiguration) - .Include(g => g.RawGameConfiguration) - .ToListAsync(cancellationToken).ConfigureAwait(false); + var loader = new JsonObjectLoader(new JsonQueryBuilder(), new Json.JsonObjectDeserializer(), new CachingReferenceHandler()); + var items = await loader.LoadAllObjectsAsync(dbContext, cancellationToken).ConfigureAwait(false); MapsterConfigurator.EnsureConfigured(); - foreach (var server in gameServers) + foreach (var item in items) { cancellationToken.ThrowIfCancellationRequested(); - var basicModel = server.Convert(); - await WriteJsonEntryAsync(archive, $"GameServerDefinition_{server.Id}.json", basicModel, sharedHandler, cancellationToken).ConfigureAwait(false); - } - } - - private static async Task ExportAccountsAsync( - ZipArchive archive, - EntityDataContext dbContext, - IdReferenceHandler sharedHandler, - CancellationToken cancellationToken) - { - var loader = new AccountJsonObjectLoader(); - var accounts = await loader.LoadAllObjectsAsync(dbContext, cancellationToken).ConfigureAwait(false); - MapsterConfigurator.EnsureConfigured(); - foreach (var account in accounts) - { - cancellationToken.ThrowIfCancellationRequested(); - var basicModel = account.Convert(); - await WriteJsonEntryAsync(archive, $"Account_{account.Id}.json", basicModel, sharedHandler, cancellationToken).ConfigureAwait(false); + var basicModel = item.Convert(); + await WriteJsonEntryAsync(archive, $"{filePrefix}{item.Id}.json", basicModel, sharedHandler, cancellationToken).ConfigureAwait(false); } } @@ -262,7 +230,6 @@ private object GetOrCreateEfObject(IContext context, object basicModelObj, Dicti } var dataModelBaseType = FindDataModelBaseType(basicModelObj.GetType()); - var efObj = context.CreateNew(dataModelBaseType); if (basicModelObj is IIdentifiable id2) @@ -345,7 +312,7 @@ private void CopyBaseTypeProperties( } catch { - // ignore type incompatibility + // Ignore type incompatibilities. } } else @@ -356,12 +323,12 @@ private void CopyBaseTypeProperties( } catch { - // ignore + // Ignore. } } } - // Recurse into MUnique parent base types + // 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) @@ -442,16 +409,16 @@ private void CopyRawCollectionProperties( if (item is IIdentifiable) { var efItem = this.GetOrCreateEfObject(context, item, createdObjects); - addMethod.Invoke(targetCollection, [efItem]); + addMethod.Invoke(targetCollection, new[] { efItem }); } else { - addMethod.Invoke(targetCollection, [item]); + addMethod.Invoke(targetCollection, new[] { item }); } } catch { - // ignore individual item errors + // Ignore individual item errors. } } } diff --git a/src/Persistence/InMemory/InMemoryBackupService.cs b/src/Persistence/InMemory/InMemoryBackupService.cs index ebc49d806..6f674c197 100644 --- a/src/Persistence/InMemory/InMemoryBackupService.cs +++ b/src/Persistence/InMemory/InMemoryBackupService.cs @@ -4,6 +4,9 @@ namespace MUnique.OpenMU.Persistence.InMemory; +using System.IO; +using System.Threading; + /// /// A stub implementation of for the in-memory persistence layer. /// Backup creation is not meaningful for an in-memory store; restore is not supported. diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs index dab6fecf9..b49ee3af1 100644 --- a/src/Startup/Program.cs +++ b/src/Startup/Program.cs @@ -269,7 +269,7 @@ private async Task CreateHostAsync(string[] args) var contextProvider = s.GetRequiredService(); if (contextProvider is PersistenceContextProvider) { - return new EfBackupService(s.GetRequiredService()); + return new BackupService(s.GetRequiredService()); } return new MUnique.OpenMU.Persistence.InMemory.InMemoryBackupService(); diff --git a/src/Web/AdminPanel/API/BackupController.cs b/src/Web/AdminPanel/API/BackupController.cs index 490d6e421..85808d160 100644 --- a/src/Web/AdminPanel/API/BackupController.cs +++ b/src/Web/AdminPanel/API/BackupController.cs @@ -4,6 +4,8 @@ namespace MUnique.OpenMU.Web.AdminPanel.API; +using System.IO; +using System.Threading; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using MUnique.OpenMU.Persistence; diff --git a/src/Web/AdminPanel/Pages/Setup.razor b/src/Web/AdminPanel/Pages/Setup.razor index 9eb9138d2..a30f8356e 100644 --- a/src/Web/AdminPanel/Pages/Setup.razor +++ b/src/Web/AdminPanel/Pages/Setup.razor @@ -55,5 +55,5 @@ else {

@Resources.SelectZipFileToRestore

} - + } diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index 16ac0d403..d3eeea338 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -1322,5 +1322,59 @@ internal 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); + } + } } } From 39ebd065e2d29c66bfcbc9df94f65a0629cc6137 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 16:11:40 +0000 Subject: [PATCH 4/7] Move BackupService to Persistence project, use repository pattern via IContext.GetAsync Co-authored-by: sven-n <5238610+sven-n@users.noreply.github.com> Agent-Logs-Url: https://github.com/MUnique/OpenMU/sessions/cf6f3fc0-2445-4600-a2e6-11cd40e6cd5d --- .../{EntityFramework => }/BackupService.cs | 165 ++++++++---------- .../InMemory/InMemoryBackupService.cs | 18 +- src/Startup/Program.cs | 2 +- 3 files changed, 81 insertions(+), 104 deletions(-) rename src/Persistence/{EntityFramework => }/BackupService.cs (67%) diff --git a/src/Persistence/EntityFramework/BackupService.cs b/src/Persistence/BackupService.cs similarity index 67% rename from src/Persistence/EntityFramework/BackupService.cs rename to src/Persistence/BackupService.cs index 2ad2bb114..bc895de1f 100644 --- a/src/Persistence/EntityFramework/BackupService.cs +++ b/src/Persistence/BackupService.cs @@ -2,29 +2,29 @@ // Licensed under the MIT License. See LICENSE file in the project root for full license information. // -namespace MUnique.OpenMU.Persistence.EntityFramework; +namespace MUnique.OpenMU.Persistence; using System.IO; using System.IO.Compression; using System.Reflection; using System.Threading; -using Microsoft.EntityFrameworkCore; -using MUnique.OpenMU.Persistence.EntityFramework.Json; -using MUnique.OpenMU.Persistence.EntityFramework.Model; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.Persistence.Json; /// -/// Implementation of for the EntityFramework persistence layer. +/// Implementation of which uses the available repositories +/// and does not depend on a specific persistence backend. /// public class BackupService : IBackupService { - private static readonly (string Prefix, Type BasicModelType)[] EntryTypeInfos = + private static readonly (string Prefix, Type DataModelType, Type BasicModelType)[] EntryTypeInfos = [ - ("GameConfiguration_", typeof(BasicModel.GameConfiguration)), - ("ChatServerDefinition_", typeof(BasicModel.ChatServerDefinition)), - ("ConnectServerDefinition_", typeof(BasicModel.ConnectServerDefinition)), - ("GameServerDefinition_", typeof(BasicModel.GameServerDefinition)), - ("Account_", typeof(BasicModel.Account)), + ("GameConfiguration_", typeof(GameConfiguration), typeof(BasicModel.GameConfiguration)), + ("ChatServerDefinition_", typeof(ChatServerDefinition), typeof(BasicModel.ChatServerDefinition)), + ("ConnectServerDefinition_", typeof(ConnectServerDefinition), typeof(BasicModel.ConnectServerDefinition)), + ("GameServerDefinition_", typeof(GameServerDefinition), typeof(BasicModel.GameServerDefinition)), + ("Account_", typeof(Account), typeof(BasicModel.Account)), ]; private readonly IPersistenceContextProvider _contextProvider; @@ -46,24 +46,19 @@ public async Task CreateBackupAsync(Stream outputStream, CancellationToken cance // A single shared reference handler ensures cross-type references are written as $ref. var sharedHandler = new IdReferenceHandler(); - await using var dbContext = new EntityDataContext(); - await dbContext.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); - try - { - await ExportGameConfigurationsAsync(archive, dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); - await ExportByLoaderAsync(archive, "ChatServerDefinition_", dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); - await ExportByLoaderAsync(archive, "ConnectServerDefinition_", dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); - await ExportByLoaderAsync(archive, "GameServerDefinition_", dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); - await ExportByLoaderAsync(archive, "Account_", dbContext, sharedHandler, cancellationToken).ConfigureAwait(false); - } - finally - { - await dbContext.Database.CloseConnectionAsync().ConfigureAwait(false); - } + // 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 async Task RestoreBackupAsync(Stream inputStream, CancellationToken cancellationToken = default) + public virtual async Task RestoreBackupAsync(Stream inputStream, CancellationToken cancellationToken = default) { using var archive = new ZipArchive(inputStream, ZipArchiveMode.Read, leaveOpen: true); @@ -96,13 +91,45 @@ public async Task RestoreBackupAsync(Stream inputStream, CancellationToken cance continue; } - this.GetOrCreateEfObject(context, basicModelObj, createdObjects); + 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, @@ -114,7 +141,7 @@ public async Task RestoreBackupAsync(Stream inputStream, CancellationToken cance await stream.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); ms.Position = 0; - var deserializer = new Persistence.Json.JsonObjectDeserializer(); + var deserializer = new JsonObjectDeserializer(); if (basicModelType == typeof(BasicModel.GameConfiguration)) { @@ -157,7 +184,7 @@ private static int GetTypeOrder(string entryName) return EntryTypeInfos.Length; } - private static (string Prefix, Type BasicModelType)? GetTypeInfoForEntry(string entryName) + private static (string Prefix, Type DataModelType, Type BasicModelType)? GetTypeInfoForEntry(string entryName) { foreach (var typeInfo in EntryTypeInfos) { @@ -170,59 +197,7 @@ private static (string Prefix, Type BasicModelType)? GetTypeInfoForEntry(string return null; } - private static async ValueTask WriteJsonEntryAsync( - ZipArchive archive, - string entryName, - T obj, - IdReferenceHandler referenceHandler, - CancellationToken cancellationToken) - where T : class - { - var entry = archive.CreateEntry(entryName); - await using var stream = entry.Open(); - var serializer = new JsonObjectSerializer(); - await serializer.SerializeAsync(obj, stream, referenceHandler, cancellationToken).ConfigureAwait(false); - } - - private static async Task ExportGameConfigurationsAsync( - ZipArchive archive, - EntityDataContext dbContext, - IdReferenceHandler sharedHandler, - CancellationToken cancellationToken) - { - // Use the specialised query builder so that maps (which depend on all other data) come last. - var loader = new GameConfigurationJsonObjectLoader(); - var gameConfigs = await loader.LoadAllObjectsAsync(dbContext, cancellationToken).ConfigureAwait(false); - MapsterConfigurator.EnsureConfigured(); - foreach (var config in gameConfigs) - { - cancellationToken.ThrowIfCancellationRequested(); - var basicModel = config.Convert(); - await WriteJsonEntryAsync(archive, $"GameConfiguration_{config.Id}.json", basicModel, sharedHandler, cancellationToken).ConfigureAwait(false); - } - } - - private static async Task ExportByLoaderAsync( - ZipArchive archive, - string filePrefix, - EntityDataContext dbContext, - IdReferenceHandler sharedHandler, - CancellationToken cancellationToken) - where TEfModel : class, IIdentifiable, IConvertibleTo - where TBasicModel : class - { - var loader = new JsonObjectLoader(new JsonQueryBuilder(), new Json.JsonObjectDeserializer(), new CachingReferenceHandler()); - var items = await loader.LoadAllObjectsAsync(dbContext, cancellationToken).ConfigureAwait(false); - MapsterConfigurator.EnsureConfigured(); - foreach (var item in items) - { - cancellationToken.ThrowIfCancellationRequested(); - var basicModel = item.Convert(); - await WriteJsonEntryAsync(archive, $"{filePrefix}{item.Id}.json", basicModel, sharedHandler, cancellationToken).ConfigureAwait(false); - } - } - - private object GetOrCreateEfObject(IContext context, object basicModelObj, Dictionary createdObjects) + private object GetOrCreateObject(IContext context, object basicModelObj, Dictionary createdObjects) { if (basicModelObj is IIdentifiable identifiable && createdObjects.TryGetValue(identifiable.Id, out var existing)) { @@ -230,18 +205,18 @@ private object GetOrCreateEfObject(IContext context, object basicModelObj, Dicti } var dataModelBaseType = FindDataModelBaseType(basicModelObj.GetType()); - var efObj = context.CreateNew(dataModelBaseType); + var newObj = context.CreateNew(dataModelBaseType); if (basicModelObj is IIdentifiable id2) { - createdObjects[id2.Id] = efObj; - SetId(efObj, id2.Id); + createdObjects[id2.Id] = newObj; + SetId(newObj, id2.Id); } - this.CopyBaseTypeProperties(basicModelObj, efObj, dataModelBaseType, context, createdObjects); - this.CopyRawCollectionProperties(basicModelObj, efObj, context, createdObjects); + this.CopyBaseTypeProperties(basicModelObj, newObj, dataModelBaseType, context, createdObjects); + this.CopyRawCollectionProperties(basicModelObj, newObj, context, createdObjects); - return efObj; + return newObj; } private static Type FindDataModelBaseType(Type basicModelType) @@ -261,10 +236,10 @@ private static Type FindDataModelBaseType(Type basicModelType) return basicModelType; } - private static void SetId(object efObj, Guid id) + private static void SetId(object obj, Guid id) { - var idProp = efObj.GetType().GetProperty("Id", BindingFlags.Public | BindingFlags.Instance); - idProp?.SetValue(efObj, id); + var idProp = obj.GetType().GetProperty("Id", BindingFlags.Public | BindingFlags.Instance); + idProp?.SetValue(obj, id); } private void CopyBaseTypeProperties( @@ -305,10 +280,10 @@ private void CopyBaseTypeProperties( if (value is IIdentifiable) { - var efChild = this.GetOrCreateEfObject(context, value, createdObjects); + var child = this.GetOrCreateObject(context, value, createdObjects); try { - targetProp.SetValue(target, efChild); + targetProp.SetValue(target, child); } catch { @@ -408,8 +383,8 @@ private void CopyRawCollectionProperties( { if (item is IIdentifiable) { - var efItem = this.GetOrCreateEfObject(context, item, createdObjects); - addMethod.Invoke(targetCollection, new[] { efItem }); + var createdItem = this.GetOrCreateObject(context, item, createdObjects); + addMethod.Invoke(targetCollection, new[] { createdItem }); } else { diff --git a/src/Persistence/InMemory/InMemoryBackupService.cs b/src/Persistence/InMemory/InMemoryBackupService.cs index 6f674c197..6703b6833 100644 --- a/src/Persistence/InMemory/InMemoryBackupService.cs +++ b/src/Persistence/InMemory/InMemoryBackupService.cs @@ -8,20 +8,22 @@ namespace MUnique.OpenMU.Persistence.InMemory; using System.Threading; /// -/// A stub implementation of for the in-memory persistence layer. -/// Backup creation is not meaningful for an in-memory store; restore is not supported. +/// An implementation of for the in-memory persistence layer. +/// Export is supported via the base ; restore is not supported. /// -public class InMemoryBackupService : IBackupService +public class InMemoryBackupService : BackupService { - /// - public Task CreateBackupAsync(Stream outputStream, CancellationToken cancellationToken = default) + /// + /// Initializes a new instance of the class. + /// + /// The persistence context provider. + public InMemoryBackupService(IPersistenceContextProvider contextProvider) + : base(contextProvider) { - // In-memory persistence has no persistent data to back up. - return Task.CompletedTask; } /// - public Task RestoreBackupAsync(Stream inputStream, CancellationToken cancellationToken = default) + 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/Startup/Program.cs b/src/Startup/Program.cs index b49ee3af1..d9628ad8a 100644 --- a/src/Startup/Program.cs +++ b/src/Startup/Program.cs @@ -272,7 +272,7 @@ private async Task CreateHostAsync(string[] args) return new BackupService(s.GetRequiredService()); } - return new MUnique.OpenMU.Persistence.InMemory.InMemoryBackupService(); + return new InMemoryBackupService(s.GetRequiredService()); }) .AddSingleton() .AddSingleton() From 64e78608161159c25012a34c2aea6e55a770f3ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:24:29 +0000 Subject: [PATCH 5/7] Fix export: add LocalizedStringJsonConverter to JsonObjectSerializer and JsonObjectDeserializer Co-authored-by: sven-n <5238610+sven-n@users.noreply.github.com> Agent-Logs-Url: https://github.com/MUnique/OpenMU/sessions/96be83dd-97c3-41c5-908e-be6c736acec6 --- src/Persistence/Json/JsonObjectDeserializer.cs | 7 ++++++- src/Persistence/Json/JsonObjectSerializer.cs | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Persistence/Json/JsonObjectDeserializer.cs b/src/Persistence/Json/JsonObjectDeserializer.cs index 0b470492b..0d477fc38 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 a76f7e6cf..786568a68 100644 --- a/src/Persistence/Json/JsonObjectSerializer.cs +++ b/src/Persistence/Json/JsonObjectSerializer.cs @@ -8,6 +8,7 @@ namespace MUnique.OpenMU.Persistence.Json; 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. @@ -47,6 +48,7 @@ private async ValueTask SerializeInternalAsync(T obj, Stream stream, Referenc WriteIndented = true, Converters = { + new LocalizedStringJsonConverter(), new OnlyWriteBelowRootConverter(), new OnlyWriteBelowRootConverter(), new OnlyWriteBelowRootConverter(), From cabe60cb352677d470508c5e373acef4ec24d306 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:50:23 +0000 Subject: [PATCH 6/7] Fix Mapster TypeInitializationException: ignore IElement-typed members in MapsterConfigurator Co-authored-by: sven-n <5238610+sven-n@users.noreply.github.com> Agent-Logs-Url: https://github.com/MUnique/OpenMU/sessions/f52ad4aa-9751-4aca-8632-70f3363c04c7 --- .../Model/MapsterConfigurator.Generated.cs | 10 ++++++++++ .../SourceGenerator/EfCoreModelGenerator.cs | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/Persistence/EntityFramework/Model/MapsterConfigurator.Generated.cs b/src/Persistence/EntityFramework/Model/MapsterConfigurator.Generated.cs index 240f56dba..c9ec5b8ee 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.AttributeSystem; using MUnique.OpenMU.Persistence; using Mapster; @@ -34,6 +35,15 @@ public static void EnsureConfigured() Mapster.TypeAdapterConfig.GlobalSettings.Default.PreserveReference(true); Mapster.TypeAdapterConfig.GlobalSettings.Default.IgnoreMember((member, side) => member.Name.StartsWith("Raw")); + Mapster.TypeAdapterConfig.GlobalSettings.Default.IgnoreMember( + (member, side) => + { + static bool ContainsIElement(Type t) => + typeof(IElement).IsAssignableFrom(t) + || (t.IsArray && t.GetElementType() is { } et && ContainsIElement(et)) + || (t.IsGenericType && t.GetGenericArguments().Any(ContainsIElement)); + return ContainsIElement(member.Type); + }); Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); diff --git a/src/Persistence/SourceGenerator/EfCoreModelGenerator.cs b/src/Persistence/SourceGenerator/EfCoreModelGenerator.cs index 3d046e5ce..fb8f804f2 100644 --- a/src/Persistence/SourceGenerator/EfCoreModelGenerator.cs +++ b/src/Persistence/SourceGenerator/EfCoreModelGenerator.cs @@ -193,6 +193,7 @@ private string GenerateMapsterConfigurator() namespace MUnique.OpenMU.Persistence.EntityFramework.Model; +using MUnique.OpenMU.AttributeSystem; using MUnique.OpenMU.Persistence; using Mapster; @@ -215,6 +216,15 @@ public static void EnsureConfigured() Mapster.TypeAdapterConfig.GlobalSettings.Default.PreserveReference(true); Mapster.TypeAdapterConfig.GlobalSettings.Default.IgnoreMember((member, side) => member.Name.StartsWith(""Raw"")); + Mapster.TypeAdapterConfig.GlobalSettings.Default.IgnoreMember( + (member, side) => + {{ + static bool ContainsIElement(Type t) => + typeof(IElement).IsAssignableFrom(t) + || (t.IsArray && t.GetElementType() is {{ }} et && ContainsIElement(et)) + || (t.IsGenericType && t.GetGenericArguments().Any(ContainsIElement)); + return ContainsIElement(member.Type); + }}); {configs} isConfigured = true; From 08bc360d21cb9b538b5f8890bdd730fdad546732 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:34:07 +0000 Subject: [PATCH 7/7] Fix import: copy BrowserFileStream to MemoryStream; add spinner progress indicator Co-authored-by: sven-n <5238610+sven-n@users.noreply.github.com> Agent-Logs-Url: https://github.com/MUnique/OpenMU/sessions/458f68ea-7cc1-4925-81a1-e833ed68d2b3 --- src/Web/AdminPanel/Pages/Setup.razor | 5 ++++- src/Web/AdminPanel/Pages/Setup.razor.cs | 11 +++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Web/AdminPanel/Pages/Setup.razor b/src/Web/AdminPanel/Pages/Setup.razor index a30f8356e..6c7ca7cb8 100644 --- a/src/Web/AdminPanel/Pages/Setup.razor +++ b/src/Web/AdminPanel/Pages/Setup.razor @@ -45,7 +45,10 @@ else
@Resources.ImportBackup
@if (this._isImporting) { -

@Resources.ImportingBackupPleaseWait

+
+ + @Resources.ImportingBackupPleaseWait +
} else if (this._importMessage is not null) { diff --git a/src/Web/AdminPanel/Pages/Setup.razor.cs b/src/Web/AdminPanel/Pages/Setup.razor.cs index 52026aff1..30e8387bd 100644 --- a/src/Web/AdminPanel/Pages/Setup.razor.cs +++ b/src/Web/AdminPanel/Pages/Setup.razor.cs @@ -7,6 +7,7 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; using Microsoft.JSInterop; +using System.IO; using MUnique.OpenMU.Network.PlugIns; using MUnique.OpenMU.Persistence; @@ -89,9 +90,15 @@ private async Task OnImportFileChangeAsync(InputFileChangeEventArgs e) try { - await using var stream = file.OpenReadStream(maxAllowedSize: long.MaxValue); + // 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; + await this.SetupService.CreateDatabaseAsync( - () => this.BackupService.RestoreBackupAsync(stream)).ConfigureAwait(false); + () => this.BackupService.RestoreBackupAsync(memoryStream)).ConfigureAwait(false); this._importMessage = Resources.BackupImportSucceeded; this._importMessageCssClass = "text-success"; }