diff --git a/src/Dapr/Common/Extensions.cs b/src/Dapr/Common/Extensions.cs index 56ce959d4..5afbcbd38 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 000000000..bc895de1f --- /dev/null +++ b/src/Persistence/BackupService.cs @@ -0,0 +1,423 @@ +// +// 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.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 +{ + private static readonly (string Prefix, Type DataModelType, Type BasicModelType)[] EntryTypeInfos = + [ + ("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; + + /// + /// 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 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 DataModelType, Type BasicModelType)? GetTypeInfoForEntry(string entryName) + { + foreach (var typeInfo in EntryTypeInfos) + { + if (entryName.StartsWith(typeInfo.Prefix, StringComparison.Ordinal)) + { + return typeInfo; + } + } + + return 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.CopyBaseTypeProperties(basicModelObj, newObj, dataModelBaseType, context, createdObjects); + this.CopyRawCollectionProperties(basicModelObj, newObj, context, createdObjects); + + return newObj; + } + + 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 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 child = this.GetOrCreateObject(context, value, createdObjects); + try + { + targetProp.SetValue(target, child); + } + catch + { + // Ignore type incompatibilities. + } + } + else + { + try + { + targetProp.SetValue(target, value); + } + catch + { + // Ignore. + } + } + } + + // 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.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 createdItem = this.GetOrCreateObject(context, item, createdObjects); + addMethod.Invoke(targetCollection, new[] { createdItem }); + } + else + { + addMethod.Invoke(targetCollection, new[] { 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/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/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..6703b6833 --- /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 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 160b1ab46..786568a68 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,19 @@ 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 = { + new LocalizedStringJsonConverter(), new OnlyWriteBelowRootConverter(), new OnlyWriteBelowRootConverter(), new OnlyWriteBelowRootConverter(), 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; diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs index 84a8b64e8..d9628ad8a 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 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 000000000..85808d160 --- /dev/null +++ b/src/Web/AdminPanel/API/BackupController.cs @@ -0,0 +1,69 @@ +// +// 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.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..6c7ca7cb8 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 4a2001622..30e8387bd 100644 --- a/src/Web/AdminPanel/Pages/Setup.razor.cs +++ b/src/Web/AdminPanel/Pages/Setup.razor.cs @@ -5,9 +5,12 @@ 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; using MUnique.OpenMU.Web.AdminPanel.Components; using MUnique.OpenMU.Web.AdminPanel.Properties; using MUnique.OpenMU.Web.AdminPanel.Services; @@ -21,6 +24,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 +41,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 +80,37 @@ 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 + { + // 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(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); + } + } } \ No newline at end of file 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); + } + } } } 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