From fa506459eacbe7d4a11a21cfcb5c9fa81790ba5c Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 9 Dec 2019 14:04:05 +0100 Subject: [PATCH] Refactoring/backups (#460) * More tests for backups and SRP improvements. --- .../Apps/AppCommandMiddleware.cs | 10 +- .../Apps/BackupApps.cs | 150 +++---- .../Apps/Commands/AssignContributor.cs | 2 +- .../Apps/DefaultAppImageStore.cs | 47 +++ .../Apps/Guards/GuardAppContributors.cs | 2 +- .../Apps/IAppImageStore.cs | 21 + .../Invitation/InviteUserCommandMiddleware.cs | 2 +- .../Assets/AssetCommandMiddleware.cs | 18 +- .../Assets/BackupAssets.cs | 92 +++-- .../Assets/DefaultAssetFileStore.cs | 75 ++++ .../Assets/IAssetFileStore.cs | 31 ++ .../Backup/BackupContext.cs | 25 ++ .../Backup/BackupContextBase.cs | 33 ++ .../Backup/BackupGrain.cs | 139 ++++--- .../Backup/BackupHandlerWithStore.cs | 54 --- .../Backup/BackupReader.cs | 2 +- .../Backup/BackupService.cs | 76 ++++ .../Backup/BackupWriter.cs | 2 +- .../Backup/DefaultBackupArchiveStore.cs | 54 +++ .../Backup/GuidMapper.cs | 5 + .../Backup/Helpers/Downloader.cs | 87 ----- .../Backup/Helpers/Safe.cs | 62 --- .../Backup/IBackupArchiveLocation.cs | 7 +- .../Backup/IBackupArchiveStore.cs | 23 ++ .../Backup/IBackupGrain.cs | 3 +- .../{BackupHandler.cs => IBackupHandler.cs} | 19 +- .../Backup/IBackupReader.cs | 30 ++ .../Backup/IBackupService.cs | 29 ++ .../Backup/IBackupWriter.cs | 27 ++ .../Backup/IRestoreGrain.cs | 2 +- .../Backup/IUserMapping.cs | 24 ++ .../Backup/RestoreContext.cs | 25 ++ .../Backup/RestoreGrain.cs | 144 ++++--- .../State/{BackupStateJob.cs => BackupJob.cs} | 2 +- .../Backup/State/BackupState.cs | 2 +- .../{RestoreStateJob.cs => RestoreJob.cs} | 4 +- .../{RestoreState.cs => RestoreState2.cs} | 4 +- .../Backup/TempFolderBackupArchiveLocation.cs | 87 ++++- .../Backup/UserMapping.cs | 127 ++++++ .../Contents/BackupContents.cs | 30 +- .../Contents/GraphQL/GraphQLModel.cs | 2 +- .../Contents/GraphQL/IGraphQLUrlGenerator.cs | 2 +- .../Contents/Text/AssetIndexStorage.cs | 4 +- .../Rules/BackupRules.cs | 10 +- .../Schemas/BackupSchemas.cs | 10 +- ...ureStore.cs => DefaultUserPictureStore.cs} | 24 +- .../DefaultUserResolver.cs | 2 +- .../Squidex.Domain.Users/IUserPictureStore.cs | 5 +- .../Squidex.Domain.Users/UserWithClaims.cs | 2 +- .../Assets/AssetStoreExtensions.cs | 74 ---- .../Assets/FTPAssetStore.cs | 2 + .../Commands/Rebuilder.cs | 114 ++++++ .../Email/SmtpEmailSender.cs | 2 + .../Log/Internal/AnsiLogConsole.cs | 4 +- .../Log/Internal/ConsoleLogProcessor.cs | 2 + .../Log/Internal/FileLogProcessor.cs | 4 +- .../Log/Internal/WindowsLogConsole.cs | 4 +- .../Plugins/PluginLoaders.cs | 2 + .../Translations/DeepLTranslator.cs | 2 + .../src/Squidex.Shared/Users/IUserResolver.cs | 2 +- .../src/Squidex.Web/Services/UrlGenerator.cs | 11 +- .../Api/Controllers/Apps/AppsController.cs | 12 +- .../Assets/AssetContentController.cs | 23 +- .../Backups/BackupContentController.cs | 21 +- .../Controllers/Backups/BackupsController.cs | 22 +- .../Controllers/Backups/RestoreController.cs | 20 +- .../Api/Controllers/Users/UsersController.cs | 12 +- .../Config/Authentication/IdentityServices.cs | 2 +- .../src/Squidex/Config/Domain/AppsServices.cs | 3 + .../Squidex/Config/Domain/AssetServices.cs | 3 + .../Squidex/Config/Domain/BackupsServices.cs | 16 +- .../Config/Domain/EventSourcingServices.cs | 4 + .../Config/Domain/MigrationServices.cs | 3 - .../Squidex/Config/Domain/QueryServices.cs | 4 +- backend/tests/RunCoverage.ps1 | 15 +- .../Apps/AppCommandMiddlewareTests.cs | 6 +- .../Apps/BackupAppsTests.cs | 369 ++++++++++++++++++ .../Apps/DefaultAppImageStoreTests.cs | 54 +++ .../Apps/Guards/GuardAppContributorsTests.cs | 4 +- .../InviteUserCommandMiddlewareTests.cs | 12 +- .../Assets/AssetCommandMiddlewareTests.cs | 25 +- .../Assets/BackupAssetsTests.cs | 256 ++++++++++++ .../Assets/DefaultAssetFileStoreTests.cs | 106 +++++ .../Backup/BackupReaderWriterTests.cs | 16 +- .../Backup/BackupServiceTests.cs | 142 +++++++ .../Backup/DefaultBackupArchiveStoreTests.cs | 63 +++ .../Backup/UserMappingTests.cs | 160 ++++++++ .../Contents/BackupContentsTests.cs | 105 +++++ .../Contents/TestData/FakeUrlGenerator.cs | 2 +- .../Rules/BackupRulesTests.cs | 82 ++++ .../Schemas/BackupSchemasTests.cs | 82 ++++ .../TestHelpers/HandlerTestBase.cs | 10 +- ...sts.cs => DefaultUserPictureStoreTests.cs} | 31 +- .../Assets/AssetExtensionTests.cs | 112 ------ .../Migrate_01/Migrations/RebuildApps.cs | 1 + .../Migrate_01/Migrations/RebuildAssets.cs | 1 + .../Migrate_01/Migrations/RebuildContents.cs | 1 + .../Migrate_01/Migrations/RebuildSnapshots.cs | 1 + backend/tools/Migrate_01/RebuildRunner.cs | 1 + backend/tools/Migrate_01/Rebuilder.cs | 125 ------ .../tools/Migrate_01/RebuilderExtensions.cs | 51 +++ 101 files changed, 2847 insertions(+), 993 deletions(-) create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Apps/DefaultAppImageStore.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Apps/IAppImageStore.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Assets/DefaultAssetFileStore.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetFileStore.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/BackupContext.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/BackupContextBase.cs delete mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/BackupHandlerWithStore.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/BackupService.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/DefaultBackupArchiveStore.cs delete mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs delete mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Safe.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveStore.cs rename backend/src/Squidex.Domain.Apps.Entities/Backup/{BackupHandler.cs => IBackupHandler.cs} (57%) create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupService.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupWriter.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/IUserMapping.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreContext.cs rename backend/src/Squidex.Domain.Apps.Entities/Backup/State/{BackupStateJob.cs => BackupJob.cs} (94%) rename backend/src/Squidex.Domain.Apps.Entities/Backup/State/{RestoreStateJob.cs => RestoreJob.cs} (92%) rename backend/src/Squidex.Domain.Apps.Entities/Backup/State/{RestoreState.cs => RestoreState2.cs} (86%) create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/UserMapping.cs rename backend/src/Squidex.Domain.Users/{AssetUserPictureStore.cs => DefaultUserPictureStore.cs} (51%) delete mode 100644 backend/src/Squidex.Infrastructure/Assets/AssetStoreExtensions.cs create mode 100644 backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/BackupAppsTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DefaultAppImageStoreTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/BackupAssetsTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DefaultAssetFileStoreTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupServiceTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/DefaultBackupArchiveStoreTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/UserMappingTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/BackupRulesTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/BackupSchemasTests.cs rename backend/tests/Squidex.Domain.Users.Tests/{AssetUserPictureStoreTests.cs => DefaultUserPictureStoreTests.cs} (56%) delete mode 100644 backend/tests/Squidex.Infrastructure.Tests/Assets/AssetExtensionTests.cs delete mode 100644 backend/tools/Migrate_01/Rebuilder.cs create mode 100644 backend/tools/Migrate_01/RebuilderExtensions.cs diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs index 0359af415..70cb33d59 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs @@ -18,22 +18,22 @@ namespace Squidex.Domain.Apps.Entities.Apps { public sealed class AppCommandMiddleware : GrainCommandMiddleware { - private readonly IAssetStore assetStore; + private readonly IAppImageStore appImageStore; private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IContextProvider contextProvider; public AppCommandMiddleware( IGrainFactory grainFactory, - IAssetStore assetStore, + IAppImageStore appImageStore, IAssetThumbnailGenerator assetThumbnailGenerator, IContextProvider contextProvider) : base(grainFactory) { Guard.NotNull(contextProvider); - Guard.NotNull(assetStore); + Guard.NotNull(appImageStore); Guard.NotNull(assetThumbnailGenerator); - this.assetStore = assetStore; + this.appImageStore = appImageStore; this.assetThumbnailGenerator = assetThumbnailGenerator; this.contextProvider = contextProvider; } @@ -66,7 +66,7 @@ namespace Squidex.Domain.Apps.Entities.Apps throw new ValidationException("File is not an image."); } - await assetStore.UploadAsync(uploadImage.AppId.ToString(), file.OpenRead(), true); + await appImageStore.UploadAsync(uploadImage.AppId, file.OpenRead()); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs index 4cc3c058b..a9eeee3a0 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs @@ -10,82 +10,78 @@ using System.Collections.Generic; using System.Threading.Tasks; using Squidex.Domain.Apps.Entities.Apps.Indexes; using Squidex.Domain.Apps.Entities.Backup; -using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Apps; using Squidex.Infrastructure; +using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Json.Objects; -using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Entities.Apps { - public sealed class BackupApps : BackupHandler + public sealed class BackupApps : IBackupHandler { - private const string UsersFile = "Users.json"; private const string SettingsFile = "Settings.json"; - private readonly IAppUISettings appUISettings; + private const string AvatarFile = "Avatar.image"; + private readonly IAppImageStore appImageStore; private readonly IAppsIndex appsIndex; - private readonly IUserResolver userResolver; + private readonly IAppUISettings appUISettings; private readonly HashSet contributors = new HashSet(); - private readonly Dictionary userMapping = new Dictionary(); - private Dictionary usersWithEmail = new Dictionary(); private string? appReservation; - private string appName; - public override string Name { get; } = "Apps"; + public string Name { get; } = "Apps"; - public BackupApps(IAppUISettings appUISettings, IAppsIndex appsIndex, IUserResolver userResolver) + public BackupApps(IAppImageStore appImageStore, IAppsIndex appsIndex, IAppUISettings appUISettings) { + Guard.NotNull(appImageStore); Guard.NotNull(appsIndex); Guard.NotNull(appUISettings); - Guard.NotNull(userResolver); this.appsIndex = appsIndex; + this.appImageStore = appImageStore; this.appUISettings = appUISettings; - this.userResolver = userResolver; } - public override async Task BackupEventAsync(Envelope @event, Guid appId, BackupWriter writer) + public async Task BackupEventAsync(Envelope @event, BackupContext context) { - if (@event.Payload is AppContributorAssigned appContributorAssigned) + switch (@event.Payload) { - var userId = appContributorAssigned.ContributorId; - - if (!usersWithEmail.ContainsKey(userId)) - { - var user = await userResolver.FindByIdOrEmailAsync(userId); - - if (user != null) - { - usersWithEmail.Add(userId, user.Email); - } - } + case AppContributorAssigned appContributorAssigned: + context.UserMapping.Backup(appContributorAssigned.ContributorId); + break; + case AppImageUploaded _: + await WriteAssetAsync(context.AppId, context.Writer); + break; } } - public override async Task BackupAsync(Guid appId, BackupWriter writer) + public async Task BackupAsync(BackupContext context) { - await WriteUsersAsync(writer); - await WriteSettingsAsync(writer, appId); + var json = await appUISettings.GetAsync(context.AppId, null); + + await context.Writer.WriteJsonAsync(SettingsFile, json); } - public override async Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + public async Task RestoreEventAsync(Envelope @event, RestoreContext context) { switch (@event.Payload) { case AppCreated appCreated: { - appName = appCreated.Name; + await ReserveAppAsync(context.AppId, appCreated.Name); + + break; + } - await ResolveUsersAsync(reader); - await ReserveAppAsync(appId); + case AppImageUploaded _: + { + await ReadAssetAsync(context.AppId, context.Reader); break; } case AppContributorAssigned contributorAssigned: { - if (!userMapping.TryGetValue(contributorAssigned.ContributorId, out var user) || user.Equals(actor)) + if (!context.UserMapping.TryMap(contributorAssigned.ContributorId, out var user) || user.Equals(context.Initiator)) { return false; } @@ -97,7 +93,7 @@ namespace Squidex.Domain.Apps.Entities.Apps case AppContributorRemoved contributorRemoved: { - if (!userMapping.TryGetValue(contributorRemoved.ContributorId, out var user) || user.Equals(actor)) + if (!context.UserMapping.TryMap(contributorRemoved.ContributorId, out var user) || user.Equals(context.Initiator)) { return false; } @@ -108,20 +104,17 @@ namespace Squidex.Domain.Apps.Entities.Apps } } - if (@event.Payload is SquidexEvent squidexEvent) - { - squidexEvent.Actor = MapUser(squidexEvent.Actor.Identifier, actor); - } - return true; } - public override Task RestoreAsync(Guid appId, BackupReader reader) + public async Task RestoreAsync(RestoreContext context) { - return ReadSettingsAsync(reader, appId); + var json = await context.Reader.ReadJsonAttachmentAsync(SettingsFile); + + await appUISettings.SetAsync(context.AppId, null, json); } - private async Task ReserveAppAsync(Guid appId) + private async Task ReserveAppAsync(Guid appId, string appName) { appReservation = await appsIndex.ReserveAsync(appId, appName); @@ -131,7 +124,7 @@ namespace Squidex.Domain.Apps.Entities.Apps } } - public override async Task CleanupRestoreErrorAsync(Guid appId) + public async Task CleanupRestoreErrorAsync(Guid appId) { if (appReservation != null) { @@ -139,66 +132,39 @@ namespace Squidex.Domain.Apps.Entities.Apps } } - private RefToken MapUser(string userId, RefToken fallback) + public async Task CompleteRestoreAsync(RestoreContext context) { - return userMapping.GetOrAdd(userId, fallback); + await appsIndex.AddAsync(appReservation); + + await appsIndex.RebuildByContributorsAsync(context.AppId, contributors); } - private async Task ResolveUsersAsync(BackupReader reader) + private Task WriteAssetAsync(Guid appId, IBackupWriter writer) { - await ReadUsersAsync(reader); - - foreach (var kvp in usersWithEmail) + return writer.WriteBlobAsync(AvatarFile, async stream => { - var email = kvp.Value; - - var user = await userResolver.FindByIdOrEmailAsync(email); - - if (user == null && await userResolver.CreateUserIfNotExists(kvp.Value)) + try { - user = await userResolver.FindByIdOrEmailAsync(email); + await appImageStore.DownloadAsync(appId, stream); } - - if (user != null) + catch (AssetNotFoundException) { - userMapping[kvp.Key] = new RefToken(RefTokenType.Subject, user.Id); } - } - } - - private async Task ReadUsersAsync(BackupReader reader) - { - var json = await reader.ReadJsonAttachmentAsync>(UsersFile); - - usersWithEmail = json; - } - - private async Task WriteUsersAsync(BackupWriter writer) - { - var json = usersWithEmail; - - await writer.WriteJsonAsync(UsersFile, json); - } - - private async Task WriteSettingsAsync(BackupWriter writer, Guid appId) - { - var json = await appUISettings.GetAsync(appId, null); - - await writer.WriteJsonAsync(SettingsFile, json); - } - - private async Task ReadSettingsAsync(BackupReader reader, Guid appId) - { - var json = await reader.ReadJsonAttachmentAsync(SettingsFile); - - await appUISettings.SetAsync(appId, null, json); + }); } - public override async Task CompleteRestoreAsync(Guid appId, BackupReader reader) + private async Task ReadAssetAsync(Guid appId, IBackupReader reader) { - await appsIndex.AddAsync(appReservation); - - await appsIndex.RebuildByContributorsAsync(appId, contributors); + await reader.ReadBlobAsync(AvatarFile, async stream => + { + try + { + await appImageStore.UploadAsync(appId, stream); + } + catch (AssetAlreadyExistsException) + { + } + }); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs index dd612658c..6e1276744 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs @@ -15,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands public string Role { get; set; } = Roles.Editor; - public bool IsRestore { get; set; } + public bool Restoring { get; set; } public bool Invite { get; set; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/DefaultAppImageStore.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/DefaultAppImageStore.cs new file mode 100644 index 000000000..642785f11 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/DefaultAppImageStore.cs @@ -0,0 +1,47 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Assets; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public sealed class DefaultAppImageStore : IAppImageStore + { + private readonly IAssetStore assetStore; + + public DefaultAppImageStore(IAssetStore assetStore) + { + Guard.NotNull(assetStore, nameof(assetStore)); + + this.assetStore = assetStore; + } + + public Task DownloadAsync(Guid backupId, Stream stream, CancellationToken ct = default) + { + var fileName = GetFileName(backupId); + + return assetStore.DownloadAsync(fileName, stream, ct); + } + + public Task UploadAsync(Guid backupId, Stream stream, CancellationToken ct = default) + { + var fileName = GetFileName(backupId); + + return assetStore.UploadAsync(fileName, stream, true, ct); + } + + private string GetFileName(Guid backupId) + { + return backupId.ToString(); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs index 88c5240a0..bc08332f9 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs @@ -45,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards command.ContributorId = user.Id; - if (!command.IsRestore) + if (!command.Restoring) { if (string.Equals(command.ContributorId, command.Actor?.Identifier, StringComparison.OrdinalIgnoreCase)) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/IAppImageStore.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/IAppImageStore.cs new file mode 100644 index 000000000..e1d0e31d7 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/IAppImageStore.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public interface IAppImageStore + { + Task UploadAsync(Guid appId, Stream stream, CancellationToken ct = default); + + Task DownloadAsync(Guid appId, Stream stream, CancellationToken ct = default); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs index a0362cb85..785523ff8 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs @@ -29,7 +29,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation { if (context.Command is AssignContributor assignContributor && ShouldInvite(assignContributor)) { - var created = await userResolver.CreateUserIfNotExists(assignContributor.ContributorId, true); + var created = await userResolver.CreateUserIfNotExistsAsync(assignContributor.ContributorId, true); await next(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs index 0a91fd30a..647bd16bf 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Assets { public sealed class AssetCommandMiddleware : GrainCommandMiddleware { - private readonly IAssetStore assetStore; + private readonly IAssetFileStore assetFileStore; private readonly IAssetEnricher assetEnricher; private readonly IAssetQueryService assetQuery; private readonly IAssetThumbnailGenerator assetThumbnailGenerator; @@ -31,20 +31,20 @@ namespace Squidex.Domain.Apps.Entities.Assets IGrainFactory grainFactory, IAssetEnricher assetEnricher, IAssetQueryService assetQuery, - IAssetStore assetStore, + IAssetFileStore assetFileStore, IAssetThumbnailGenerator assetThumbnailGenerator, IContextProvider contextProvider, IEnumerable> tagGenerators) : base(grainFactory) { Guard.NotNull(assetEnricher); - Guard.NotNull(assetStore); + Guard.NotNull(assetFileStore); Guard.NotNull(assetQuery); Guard.NotNull(assetThumbnailGenerator); Guard.NotNull(contextProvider); Guard.NotNull(tagGenerators); - this.assetStore = assetStore; + this.assetFileStore = assetFileStore; this.assetEnricher = assetEnricher; this.assetQuery = assetQuery; this.assetThumbnailGenerator = assetThumbnailGenerator; @@ -90,11 +90,11 @@ namespace Squidex.Domain.Apps.Entities.Assets context.Complete(new AssetCreatedResult(asset, false)); - await assetStore.CopyAsync(tempFile, createAsset.AssetId.ToString(), asset.FileVersion, null); + await assetFileStore.CopyAsync(tempFile, createAsset.AssetId, asset.FileVersion); } finally { - await assetStore.DeleteAsync(tempFile); + await assetFileStore.DeleteAsync(tempFile); } break; @@ -111,11 +111,11 @@ namespace Squidex.Domain.Apps.Entities.Assets var asset = context.Result(); - await assetStore.CopyAsync(tempFile, updateAsset.AssetId.ToString(), asset.FileVersion, null); + await assetFileStore.CopyAsync(tempFile, updateAsset.AssetId, asset.FileVersion); } finally { - await assetStore.DeleteAsync(tempFile); + await assetFileStore.DeleteAsync(tempFile); } break; @@ -153,7 +153,7 @@ namespace Squidex.Domain.Apps.Entities.Assets { using (var hashStream = new HasherStream(command.File.OpenRead(), HashAlgorithmName.SHA256)) { - await assetStore.UploadAsync(tempFile, hashStream); + await assetFileStore.UploadAsync(tempFile, hashStream); command.FileHash = $"{hashStream.GetHashStringAndReset()}{command.File.FileName}{command.File.FileSize}".Sha256Base64(); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs index 85971681c..d56826b4c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs @@ -13,53 +13,53 @@ using Squidex.Domain.Apps.Entities.Assets.State; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Domain.Apps.Events.Assets; using Squidex.Infrastructure; -using Squidex.Infrastructure.Assets; +using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.States; using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Entities.Assets { - public sealed class BackupAssets : BackupHandlerWithStore + public sealed class BackupAssets : IBackupHandler { private const string TagsFile = "AssetTags.json"; private readonly HashSet assetIds = new HashSet(); private readonly HashSet assetFolderIds = new HashSet(); - private readonly IAssetStore assetStore; + private readonly Rebuilder rebuilder; + private readonly IAssetFileStore assetFileStore; private readonly ITagService tagService; - public override string Name { get; } = "Assets"; + public string Name { get; } = "Assets"; - public BackupAssets(IStore store, IAssetStore assetStore, ITagService tagService) - : base(store) + public BackupAssets(Rebuilder rebuilder, IAssetFileStore assetFileStore, ITagService tagService) { - Guard.NotNull(assetStore); + Guard.NotNull(rebuilder); + Guard.NotNull(assetFileStore); Guard.NotNull(tagService); - this.assetStore = assetStore; - + this.rebuilder = rebuilder; + this.assetFileStore = assetFileStore; this.tagService = tagService; } - public override Task BackupAsync(Guid appId, BackupWriter writer) + public Task BackupAsync(BackupContext context) { - return BackupTagsAsync(appId, writer); + return BackupTagsAsync(context); } - public override Task BackupEventAsync(Envelope @event, Guid appId, BackupWriter writer) + public Task BackupEventAsync(Envelope @event, BackupContext context) { switch (@event.Payload) { case AssetCreated assetCreated: - return WriteAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, writer); + return WriteAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, context.Writer); case AssetUpdated assetUpdated: - return WriteAssetAsync(assetUpdated.AssetId, assetUpdated.FileVersion, writer); + return WriteAssetAsync(assetUpdated.AssetId, assetUpdated.FileVersion, context.Writer); } return TaskHelper.Done; } - public override async Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + public async Task RestoreEventAsync(Envelope @event, RestoreContext context) { switch (@event.Payload) { @@ -67,60 +67,72 @@ namespace Squidex.Domain.Apps.Entities.Assets assetFolderIds.Add(assetFolderCreated.AssetFolderId); break; case AssetCreated assetCreated: - await ReadAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, reader); + await ReadAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, context.Reader); break; case AssetUpdated assetUpdated: - await ReadAssetAsync(assetUpdated.AssetId, assetUpdated.FileVersion, reader); + await ReadAssetAsync(assetUpdated.AssetId, assetUpdated.FileVersion, context.Reader); break; } return true; } - public override async Task RestoreAsync(Guid appId, BackupReader reader) + public async Task RestoreAsync(RestoreContext context) { - await RestoreTagsAsync(appId, reader); + await RestoreTagsAsync(context); + + if (assetIds.Count > 0) + { + await rebuilder.InsertManyAsync(async target => + { + foreach (var id in assetIds) + { + await target(id); + } + }); + } - await RebuildManyAsync(assetIds, RebuildAsync); - await RebuildManyAsync(assetFolderIds, RebuildAsync); + if (assetFolderIds.Count > 0) + { + await rebuilder.InsertManyAsync(async target => + { + foreach (var id in assetFolderIds) + { + await target(id); + } + }); + } } - private async Task RestoreTagsAsync(Guid appId, BackupReader reader) + private async Task RestoreTagsAsync(RestoreContext context) { - var tags = await reader.ReadJsonAttachmentAsync(TagsFile); + var tags = await context.Reader.ReadJsonAttachmentAsync(TagsFile); - await tagService.RebuildTagsAsync(appId, TagGroups.Assets, tags); + await tagService.RebuildTagsAsync(context.AppId, TagGroups.Assets, tags); } - private async Task BackupTagsAsync(Guid appId, BackupWriter writer) + private async Task BackupTagsAsync(BackupContext context) { - var tags = await tagService.GetExportableTagsAsync(appId, TagGroups.Assets); + var tags = await tagService.GetExportableTagsAsync(context.AppId, TagGroups.Assets); - await writer.WriteJsonAsync(TagsFile, tags); + await context.Writer.WriteJsonAsync(TagsFile, tags); } - private Task WriteAssetAsync(Guid assetId, long fileVersion, BackupWriter writer) + private Task WriteAssetAsync(Guid assetId, long fileVersion, IBackupWriter writer) { return writer.WriteBlobAsync(GetName(assetId, fileVersion), stream => { - return assetStore.DownloadAsync(assetId.ToString(), fileVersion, null, stream); + return assetFileStore.DownloadAsync(assetId, fileVersion, stream); }); } - private Task ReadAssetAsync(Guid assetId, long fileVersion, BackupReader reader) + private Task ReadAssetAsync(Guid assetId, long fileVersion, IBackupReader reader) { assetIds.Add(assetId); - return reader.ReadBlobAsync(GetName(reader.OldGuid(assetId), fileVersion), async stream => + return reader.ReadBlobAsync(GetName(reader.OldGuid(assetId), fileVersion), stream => { - try - { - await assetStore.UploadAsync(assetId.ToString(), fileVersion, null, stream, true); - } - catch (AssetAlreadyExistsException) - { - return; - } + return assetFileStore.UploadAsync(assetId, fileVersion, stream); }); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/DefaultAssetFileStore.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/DefaultAssetFileStore.cs new file mode 100644 index 000000000..8457a9ae8 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/DefaultAssetFileStore.cs @@ -0,0 +1,75 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Squidex.Infrastructure.Assets; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public sealed class DefaultAssetFileStore : IAssetFileStore + { + private readonly IAssetStore assetStore; + + public DefaultAssetFileStore(IAssetStore assetStore) + { + this.assetStore = assetStore; + } + + public string? GeneratePublicUrl(Guid id, long fileVersion) + { + var fileName = GetFileName(id, fileVersion); + + return assetStore.GeneratePublicUrl(fileName); + } + + public Task UploadAsync(Guid id, long fileVersion, Stream stream, CancellationToken ct = default) + { + var fileName = GetFileName(id, fileVersion); + + return assetStore.UploadAsync(fileName, stream, true, ct); + } + + public Task UploadAsync(string tempFile, Stream stream, CancellationToken ct = default) + { + return assetStore.UploadAsync(tempFile, stream, false, ct); + } + + public Task CopyAsync(string tempFile, Guid id, long fileVersion, CancellationToken ct = default) + { + var fileName = GetFileName(id, fileVersion); + + return assetStore.CopyAsync(tempFile, fileName, ct); + } + + public Task DownloadAsync(Guid id, long fileVersion, Stream stream, CancellationToken ct = default) + { + var fileName = GetFileName(id, fileVersion); + + return assetStore.DownloadAsync(fileName, stream, ct); + } + + public Task DeleteAsync(Guid id, long fileVersion) + { + var fileName = GetFileName(id, fileVersion); + + return assetStore.DeleteAsync(fileName); + } + + public Task DeleteAsync(string tempFile) + { + return assetStore.DeleteAsync(tempFile); + } + + private static string GetFileName(Guid id, long fileVersion) + { + return $"{id}_{fileVersion}"; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetFileStore.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetFileStore.cs new file mode 100644 index 000000000..50a99d2f7 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetFileStore.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public interface IAssetFileStore + { + string? GeneratePublicUrl(Guid id, long fileVersion); + + Task CopyAsync(string tempFile, Guid id, long fileVersion, CancellationToken ct = default); + + Task UploadAsync(string tempFile, Stream stream, CancellationToken ct = default); + + Task UploadAsync(Guid id, long fileVersion, Stream stream, CancellationToken ct = default); + + Task DownloadAsync(Guid id, long fileVersion, Stream stream, CancellationToken ct = default); + + Task DeleteAsync(string tempFile); + + Task DeleteAsync(Guid id, long fileVersion); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupContext.cs new file mode 100644 index 000000000..1a34b0b63 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupContext.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed class BackupContext : BackupContextBase + { + public IBackupWriter Writer { get; } + + public BackupContext(Guid appId, IUserMapping userMapping, IBackupWriter writer) + : base(appId, userMapping) + { + Guard.NotNull(writer); + + Writer = writer; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupContextBase.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupContextBase.cs new file mode 100644 index 000000000..f520719b7 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupContextBase.cs @@ -0,0 +1,33 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public abstract class BackupContextBase + { + public IUserMapping UserMapping { get; } + + public Guid AppId { get; set; } + + public RefToken Initiator + { + get { return UserMapping.Initiator; } + } + + protected BackupContextBase(Guid appId, IUserMapping userMapping) + { + Guard.NotNull(userMapping); + + AppId = appId; + + UserMapping = userMapping; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs index 074b964a7..f72253cdf 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs @@ -13,16 +13,14 @@ using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using NodaTime; using Orleans.Concurrency; -using Squidex.Domain.Apps.Entities.Backup.Helpers; using Squidex.Domain.Apps.Entities.Backup.State; using Squidex.Domain.Apps.Events; using Squidex.Infrastructure; -using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Tasks; +using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Entities.Backup { @@ -31,47 +29,48 @@ namespace Squidex.Domain.Apps.Entities.Backup { private const int MaxBackups = 10; private static readonly Duration UpdateDuration = Duration.FromSeconds(1); - private readonly IAssetStore assetStore; private readonly IBackupArchiveLocation backupArchiveLocation; + private readonly IBackupArchiveStore backupArchiveStore; private readonly IClock clock; - private readonly IJsonSerializer serializer; private readonly IServiceProvider serviceProvider; private readonly IEventDataFormatter eventDataFormatter; private readonly IEventStore eventStore; private readonly ISemanticLog log; private readonly IGrainState state; - private CancellationTokenSource? currentTask; - private BackupStateJob? currentJob; + private readonly IUserResolver userResolver; + private CancellationTokenSource? currentTaskToken; + private BackupJob? currentJob; public BackupGrain( - IAssetStore assetStore, IBackupArchiveLocation backupArchiveLocation, + IBackupArchiveStore backupArchiveStore, IClock clock, - IEventStore eventStore, IEventDataFormatter eventDataFormatter, - IJsonSerializer serializer, + IEventStore eventStore, + IGrainState state, IServiceProvider serviceProvider, - ISemanticLog log, - IGrainState state) + IUserResolver userResolver, + ISemanticLog log) { - Guard.NotNull(assetStore); Guard.NotNull(backupArchiveLocation); + Guard.NotNull(backupArchiveStore); Guard.NotNull(clock); - Guard.NotNull(eventStore); Guard.NotNull(eventDataFormatter); + Guard.NotNull(eventStore); Guard.NotNull(serviceProvider); - Guard.NotNull(serializer); Guard.NotNull(state); + Guard.NotNull(userResolver); Guard.NotNull(log); - this.assetStore = assetStore; this.backupArchiveLocation = backupArchiveLocation; + this.backupArchiveStore = backupArchiveStore; this.clock = clock; - this.eventStore = eventStore; this.eventDataFormatter = eventDataFormatter; - this.serializer = serializer; + this.eventStore = eventStore; this.serviceProvider = serviceProvider; this.state = state; + this.userResolver = userResolver; + this.log = log; } @@ -84,27 +83,14 @@ namespace Squidex.Domain.Apps.Entities.Backup private async Task RecoverAfterRestartAsync() { - foreach (var job in state.Value.Jobs) - { - if (!job.Stopped.HasValue) - { - var jobId = job.Id.ToString(); - - job.Stopped = clock.GetCurrentInstant(); - - await Safe.DeleteAsync(backupArchiveLocation, jobId, log); - await Safe.DeleteAsync(assetStore, jobId, log); + state.Value.Jobs.RemoveAll(x => !x.Stopped.HasValue); - job.Status = JobStatus.Failed; - - await state.WriteAsync(); - } - } + await state.WriteAsync(); } - public async Task RunAsync() + public async Task BackupAsync(RefToken actor) { - if (currentTask != null) + if (currentTaskToken != null) { throw new DomainException("Another backup process is already running."); } @@ -114,53 +100,60 @@ namespace Squidex.Domain.Apps.Entities.Backup throw new DomainException($"You cannot have more than {MaxBackups} backups."); } - var job = new BackupStateJob + var job = new BackupJob { Id = Guid.NewGuid(), Started = clock.GetCurrentInstant(), Status = JobStatus.Started }; - currentTask = new CancellationTokenSource(); + currentTaskToken = new CancellationTokenSource(); currentJob = job; state.Value.Jobs.Insert(0, job); await state.WriteAsync(); - Process(job, currentTask.Token); + Process(job, actor, currentTaskToken.Token); } - private void Process(BackupStateJob job, CancellationToken ct) + private void Process(BackupJob job, RefToken actor, CancellationToken ct) { - ProcessAsync(job, ct).Forget(); + ProcessAsync(job, actor, ct).Forget(); } - private async Task ProcessAsync(BackupStateJob job, CancellationToken ct) + private async Task ProcessAsync(BackupJob job, RefToken actor, CancellationToken ct) { - var jobId = job.Id.ToString(); - var handlers = CreateHandlers(); var lastTimestamp = job.Started; try { - using (var stream = await backupArchiveLocation.OpenStreamAsync(jobId)) + using (var stream = backupArchiveLocation.OpenStream(job.Id)) { - using (var writer = new BackupWriter(serializer, stream, true)) + using (var writer = await backupArchiveLocation.OpenWriterAsync(stream)) { + var userMapping = new UserMapping(actor); + + var context = new BackupContext(Key, userMapping, writer); + await eventStore.QueryAsync(async storedEvent => { var @event = eventDataFormatter.Parse(storedEvent.Data); - writer.WriteEvent(storedEvent); + if (@event.Payload is SquidexEvent squidexEvent) + { + context.UserMapping.Backup(squidexEvent.Actor); + } foreach (var handler in handlers) { - await handler.BackupEventAsync(@event, Key, writer); + await handler.BackupEventAsync(@event, context); } + writer.WriteEvent(storedEvent); + job.HandledEvents = writer.WrittenEvents; job.HandledAssets = writer.WrittenAttachments; @@ -169,27 +162,37 @@ namespace Squidex.Domain.Apps.Entities.Backup foreach (var handler in handlers) { - await handler.BackupAsync(Key, writer); + ct.ThrowIfCancellationRequested(); + + await handler.BackupAsync(context); } foreach (var handler in handlers) { - await handler.CompleteBackupAsync(Key, writer); + ct.ThrowIfCancellationRequested(); + + await handler.CompleteBackupAsync(context); } + + await userMapping.StoreAsync(writer, userResolver); } stream.Position = 0; ct.ThrowIfCancellationRequested(); - await assetStore.UploadAsync(jobId, 0, null, stream, false, ct); + await backupArchiveStore.UploadAsync(job.Id, stream, ct); } job.Status = JobStatus.Completed; } + catch (OperationCanceledException) + { + await RemoveAsync(job); + } catch (Exception ex) { - log.LogError(ex, jobId, (ctx, w) => w + log.LogError(ex, job.Id.ToString(), (ctx, w) => w .WriteProperty("action", "makeBackup") .WriteProperty("status", "failed") .WriteProperty("backupId", ctx)); @@ -198,13 +201,11 @@ namespace Squidex.Domain.Apps.Entities.Backup } finally { - await Safe.DeleteAsync(backupArchiveLocation, jobId, log); - job.Stopped = clock.GetCurrentInstant(); await state.WriteAsync(); - currentTask = null; + currentTaskToken = null; currentJob = null; } } @@ -234,24 +235,36 @@ namespace Squidex.Domain.Apps.Entities.Backup if (currentJob == job) { - currentTask?.Cancel(); + currentTaskToken?.Cancel(); } else { - var jobId = job.Id.ToString(); + await RemoveAsync(job); + } + } - await Safe.DeleteAsync(backupArchiveLocation, jobId, log); - await Safe.DeleteAsync(assetStore, jobId, log); + private async Task RemoveAsync(BackupJob job) + { + try + { + await backupArchiveStore.DeleteAsync(job.Id); + } + catch (Exception ex) + { + log.LogError(ex, job.Id.ToString(), (logOperationId, w) => w + .WriteProperty("action", "deleteBackup") + .WriteProperty("status", "failed") + .WriteProperty("operationId", logOperationId)); + } - state.Value.Jobs.Remove(job); + state.Value.Jobs.Remove(job); - await state.WriteAsync(); - } + await state.WriteAsync(); } - private IEnumerable CreateHandlers() + private IEnumerable CreateHandlers() { - return serviceProvider.GetRequiredService>(); + return serviceProvider.GetRequiredService>(); } public Task>> GetStateAsync() diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupHandlerWithStore.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupHandlerWithStore.cs deleted file mode 100644 index e64b767fe..000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupHandlerWithStore.cs +++ /dev/null @@ -1,54 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.States; - -namespace Squidex.Domain.Apps.Entities.Backup -{ - public abstract class BackupHandlerWithStore : BackupHandler - { - private readonly IStore store; - - protected BackupHandlerWithStore(IStore store) - { - Guard.NotNull(store); - - this.store = store; - } - - protected async Task RebuildManyAsync(IEnumerable ids, Func action) - { - foreach (var id in ids) - { - await action(id); - } - } - - protected async Task RebuildAsync(Guid key) where TState : IDomainState, new() - { - var state = new TState - { - Version = EtagVersion.Empty - }; - - var persistence = store.WithSnapshotsAndEventSourcing(typeof(TGrain), key, (TState s) => state = s, e => - { - state = state.Apply(e); - - state.Version++; - }); - - await persistence.ReadAsync(); - await persistence.WriteSnapshotAsync(state); - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs index da249a082..c3581a1bd 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs @@ -22,7 +22,7 @@ using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Backup { - public sealed class BackupReader : DisposableObjectBase + public class BackupReader : DisposableObjectBase, IBackupReader { private readonly GuidMapper guidMapper = new GuidMapper(); private readonly ZipArchive archive; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupService.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupService.cs new file mode 100644 index 000000000..621e4698e --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupService.cs @@ -0,0 +1,76 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Orleans; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed class BackupService : IBackupService + { + private readonly IGrainFactory grainFactory; + + public BackupService(IGrainFactory grainFactory) + { + Guard.NotNull(grainFactory); + + this.grainFactory = grainFactory; + } + + public Task StartBackupAsync(Guid appId, RefToken actor) + { + var grain = grainFactory.GetGrain(appId); + + return grain.BackupAsync(actor); + } + + public Task StartRestoreAsync(RefToken actor, Uri url, string? newAppName) + { + var grain = grainFactory.GetGrain(SingleGrain.Id); + + return grain.RestoreAsync(url, actor, newAppName); + } + + public async Task GetRestoreAsync() + { + var grain = grainFactory.GetGrain(SingleGrain.Id); + + var state = await grain.GetStateAsync(); + + return state.Value; + } + + public async Task> GetBackupsAsync(Guid appId) + { + var grain = grainFactory.GetGrain(appId); + + var state = await grain.GetStateAsync(); + + return state.Value; + } + + public async Task GetBackupAsync(Guid appId, Guid backupId) + { + var grain = grainFactory.GetGrain(appId); + + var state = await grain.GetStateAsync(); + + return state.Value.Find(x => x.Id == backupId); + } + + public Task DeleteBackupAsync(Guid appId, Guid backupId) + { + var grain = grainFactory.GetGrain(appId); + + return grain.DeleteAsync(backupId); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs index 217f88541..e344b35cc 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs @@ -18,7 +18,7 @@ using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Entities.Backup { - public sealed class BackupWriter : DisposableObjectBase + public sealed class BackupWriter : DisposableObjectBase, IBackupWriter { private readonly ZipArchive archive; private readonly IJsonSerializer serializer; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/DefaultBackupArchiveStore.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/DefaultBackupArchiveStore.cs new file mode 100644 index 000000000..bc9f42047 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/DefaultBackupArchiveStore.cs @@ -0,0 +1,54 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Assets; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed class DefaultBackupArchiveStore : IBackupArchiveStore + { + private readonly IAssetStore assetStore; + + public DefaultBackupArchiveStore(IAssetStore assetStore) + { + Guard.NotNull(assetStore, nameof(assetStore)); + + this.assetStore = assetStore; + } + + public Task DownloadAsync(Guid backupId, Stream stream, CancellationToken ct = default) + { + var fileName = GetFileName(backupId); + + return assetStore.DownloadAsync(fileName, stream, ct); + } + + public Task UploadAsync(Guid backupId, Stream stream, CancellationToken ct = default) + { + var fileName = GetFileName(backupId); + + return assetStore.UploadAsync(fileName, stream, true, ct); + } + + public Task DeleteAsync(Guid backupId) + { + var fileName = GetFileName(backupId); + + return assetStore.DeleteAsync(fileName); + } + + private string GetFileName(Guid backupId) + { + return $"{backupId}_0"; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/GuidMapper.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/GuidMapper.cs index c1aed7a3f..a99236ee3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/GuidMapper.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/GuidMapper.cs @@ -94,6 +94,11 @@ namespace Squidex.Domain.Apps.Entities.Backup private Guid GenerateNewGuid(Guid oldGuid) { + if (oldGuid == Guid.Empty) + { + return Guid.Empty; + } + return oldToNewGuid.GetOrAdd(oldGuid, GuidGenerator); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs deleted file mode 100644 index 1cef6ad2a..000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs +++ /dev/null @@ -1,87 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.IO; -using System.Net.Http; -using System.Threading.Tasks; -using Squidex.Infrastructure.Json; - -namespace Squidex.Domain.Apps.Entities.Backup.Helpers -{ - public static class Downloader - { - public static async Task DownloadAsync(this IBackupArchiveLocation backupArchiveLocation, Uri url, string id) - { - if (string.Equals(url.Scheme, "file")) - { - try - { - using (var targetStream = await backupArchiveLocation.OpenStreamAsync(id)) - { - using (var sourceStream = new FileStream(url.LocalPath, FileMode.Open, FileAccess.Read)) - { - await sourceStream.CopyToAsync(targetStream); - } - } - } - catch (IOException ex) - { - throw new BackupRestoreException($"Cannot download the archive: {ex.Message}.", ex); - } - } - else - { - HttpResponseMessage? response = null; - try - { - using (var client = new HttpClient()) - { - response = await client.GetAsync(url); - response.EnsureSuccessStatusCode(); - - using (var sourceStream = await response.Content.ReadAsStreamAsync()) - { - using (var targetStream = await backupArchiveLocation.OpenStreamAsync(id)) - { - await sourceStream.CopyToAsync(targetStream); - } - } - } - } - catch (HttpRequestException ex) - { - throw new BackupRestoreException($"Cannot download the archive. Got status code: {response?.StatusCode}.", ex); - } - } - } - - public static async Task OpenArchiveAsync(this IBackupArchiveLocation backupArchiveLocation, string id, IJsonSerializer serializer) - { - Stream? stream = null; - - try - { - stream = await backupArchiveLocation.OpenStreamAsync(id); - - return new BackupReader(serializer, stream); - } - catch (IOException) - { - stream?.Dispose(); - - throw new BackupRestoreException("The backup archive is correupt and cannot be opened."); - } - catch (Exception) - { - stream?.Dispose(); - - throw; - } - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Safe.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Safe.cs deleted file mode 100644 index 4938b7788..000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Safe.cs +++ /dev/null @@ -1,62 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; -using Squidex.Infrastructure.Assets; -using Squidex.Infrastructure.Log; - -namespace Squidex.Domain.Apps.Entities.Backup.Helpers -{ - public static class Safe - { - public static async Task DeleteAsync(IBackupArchiveLocation backupArchiveLocation, string id, ISemanticLog log) - { - try - { - await backupArchiveLocation.DeleteArchiveAsync(id); - } - catch (Exception ex) - { - log.LogError(ex, id, (logOperationId, w) => w - .WriteProperty("action", "deleteArchive") - .WriteProperty("status", "failed") - .WriteProperty("operationId", logOperationId)); - } - } - - public static async Task DeleteAsync(IAssetStore assetStore, string id, ISemanticLog log) - { - try - { - await assetStore.DeleteAsync(id, 0, null); - } - catch (Exception ex) - { - log.LogError(ex, id, (logOperationId, w) => w - .WriteProperty("action", "deleteBackup") - .WriteProperty("status", "failed") - .WriteProperty("operationId", logOperationId)); - } - } - - public static async Task CleanupRestoreErrorAsync(BackupHandler handler, Guid appId, Guid id, ISemanticLog log) - { - try - { - await handler.CleanupRestoreErrorAsync(appId); - } - catch (Exception ex) - { - log.LogError(ex, id.ToString(), (logOperationId, w) => w - .WriteProperty("action", "cleanupRestore") - .WriteProperty("status", "failed") - .WriteProperty("operationId", logOperationId)); - } - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveLocation.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveLocation.cs index 96c498639..c2e59ee9c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveLocation.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveLocation.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.IO; using System.Threading.Tasks; @@ -12,8 +13,10 @@ namespace Squidex.Domain.Apps.Entities.Backup { public interface IBackupArchiveLocation { - Task OpenStreamAsync(string backupId); + Stream OpenStream(Guid backupId); - Task DeleteArchiveAsync(string backupId); + Task OpenWriterAsync(Stream stream); + + Task OpenReaderAsync(Uri url, Guid id); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveStore.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveStore.cs new file mode 100644 index 000000000..4ff57a28f --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveStore.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public interface IBackupArchiveStore + { + Task UploadAsync(Guid backupId, Stream stream, CancellationToken ct = default); + + Task DownloadAsync(Guid backupId, Stream stream, CancellationToken ct = default); + + Task DeleteAsync(Guid backupId); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupGrain.cs index 21f66e2ff..9d82ae0d1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupGrain.cs @@ -9,13 +9,14 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; using Orleans; +using Squidex.Infrastructure; using Squidex.Infrastructure.Orleans; namespace Squidex.Domain.Apps.Entities.Backup { public interface IBackupGrain : IGrainWithGuidKey { - Task RunAsync(); + Task BackupAsync(RefToken actor); Task DeleteAsync(Guid id); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupHandler.cs similarity index 57% rename from backend/src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs rename to backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupHandler.cs index dc0bb7b0d..ee328dca6 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupHandler.cs @@ -7,47 +7,46 @@ using System; using System.Threading.Tasks; -using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Entities.Backup { - public abstract class BackupHandler + public interface IBackupHandler { - public abstract string Name { get; } + string Name { get; } - public virtual Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + public Task RestoreEventAsync(Envelope @event, RestoreContext context) { return TaskHelper.True; } - public virtual Task BackupEventAsync(Envelope @event, Guid appId, BackupWriter writer) + public Task BackupEventAsync(Envelope @event, BackupContext context) { return TaskHelper.Done; } - public virtual Task RestoreAsync(Guid appId, BackupReader reader) + public Task RestoreAsync(RestoreContext context) { return TaskHelper.Done; } - public virtual Task BackupAsync(Guid appId, BackupWriter writer) + public Task BackupAsync(BackupContext context) { return TaskHelper.Done; } - public virtual Task CleanupRestoreErrorAsync(Guid appId) + public Task CleanupRestoreErrorAsync(Guid appId) { return TaskHelper.Done; } - public virtual Task CompleteRestoreAsync(Guid appId, BackupReader reader) + public Task CompleteRestoreAsync(RestoreContext context) { return TaskHelper.Done; } - public virtual Task CompleteBackupAsync(Guid appId, BackupWriter writer) + public Task CompleteBackupAsync(BackupContext context) { return TaskHelper.Done; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs new file mode 100644 index 000000000..0e6e4a80e --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading.Tasks; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public interface IBackupReader : IDisposable + { + int ReadAttachments { get; } + + int ReadEvents { get; } + + Guid OldGuid(Guid newId); + + Task ReadBlobAsync(string name, Func handler); + + Task ReadEventsAsync(IStreamNameResolver streamNameResolver, IEventDataFormatter formatter, Func<(string Stream, Envelope Event), Task> handler); + + Task ReadJsonAttachmentAsync(string name); + } +} \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupService.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupService.cs new file mode 100644 index 000000000..7541e5b26 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupService.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public interface IBackupService + { + Task StartBackupAsync(Guid appId, RefToken actor); + + Task StartRestoreAsync(RefToken actor, Uri url, string? newAppName); + + Task GetRestoreAsync(); + + Task> GetBackupsAsync(Guid appId); + + Task GetBackupAsync(Guid appId, Guid backupId); + + Task DeleteBackupAsync(Guid appId, Guid backupId); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupWriter.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupWriter.cs new file mode 100644 index 000000000..15b630b3b --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupWriter.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading.Tasks; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public interface IBackupWriter : IDisposable + { + int WrittenAttachments { get; } + + int WrittenEvents { get; } + + Task WriteBlobAsync(string name, Func handler); + + void WriteEvent(StoredEvent storedEvent); + + Task WriteJsonAsync(string name, object value); + } +} \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs index ada75140f..6e1823066 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs @@ -17,6 +17,6 @@ namespace Squidex.Domain.Apps.Entities.Backup { Task RestoreAsync(Uri url, RefToken actor, string? newAppName = null); - Task> GetJobAsync(); + Task> GetStateAsync(); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IUserMapping.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IUserMapping.cs new file mode 100644 index 000000000..9816d6d47 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IUserMapping.cs @@ -0,0 +1,24 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public interface IUserMapping + { + RefToken Initiator { get; } + + void Backup(RefToken token); + + void Backup(string userId); + + bool TryMap(RefToken token, out RefToken result); + + bool TryMap(string userId, out RefToken result); + } +} \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreContext.cs new file mode 100644 index 000000000..a571fa957 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreContext.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed class RestoreContext : BackupContextBase + { + public IBackupReader Reader { get; } + + public RestoreContext(Guid appId, IUserMapping userMapping, IBackupReader reader) + : base(appId, userMapping) + { + Guard.NotNull(reader); + + Reader = reader; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs index 38ebeb0a2..bf30e9140 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs @@ -12,18 +12,17 @@ using Microsoft.Extensions.DependencyInjection; using NodaTime; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; -using Squidex.Domain.Apps.Entities.Backup.Helpers; using Squidex.Domain.Apps.Entities.Backup.State; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Apps; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.States; using Squidex.Infrastructure.Tasks; +using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Entities.Backup { @@ -32,50 +31,52 @@ namespace Squidex.Domain.Apps.Entities.Backup private readonly IBackupArchiveLocation backupArchiveLocation; private readonly IClock clock; private readonly ICommandBus commandBus; - private readonly IJsonSerializer serializer; private readonly IEventStore eventStore; private readonly IEventDataFormatter eventDataFormatter; private readonly ISemanticLog log; private readonly IServiceProvider serviceProvider; private readonly IStreamNameResolver streamNameResolver; - private readonly IGrainState state; + private readonly IUserResolver userResolver; + private readonly IGrainState state; + private RestoreContext restoreContext; - private RestoreStateJob CurrentJob + private RestoreJob CurrentJob { get { return state.Value.Job; } } - public RestoreGrain(IBackupArchiveLocation backupArchiveLocation, + public RestoreGrain( + IBackupArchiveLocation backupArchiveLocation, IClock clock, ICommandBus commandBus, - IEventStore eventStore, IEventDataFormatter eventDataFormatter, - IJsonSerializer serializer, - ISemanticLog log, + IEventStore eventStore, + IGrainState state, IServiceProvider serviceProvider, IStreamNameResolver streamNameResolver, - IGrainState state) + IUserResolver userResolver, + ISemanticLog log) { Guard.NotNull(backupArchiveLocation); Guard.NotNull(clock); Guard.NotNull(commandBus); - Guard.NotNull(eventStore); Guard.NotNull(eventDataFormatter); - Guard.NotNull(serializer); + Guard.NotNull(eventStore); Guard.NotNull(serviceProvider); Guard.NotNull(state); Guard.NotNull(streamNameResolver); + Guard.NotNull(userResolver); Guard.NotNull(log); this.backupArchiveLocation = backupArchiveLocation; this.clock = clock; this.commandBus = commandBus; - this.eventStore = eventStore; this.eventDataFormatter = eventDataFormatter; - this.serializer = serializer; + this.eventStore = eventStore; this.serviceProvider = serviceProvider; - this.streamNameResolver = streamNameResolver; this.state = state; + this.streamNameResolver = streamNameResolver; + this.userResolver = userResolver; this.log = log; } @@ -90,14 +91,10 @@ namespace Squidex.Domain.Apps.Entities.Backup { if (CurrentJob?.Status == JobStatus.Started) { - var handlers = CreateHandlers(); - Log("Failed due application restart"); CurrentJob.Status = JobStatus.Failed; - await CleanupAsync(handlers); - await state.WriteAsync(); } } @@ -117,7 +114,7 @@ namespace Squidex.Domain.Apps.Entities.Backup throw new DomainException("A restore operation is already running."); } - state.Value.Job = new RestoreStateJob + state.Value.Job = new RestoreJob { Id = Guid.NewGuid(), NewAppName = newAppName, @@ -159,12 +156,7 @@ namespace Squidex.Domain.Apps.Entities.Backup .WriteProperty("operationId", ctx.jobId) .WriteProperty("url", ctx.jobUrl)); - using (Profiler.Trace("Download")) - { - await DownloadAsync(); - } - - using (var reader = await backupArchiveLocation.OpenArchiveAsync(CurrentJob.Id.ToString(), serializer)) + using (var reader = await DownloadAsync()) { using (Profiler.Trace("ReadEvents")) { @@ -173,9 +165,9 @@ namespace Squidex.Domain.Apps.Entities.Backup foreach (var handler in handlers) { - using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.RestoreAsync))) + using (Profiler.TraceMethod(handler.GetType(), nameof(IBackupHandler.RestoreAsync))) { - await handler.RestoreAsync(CurrentJob.AppId, reader); + await handler.RestoreAsync(restoreContext); } Log($"Restored {handler.Name}"); @@ -183,9 +175,9 @@ namespace Squidex.Domain.Apps.Entities.Backup foreach (var handler in handlers) { - using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.CompleteRestoreAsync))) + using (Profiler.TraceMethod(handler.GetType(), nameof(IBackupHandler.CompleteRestoreAsync))) { - await handler.CompleteRestoreAsync(CurrentJob.AppId, reader); + await handler.CompleteRestoreAsync(restoreContext); } Log($"Completed {handler.Name}"); @@ -253,9 +245,9 @@ namespace Squidex.Domain.Apps.Entities.Backup await commandBus.PublishAsync(new AssignContributor { Actor = actor, - AppId = CurrentJob.AppId, + AppId = CurrentJob.AppId.Id, ContributorId = actor.Identifier, - IsRestore = true, + Restoring = true, Role = Role.Owner }); @@ -272,29 +264,44 @@ namespace Squidex.Domain.Apps.Entities.Backup } } - private async Task CleanupAsync(IEnumerable handlers) + private async Task CleanupAsync(IEnumerable handlers) { - await Safe.DeleteAsync(backupArchiveLocation, CurrentJob.Id.ToString(), log); - - if (CurrentJob.AppId != Guid.Empty) + if (CurrentJob.AppId != null) { + var appId = CurrentJob.AppId.Id; + foreach (var handler in handlers) { - await Safe.CleanupRestoreErrorAsync(handler, CurrentJob.AppId, CurrentJob.Id, log); + try + { + await handler.CleanupRestoreErrorAsync(appId); + } + catch (Exception ex) + { + log.LogError(ex, appId.ToString(), (logOperationId, w) => w + .WriteProperty("action", "cleanupRestore") + .WriteProperty("status", "failed") + .WriteProperty("operationId", logOperationId)); + } } } } - private async Task DownloadAsync() + private async Task DownloadAsync() { - Log("Downloading Backup"); + using (Profiler.Trace("Download")) + { + Log("Downloading Backup"); + + var reader = await backupArchiveLocation.OpenReaderAsync(CurrentJob.Url, CurrentJob.Id); - await backupArchiveLocation.DownloadAsync(CurrentJob.Url, CurrentJob.Id.ToString()); + Log("Downloaded Backup"); - Log("Downloaded Backup"); + return reader; + } } - private async Task ReadEventsAsync(BackupReader reader, IEnumerable handlers) + private async Task ReadEventsAsync(IBackupReader reader, IEnumerable handlers) { await reader.ReadEventsAsync(streamNameResolver, eventDataFormatter, async storedEvent => { @@ -304,31 +311,40 @@ namespace Squidex.Domain.Apps.Entities.Backup Log($"Reading {reader.ReadEvents} events and {reader.ReadAttachments} attachments completed.", true); } - private async Task HandleEventAsync(BackupReader reader, IEnumerable handlers, string stream, Envelope @event) + private async Task HandleEventAsync(IBackupReader reader, IEnumerable handlers, string stream, Envelope @event) { - if (@event.Payload is SquidexEvent squidexEvent) - { - squidexEvent.Actor = CurrentJob.Actor; - } - if (@event.Payload is AppCreated appCreated) { - CurrentJob.AppId = appCreated.AppId.Id; - if (!string.IsNullOrWhiteSpace(CurrentJob.NewAppName)) { appCreated.Name = CurrentJob.NewAppName; + + CurrentJob.AppId = NamedId.Of(appCreated.AppId.Id, CurrentJob.NewAppName); + } + else + { + CurrentJob.AppId = appCreated.AppId; } + + await CreateContextAsync(reader); } - if (@event.Payload is AppEvent appEvent && !string.IsNullOrWhiteSpace(CurrentJob.NewAppName)) + if (@event.Payload is SquidexEvent squidexEvent) { - appEvent.AppId = NamedId.Of(appEvent.AppId.Id, CurrentJob.NewAppName); + if (restoreContext.UserMapping.TryMap(squidexEvent.Actor, out var newUser)) + { + squidexEvent.Actor = newUser; + } + } + + if (@event.Payload is AppEvent appEvent) + { + appEvent.AppId = CurrentJob.AppId; } foreach (var handler in handlers) { - if (!await handler.RestoreEventAsync(@event, CurrentJob.AppId, reader, CurrentJob.Actor)) + if (!await handler.RestoreEventAsync(@event, restoreContext)) { return; } @@ -342,6 +358,22 @@ namespace Squidex.Domain.Apps.Entities.Backup Log($"Read {reader.ReadEvents} events and {reader.ReadAttachments} attachments.", true); } + private async Task CreateContextAsync(IBackupReader reader) + { + var userMapping = new UserMapping(CurrentJob.Actor); + + using (Profiler.Trace("CreateUsers")) + { + Log("Creating Users"); + + await userMapping.RestoreAsync(reader, userResolver); + + Log("Created Users"); + } + + restoreContext = new RestoreContext(CurrentJob.AppId.Id, userMapping, reader); + } + private void Log(string message, bool replace = false) { if (replace && CurrentJob.Log.Count > 0) @@ -354,12 +386,12 @@ namespace Squidex.Domain.Apps.Entities.Backup } } - private IEnumerable CreateHandlers() + private IEnumerable CreateHandlers() { - return serviceProvider.GetRequiredService>(); + return serviceProvider.GetRequiredService>(); } - public Task> GetJobAsync() + public Task> GetStateAsync() { return Task.FromResult>(CurrentJob); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupJob.cs similarity index 94% rename from backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs rename to backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupJob.cs index 209a6f1c7..2b519e3ae 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupJob.cs @@ -11,7 +11,7 @@ using NodaTime; namespace Squidex.Domain.Apps.Entities.Backup.State { - public sealed class BackupStateJob : IBackupJob + public sealed class BackupJob : IBackupJob { [DataMember] public Guid Id { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs index 75cf87a13..8b07ea8e3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs @@ -13,6 +13,6 @@ namespace Squidex.Domain.Apps.Entities.Backup.State public sealed class BackupState { [DataMember] - public List Jobs { get; } = new List(); + public List Jobs { get; } = new List(); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreJob.cs similarity index 92% rename from backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs rename to backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreJob.cs index 5d9e58f8c..7decbfb34 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreJob.cs @@ -14,7 +14,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Backup.State { [DataContract] - public sealed class RestoreStateJob : IRestoreJob + public sealed class RestoreJob : IRestoreJob { [DataMember] public string AppName { get; set; } @@ -23,7 +23,7 @@ namespace Squidex.Domain.Apps.Entities.Backup.State public Guid Id { get; set; } [DataMember] - public Guid AppId { get; set; } + public NamedId AppId { get; set; } [DataMember] public RefToken Actor { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState2.cs similarity index 86% rename from backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState.cs rename to backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState2.cs index bbfc4db36..eb1351b76 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState2.cs @@ -9,9 +9,9 @@ using System.Runtime.Serialization; namespace Squidex.Domain.Apps.Entities.Backup.State { - public class RestoreState + public class RestoreState2 { [DataMember] - public RestoreStateJob Job { get; set; } + public RestoreJob Job { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs index 4f5822ce7..8decd9935 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs @@ -5,39 +5,106 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Net.Http; using System.Threading.Tasks; -using Squidex.Infrastructure.Tasks; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json; namespace Squidex.Domain.Apps.Entities.Backup { + [ExcludeFromCodeCoverage] public sealed class TempFolderBackupArchiveLocation : IBackupArchiveLocation { - public Task OpenStreamAsync(string backupId) + private readonly IJsonSerializer jsonSerializer; + + public TempFolderBackupArchiveLocation(IJsonSerializer jsonSerializer) { - var tempFile = GetTempFile(backupId); + Guard.NotNull(jsonSerializer); - return Task.FromResult(new FileStream(tempFile, FileMode.OpenOrCreate, FileAccess.ReadWrite)); + this.jsonSerializer = jsonSerializer; } - public Task DeleteArchiveAsync(string backupId) + public async Task OpenReaderAsync(Uri url, Guid id) { - var tempFile = GetTempFile(backupId); + var stream = OpenStream(id); + + if (string.Equals(url.Scheme, "file")) + { + try + { + using (var sourceStream = new FileStream(url.LocalPath, FileMode.Open, FileAccess.Read)) + { + await sourceStream.CopyToAsync(stream); + } + } + catch (IOException ex) + { + throw new BackupRestoreException($"Cannot download the archive: {ex.Message}.", ex); + } + } + else + { + HttpResponseMessage? response = null; + try + { + using (var client = new HttpClient()) + { + response = await client.GetAsync(url); + response.EnsureSuccessStatusCode(); + + using (var sourceStream = await response.Content.ReadAsStreamAsync()) + { + await sourceStream.CopyToAsync(stream); + } + } + } + catch (HttpRequestException ex) + { + throw new BackupRestoreException($"Cannot download the archive. Got status code: {response?.StatusCode}.", ex); + } + } try { - File.Delete(tempFile); + return new BackupReader(jsonSerializer, stream); } catch (IOException) { + stream.Dispose(); + + throw new BackupRestoreException("The backup archive is corrupt and cannot be opened."); + } + catch (Exception) + { + stream.Dispose(); + + throw; } + } - return TaskHelper.Done; + public Stream OpenStream(Guid backupId) + { + var tempFile = Path.Combine(Path.GetTempPath(), backupId + ".zip"); + + var fileStream = new FileStream( + tempFile, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.None, + 4096, + FileOptions.DeleteOnClose); + + return fileStream; } - private static string GetTempFile(string backupId) + public Task OpenWriterAsync(Stream stream) { - return Path.Combine(Path.GetTempPath(), backupId + ".zip"); + var writer = new BackupWriter(jsonSerializer, stream, true); + + return Task.FromResult(writer); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/UserMapping.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/UserMapping.cs new file mode 100644 index 000000000..e0002fd82 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/UserMapping.cs @@ -0,0 +1,127 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Infrastructure; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public class UserMapping : IUserMapping + { + private const string UsersFile = "Users.json"; + private readonly Dictionary userMap = new Dictionary(); + private readonly RefToken initiator; + + public RefToken Initiator + { + get { return initiator; } + } + + public UserMapping(RefToken initiator) + { + Guard.NotNull(initiator); + + this.initiator = initiator; + } + + public void Backup(RefToken token) + { + Guard.NotNull(userMap); + + if (!token.IsSubject) + { + return; + } + + userMap[token.Identifier] = token; + } + + public void Backup(string userId) + { + Guard.NotNullOrEmpty(userId); + + if (!userMap.ContainsKey(userId)) + { + userMap[userId] = new RefToken(RefTokenType.Subject, userId); + } + } + + public async Task StoreAsync(IBackupWriter writer, IUserResolver userResolver) + { + Guard.NotNull(writer); + Guard.NotNull(userResolver); + + var users = await userResolver.QueryManyAsync(userMap.Keys.ToArray()); + + var json = users.ToDictionary(x => x.Key, x => x.Value.Email); + + await writer.WriteJsonAsync(UsersFile, json); + } + + public async Task RestoreAsync(IBackupReader reader, IUserResolver userResolver) + { + Guard.NotNull(reader); + Guard.NotNull(userResolver); + + var json = await reader.ReadJsonAttachmentAsync>(UsersFile); + + foreach (var kvp in json) + { + var email = kvp.Value; + + var user = await userResolver.FindByIdOrEmailAsync(email); + + if (user == null && await userResolver.CreateUserIfNotExistsAsync(kvp.Value, false)) + { + user = await userResolver.FindByIdOrEmailAsync(email); + } + + if (user != null) + { + userMap[kvp.Key] = new RefToken(RefTokenType.Subject, user.Id); + } + } + } + + public bool TryMap(string userId, out RefToken result) + { + Guard.NotNullOrEmpty(userId); + + if (userMap.TryGetValue(userId, out var mapped)) + { + result = mapped; + return true; + } + + result = initiator; + return false; + } + + public bool TryMap(RefToken token, out RefToken result) + { + Guard.NotNull(token); + + if (token.IsClient) + { + result = token; + return true; + } + + if (userMap.TryGetValue(token.Identifier, out var mapped)) + { + result = mapped; + return true; + } + + result = initiator; + return false; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs index 84b0cb8d9..ec0489173 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs @@ -14,24 +14,27 @@ using Squidex.Domain.Apps.Entities.Contents.State; using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.States; using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Entities.Contents { - public sealed class BackupContents : BackupHandlerWithStore + public sealed class BackupContents : IBackupHandler { private readonly Dictionary> contentIdsBySchemaId = new Dictionary>(); + private readonly Rebuilder rebuilder; - public override string Name { get; } = "Contents"; + public string Name { get; } = "Contents"; - public BackupContents(IStore store) - : base(store) + public BackupContents(Rebuilder rebuilder) { + Guard.NotNull(rebuilder); + + this.rebuilder = rebuilder; } - public override Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + public Task RestoreEventAsync(Envelope @event, RestoreContext context) { switch (@event.Payload) { @@ -46,11 +49,18 @@ namespace Squidex.Domain.Apps.Entities.Contents return TaskHelper.True; } - public override Task RestoreAsync(Guid appId, BackupReader reader) + public async Task RestoreAsync(RestoreContext context) { - var contentIds = contentIdsBySchemaId.Values.SelectMany(x => x); - - return RebuildManyAsync(contentIds, RebuildAsync); + if (contentIdsBySchemaId.Count > 0) + { + await rebuilder.InsertManyAsync(async target => + { + foreach (var contentId in contentIdsBySchemaId.Values.SelectMany(x => x)) + { + await target(contentId); + } + }); + } } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs index 708bd815d..4c4a4bf80 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs @@ -111,7 +111,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { var context = (GraphQLExecutionContext)c.UserContext; - return context.UrlGenerator.GenerateAssetSourceUrl(app, c.Source); + return context.UrlGenerator.GenerateAssetSourceUrl(c.Source); }); return resolver; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLUrlGenerator.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLUrlGenerator.cs index 8be64d064..6a1bb87b7 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLUrlGenerator.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLUrlGenerator.cs @@ -17,7 +17,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL string? GenerateAssetThumbnailUrl(IAppEntity app, IAssetEntity asset); - string? GenerateAssetSourceUrl(IAppEntity app, IAssetEntity asset); + string? GenerateAssetSourceUrl(IAssetEntity asset); string GenerateAssetUrl(IAppEntity app, IAssetEntity asset); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/AssetIndexStorage.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/AssetIndexStorage.cs index 718fe8aa4..da10e3451 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/AssetIndexStorage.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/AssetIndexStorage.cs @@ -45,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text { try { - await assetStore.DownloadAsync(directoryInfo.Name, 0, string.Empty, fileStream); + await assetStore.DownloadAsync(directoryInfo.Name, fileStream); fileStream.Position = 0; @@ -99,7 +99,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text fileStream.Position = 0; - await assetStore.UploadAsync(directoryInfo.Name, 0, string.Empty, fileStream, true); + await assetStore.UploadAsync(directoryInfo.Name, fileStream, true); } } finally diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/BackupRules.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/BackupRules.cs index 01226e61d..b46a75ea2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/BackupRules.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/BackupRules.cs @@ -17,12 +17,12 @@ using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Entities.Rules { - public sealed class BackupRules : BackupHandler + public sealed class BackupRules : IBackupHandler { private readonly HashSet ruleIds = new HashSet(); private readonly IRulesIndex indexForRules; - public override string Name { get; } = "Rules"; + public string Name { get; } = "Rules"; public BackupRules(IRulesIndex indexForRules) { @@ -31,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Rules this.indexForRules = indexForRules; } - public override Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + public Task RestoreEventAsync(Envelope @event, RestoreContext context) { switch (@event.Payload) { @@ -46,9 +46,9 @@ namespace Squidex.Domain.Apps.Entities.Rules return TaskHelper.True; } - public override Task RestoreAsync(Guid appId, BackupReader reader) + public Task RestoreAsync(RestoreContext context) { - return indexForRules.RebuildAsync(appId, ruleIds); + return indexForRules.RebuildAsync(context.AppId, ruleIds); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/BackupSchemas.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/BackupSchemas.cs index a62306c64..9a722fe36 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/BackupSchemas.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/BackupSchemas.cs @@ -17,12 +17,12 @@ using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Entities.Schemas { - public sealed class BackupSchemas : BackupHandler + public sealed class BackupSchemas : IBackupHandler { private readonly Dictionary schemasByName = new Dictionary(); private readonly ISchemasIndex indexSchemas; - public override string Name { get; } = "Schemas"; + public string Name { get; } = "Schemas"; public BackupSchemas(ISchemasIndex indexSchemas) { @@ -31,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas this.indexSchemas = indexSchemas; } - public override Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + public Task RestoreEventAsync(Envelope @event, RestoreContext context) { switch (@event.Payload) { @@ -46,9 +46,9 @@ namespace Squidex.Domain.Apps.Entities.Schemas return TaskHelper.True; } - public override Task RestoreAsync(Guid appId, BackupReader reader) + public Task RestoreAsync(RestoreContext context) { - return indexSchemas.RebuildAsync(appId, schemasByName); + return indexSchemas.RebuildAsync(context.AppId, schemasByName); } } } diff --git a/backend/src/Squidex.Domain.Users/AssetUserPictureStore.cs b/backend/src/Squidex.Domain.Users/DefaultUserPictureStore.cs similarity index 51% rename from backend/src/Squidex.Domain.Users/AssetUserPictureStore.cs rename to backend/src/Squidex.Domain.Users/DefaultUserPictureStore.cs index 48b364886..b0c0de420 100644 --- a/backend/src/Squidex.Domain.Users/AssetUserPictureStore.cs +++ b/backend/src/Squidex.Domain.Users/DefaultUserPictureStore.cs @@ -6,37 +6,41 @@ // ========================================================================== using System.IO; +using System.Threading; using System.Threading.Tasks; using Squidex.Infrastructure; using Squidex.Infrastructure.Assets; namespace Squidex.Domain.Users { - public sealed class AssetUserPictureStore : IUserPictureStore + public sealed class DefaultUserPictureStore : IUserPictureStore { private readonly IAssetStore assetStore; - public AssetUserPictureStore(IAssetStore assetStore) + public DefaultUserPictureStore(IAssetStore assetStore) { Guard.NotNull(assetStore); this.assetStore = assetStore; } - public Task UploadAsync(string userId, Stream stream) + public Task UploadAsync(string userId, Stream stream, CancellationToken ct = default) { - return assetStore.UploadAsync(userId, 0, "picture", stream, true); + var fileName = GetFileName(userId); + + return assetStore.UploadAsync(fileName, stream, true, ct); } - public async Task DownloadAsync(string userId) + public Task DownloadAsync(string userId, Stream stream, CancellationToken ct = default) { - var memoryStream = new MemoryStream(); - - await assetStore.DownloadAsync(userId, 0, "picture", memoryStream); + var fileName = GetFileName(userId); - memoryStream.Position = 0; + return assetStore.DownloadAsync(fileName, stream, ct); + } - return memoryStream; + private static string GetFileName(string userId) + { + return $"{userId}_0_picture"; } } } diff --git a/backend/src/Squidex.Domain.Users/DefaultUserResolver.cs b/backend/src/Squidex.Domain.Users/DefaultUserResolver.cs index 97357cc60..023cd57aa 100644 --- a/backend/src/Squidex.Domain.Users/DefaultUserResolver.cs +++ b/backend/src/Squidex.Domain.Users/DefaultUserResolver.cs @@ -27,7 +27,7 @@ namespace Squidex.Domain.Users this.serviceProvider = serviceProvider; } - public async Task CreateUserIfNotExists(string email, bool invited) + public async Task CreateUserIfNotExistsAsync(string email, bool invited) { Guard.NotNullOrEmpty(email); diff --git a/backend/src/Squidex.Domain.Users/IUserPictureStore.cs b/backend/src/Squidex.Domain.Users/IUserPictureStore.cs index 461b7e01b..f6b1560fa 100644 --- a/backend/src/Squidex.Domain.Users/IUserPictureStore.cs +++ b/backend/src/Squidex.Domain.Users/IUserPictureStore.cs @@ -6,14 +6,15 @@ // ========================================================================== using System.IO; +using System.Threading; using System.Threading.Tasks; namespace Squidex.Domain.Users { public interface IUserPictureStore { - Task UploadAsync(string userId, Stream stream); + Task UploadAsync(string userId, Stream stream, CancellationToken ct = default); - Task DownloadAsync(string userId); + Task DownloadAsync(string userId, Stream stream, CancellationToken ct = default); } } diff --git a/backend/src/Squidex.Domain.Users/UserWithClaims.cs b/backend/src/Squidex.Domain.Users/UserWithClaims.cs index 17f6f6bad..462ca948f 100644 --- a/backend/src/Squidex.Domain.Users/UserWithClaims.cs +++ b/backend/src/Squidex.Domain.Users/UserWithClaims.cs @@ -33,7 +33,7 @@ namespace Squidex.Domain.Users public bool IsLocked { - get { return Identity.LockoutEnd > DateTime.Now.ToUniversalTime(); } + get { return Identity.LockoutEnd > DateTime.UtcNow; } } IReadOnlyList IUser.Claims diff --git a/backend/src/Squidex.Infrastructure/Assets/AssetStoreExtensions.cs b/backend/src/Squidex.Infrastructure/Assets/AssetStoreExtensions.cs deleted file mode 100644 index 00fae3d2d..000000000 --- a/backend/src/Squidex.Infrastructure/Assets/AssetStoreExtensions.cs +++ /dev/null @@ -1,74 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace Squidex.Infrastructure.Assets -{ - public static class AssetStoreExtensions - { - public static string? GeneratePublicUrl(this IAssetStore store, Guid id, long version, string? suffix) - { - return store.GeneratePublicUrl(id.ToString(), version, suffix); - } - - public static string? GeneratePublicUrl(this IAssetStore store, string id, long version, string? suffix) - { - return store.GeneratePublicUrl(GetFileName(id, version, suffix)); - } - - public static Task CopyAsync(this IAssetStore store, string sourceFileName, Guid id, long version, string? suffix, CancellationToken ct = default) - { - return store.CopyAsync(sourceFileName, id.ToString(), version, suffix, ct); - } - - public static Task CopyAsync(this IAssetStore store, string sourceFileName, string id, long version, string? suffix, CancellationToken ct = default) - { - return store.CopyAsync(sourceFileName, GetFileName(id, version, suffix), ct); - } - - public static Task DownloadAsync(this IAssetStore store, Guid id, long version, string? suffix, Stream stream, CancellationToken ct = default) - { - return store.DownloadAsync(id.ToString(), version, suffix, stream, ct); - } - - public static Task DownloadAsync(this IAssetStore store, string id, long version, string? suffix, Stream stream, CancellationToken ct = default) - { - return store.DownloadAsync(GetFileName(id, version, suffix), stream, ct); - } - - public static Task UploadAsync(this IAssetStore store, Guid id, long version, string? suffix, Stream stream, bool overwrite = false, CancellationToken ct = default) - { - return store.UploadAsync(id.ToString(), version, suffix, stream, overwrite, ct); - } - - public static Task UploadAsync(this IAssetStore store, string id, long version, string? suffix, Stream stream, bool overwrite = false, CancellationToken ct = default) - { - return store.UploadAsync(GetFileName(id, version, suffix), stream, overwrite, ct); - } - - public static Task DeleteAsync(this IAssetStore store, Guid id, long version, string? suffix) - { - return store.DeleteAsync(id.ToString(), version, suffix); - } - - public static Task DeleteAsync(this IAssetStore store, string id, long version, string? suffix) - { - return store.DeleteAsync(GetFileName(id, version, suffix)); - } - - public static string GetFileName(string id, long version, string? suffix = null) - { - Guard.NotNullOrEmpty(id); - - return StringExtensions.JoinNonEmpty("_", id, version.ToString(), suffix); - } - } -} diff --git a/backend/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs b/backend/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs index a34f0c334..6e71189f3 100644 --- a/backend/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs +++ b/backend/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -14,6 +15,7 @@ using Squidex.Infrastructure.Log; namespace Squidex.Infrastructure.Assets { + [ExcludeFromCodeCoverage] public sealed class FTPAssetStore : IAssetStore, IInitializable { private readonly string path; diff --git a/backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs b/backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs new file mode 100644 index 000000000..f45d94481 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs @@ -0,0 +1,114 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; +using Squidex.Infrastructure.Caching; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; + +namespace Squidex.Infrastructure.Commands +{ + public delegate Task IdSource(Func add); + + public class Rebuilder + { + private readonly ILocalCache localCache; + private readonly IStore store; + private readonly IEventStore eventStore; + + public Rebuilder( + ILocalCache localCache, + IStore store, + IEventStore eventStore) + { + Guard.NotNull(localCache); + Guard.NotNull(store); + Guard.NotNull(eventStore); + + this.eventStore = eventStore; + this.localCache = localCache; + this.store = store; + } + + public Task RebuildAsync(string filter, CancellationToken ct) where TState : IDomainState, new() + { + return RebuildAsync(async target => + { + await eventStore.QueryAsync(async storedEvent => + { + var id = storedEvent.Data.Headers.AggregateId(); + + await target(id); + }, filter, ct: ct); + }, ct); + } + + public virtual async Task RebuildAsync(IdSource source, CancellationToken ct = default) where TState : IDomainState, new() + { + Guard.NotNull(source); + + await store.GetSnapshotStore().ClearAsync(); + + await InsertManyAsync(source, ct); + } + + public virtual async Task InsertManyAsync(IdSource source, CancellationToken ct = default) where TState : IDomainState, new() + { + Guard.NotNull(source); + + var worker = new ActionBlock(async id => + { + try + { + var state = new TState + { + Version = EtagVersion.Empty + }; + + var persistence = store.WithSnapshotsAndEventSourcing(typeof(TGrain), id, (TState s) => state = s, e => + { + state = state.Apply(e); + + state.Version++; + }); + + await persistence.ReadAsync(); + await persistence.WriteSnapshotAsync(state); + } + catch (DomainObjectNotFoundException) + { + return; + } + }, + new ExecutionDataflowBlockOptions + { + MaxDegreeOfParallelism = Environment.ProcessorCount * 2 + }); + + var handledIds = new HashSet(); + + using (localCache.StartContext()) + { + await source(new Func(async id => + { + if (handledIds.Add(id)) + { + await worker.SendAsync(id, ct); + } + })); + + worker.Complete(); + + await worker.Completion; + } + } + } +} \ No newline at end of file diff --git a/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs b/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs index 347cc48ec..70242cdfd 100644 --- a/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs +++ b/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Net.Mail; using System.Threading.Tasks; @@ -12,6 +13,7 @@ using Microsoft.Extensions.Options; namespace Squidex.Infrastructure.Email { + [ExcludeFromCodeCoverage] public sealed class SmtpEmailSender : IEmailSender { private readonly SmtpClient smtpClient; diff --git a/backend/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs b/backend/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs index cb8ec5acc..4fa5bb2fe 100644 --- a/backend/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs +++ b/backend/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs @@ -6,10 +6,12 @@ // ========================================================================== using System; +using System.Diagnostics.CodeAnalysis; namespace Squidex.Infrastructure.Log.Internal { - public class AnsiLogConsole : IConsole + [ExcludeFromCodeCoverage] + public sealed class AnsiLogConsole : IConsole { private readonly bool logToStdError; diff --git a/backend/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs b/backend/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs index a18c4af1a..446ef8db9 100644 --- a/backend/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs +++ b/backend/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs @@ -8,11 +8,13 @@ using System; using System.Collections.Concurrent; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using System.Threading; namespace Squidex.Infrastructure.Log.Internal { + [ExcludeFromCodeCoverage] public sealed class ConsoleLogProcessor : DisposableObjectBase { private const int MaxQueuedMessages = 1024; diff --git a/backend/src/Squidex.Infrastructure/Log/Internal/FileLogProcessor.cs b/backend/src/Squidex.Infrastructure/Log/Internal/FileLogProcessor.cs index 6171731af..f14b8b070 100644 --- a/backend/src/Squidex.Infrastructure/Log/Internal/FileLogProcessor.cs +++ b/backend/src/Squidex.Infrastructure/Log/Internal/FileLogProcessor.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Concurrent; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Text; using System.Threading; @@ -15,7 +16,8 @@ using NodaTime; namespace Squidex.Infrastructure.Log.Internal { - public class FileLogProcessor : DisposableObjectBase + [ExcludeFromCodeCoverage] + public sealed class FileLogProcessor : DisposableObjectBase { private const int MaxQueuedMessages = 1024; private const int Retries = 10; diff --git a/backend/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs b/backend/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs index 3a0b136f6..6f56cf1a6 100644 --- a/backend/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs +++ b/backend/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs @@ -6,10 +6,12 @@ // ========================================================================== using System; +using System.Diagnostics.CodeAnalysis; namespace Squidex.Infrastructure.Log.Internal { - public class WindowsLogConsole : IConsole + [ExcludeFromCodeCoverage] + public sealed class WindowsLogConsole : IConsole { private readonly bool logToStdError; diff --git a/backend/src/Squidex.Infrastructure/Plugins/PluginLoaders.cs b/backend/src/Squidex.Infrastructure/Plugins/PluginLoaders.cs index d988b42e0..cc39ca3b7 100644 --- a/backend/src/Squidex.Infrastructure/Plugins/PluginLoaders.cs +++ b/backend/src/Squidex.Infrastructure/Plugins/PluginLoaders.cs @@ -7,12 +7,14 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Reflection; using McMaster.NETCore.Plugins; namespace Squidex.Infrastructure.Plugins { + [ExcludeFromCodeCoverage] public static class PluginLoaders { public static PluginLoader? LoadPlugin(string pluginPath, AssemblyName[] sharedAssemblies) diff --git a/backend/src/Squidex.Infrastructure/Translations/DeepLTranslator.cs b/backend/src/Squidex.Infrastructure/Translations/DeepLTranslator.cs index 6c7af39f7..8428a1587 100644 --- a/backend/src/Squidex.Infrastructure/Translations/DeepLTranslator.cs +++ b/backend/src/Squidex.Infrastructure/Translations/DeepLTranslator.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Net.Http; using System.Threading; @@ -17,6 +18,7 @@ using Squidex.Infrastructure.Json; namespace Squidex.Infrastructure.Translations { + [ExcludeFromCodeCoverage] public sealed class DeepLTranslator : ITranslator { private const string Url = "https://api.deepl.com/v2/translate"; diff --git a/backend/src/Squidex.Shared/Users/IUserResolver.cs b/backend/src/Squidex.Shared/Users/IUserResolver.cs index 429930038..c519ef44c 100644 --- a/backend/src/Squidex.Shared/Users/IUserResolver.cs +++ b/backend/src/Squidex.Shared/Users/IUserResolver.cs @@ -12,7 +12,7 @@ namespace Squidex.Shared.Users { public interface IUserResolver { - Task CreateUserIfNotExists(string email, bool invited = false); + Task CreateUserIfNotExistsAsync(string email, bool invited = false); Task FindByIdOrEmailAsync(string idOrEmail); diff --git a/backend/src/Squidex.Web/Services/UrlGenerator.cs b/backend/src/Squidex.Web/Services/UrlGenerator.cs index 82f6d7a2b..ec75ee008 100644 --- a/backend/src/Squidex.Web/Services/UrlGenerator.cs +++ b/backend/src/Squidex.Web/Services/UrlGenerator.cs @@ -16,20 +16,19 @@ using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents.GraphQL; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; -using Squidex.Infrastructure.Assets; namespace Squidex.Web.Services { public sealed class UrlGenerator : IGraphQLUrlGenerator, IRuleUrlGenerator, IAssetUrlGenerator, IEmailUrlGenerator { - private readonly IAssetStore assetStore; + private readonly IAssetFileStore assetFileStore; private readonly UrlsOptions urlsOptions; public bool CanGenerateAssetSourceUrl { get; } - public UrlGenerator(IOptions urlsOptions, IAssetStore assetStore, bool allowAssetSourceUrl) + public UrlGenerator(IOptions urlsOptions, IAssetFileStore assetFileStore, bool allowAssetSourceUrl) { - this.assetStore = assetStore; + this.assetFileStore = assetFileStore; this.urlsOptions = urlsOptions.Value; CanGenerateAssetSourceUrl = allowAssetSourceUrl; @@ -70,9 +69,9 @@ namespace Squidex.Web.Services return urlsOptions.BuildUrl("app/"); } - public string? GenerateAssetSourceUrl(IAppEntity app, IAssetEntity asset) + public string? GenerateAssetSourceUrl(IAssetEntity asset) { - return assetStore.GeneratePublicUrl(asset.Id.ToString(), asset.FileVersion, null); + return assetFileStore.GeneratePublicUrl(asset.Id, asset.FileVersion); } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 51e24437e..49c9ebb52 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -36,18 +36,21 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiExplorerSettings(GroupName = nameof(Apps))] public sealed class AppsController : ApiController { + private readonly IAppImageStore appImageStore; private readonly IAssetStore assetStore; private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IAppProvider appProvider; private readonly IAppPlansProvider appPlansProvider; public AppsController(ICommandBus commandBus, + IAppImageStore appImageStore, IAssetStore assetStore, IAssetThumbnailGenerator assetThumbnailGenerator, IAppProvider appProvider, IAppPlansProvider appPlansProvider) : base(commandBus) { + this.appImageStore = appImageStore; this.assetStore = assetStore; this.assetThumbnailGenerator = assetThumbnailGenerator; this.appProvider = appProvider; @@ -179,12 +182,11 @@ namespace Squidex.Areas.Api.Controllers.Apps var handler = new Func(async bodyStream => { - var assetId = App.Id.ToString(); - var assetResizedId = $"{assetId}_{etag}_Resized"; + var resizedAsset = $"{App.Id}_{etag}_Resized"; try { - await assetStore.DownloadAsync(assetResizedId, bodyStream); + await assetStore.DownloadAsync(resizedAsset, bodyStream); } catch (AssetNotFoundException) { @@ -196,7 +198,7 @@ namespace Squidex.Areas.Api.Controllers.Apps { using (Profiler.Trace("ResizeDownload")) { - await assetStore.DownloadAsync(assetId, sourceStream); + await appImageStore.DownloadAsync(App.Id, sourceStream); sourceStream.Position = 0; } @@ -208,7 +210,7 @@ namespace Squidex.Areas.Api.Controllers.Apps using (Profiler.Trace("ResizeUpload")) { - await assetStore.UploadAsync(assetResizedId, destinationStream); + await assetStore.UploadAsync(resizedAsset, destinationStream); destinationStream.Position = 0; } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs index a7c5a4eec..eb55fb6f2 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs @@ -30,19 +30,22 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiExplorerSettings(GroupName = nameof(Assets))] public sealed class AssetContentController : ApiController { - private readonly IAssetStore assetStore; + private readonly IAssetFileStore assetFileStore; private readonly IAssetRepository assetRepository; + private readonly IAssetStore assetStore; private readonly IAssetThumbnailGenerator assetThumbnailGenerator; public AssetContentController( ICommandBus commandBus, - IAssetStore assetStore, + IAssetFileStore assetFileStore, IAssetRepository assetRepository, + IAssetStore assetStore, IAssetThumbnailGenerator assetThumbnailGenerator) : base(commandBus) { - this.assetStore = assetStore; + this.assetFileStore = assetFileStore; this.assetRepository = assetRepository; + this.assetStore = assetStore; this.assetThumbnailGenerator = assetThumbnailGenerator; } @@ -123,20 +126,18 @@ namespace Squidex.Areas.Api.Controllers.Assets var handler = new Func(async bodyStream => { - var assetId = asset.Id.ToString(); - if (asset.IsImage && query.ShouldResize()) { - var assetSuffix = $"{query.Width}_{query.Height}_{query.Mode}"; + var resizedAsset = $"{asset.Id}_{asset.FileVersion}_{query.Width}_{query.Height}_{query.Mode}"; if (query.Quality.HasValue) { - assetSuffix += $"_{query.Quality}"; + resizedAsset += $"_{query.Quality}"; } try { - await assetStore.DownloadAsync(assetId, fileVersion, assetSuffix, bodyStream); + await assetStore.DownloadAsync(resizedAsset, bodyStream); } catch (AssetNotFoundException) { @@ -148,7 +149,7 @@ namespace Squidex.Areas.Api.Controllers.Assets { using (Profiler.Trace("ResizeDownload")) { - await assetStore.DownloadAsync(assetId, fileVersion, null, sourceStream); + await assetFileStore.DownloadAsync(asset.Id, fileVersion, sourceStream); sourceStream.Position = 0; } @@ -160,7 +161,7 @@ namespace Squidex.Areas.Api.Controllers.Assets using (Profiler.Trace("ResizeUpload")) { - await assetStore.UploadAsync(assetId, fileVersion, assetSuffix, destinationStream); + await assetStore.UploadAsync(resizedAsset, destinationStream); destinationStream.Position = 0; } @@ -172,7 +173,7 @@ namespace Squidex.Areas.Api.Controllers.Assets } else { - await assetStore.DownloadAsync(assetId, fileVersion, null, bodyStream); + await assetFileStore.DownloadAsync(asset.Id, fileVersion, bodyStream); } }); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs b/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs index 6c8eeba2f..ef1193462 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs @@ -9,9 +9,7 @@ using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Orleans; using Squidex.Domain.Apps.Entities.Backup; -using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Commands; using Squidex.Web; @@ -23,14 +21,16 @@ namespace Squidex.Areas.Api.Controllers.Backups [ApiExplorerSettings(GroupName = nameof(Backups))] public class BackupContentController : ApiController { - private readonly IAssetStore assetStore; - private readonly IGrainFactory grainFactory; + private readonly IBackupArchiveStore backupArchiveStore; + private readonly IBackupService backupservice; - public BackupContentController(ICommandBus commandBus, IAssetStore assetStore, IGrainFactory grainFactory) + public BackupContentController(ICommandBus commandBus, + IBackupArchiveStore backupArchiveStore, + IBackupService backupservice) : base(commandBus) { - this.assetStore = assetStore; - this.grainFactory = grainFactory; + this.backupArchiveStore = backupArchiveStore; + this.backupservice = backupservice; } /// @@ -50,10 +50,7 @@ namespace Squidex.Areas.Api.Controllers.Backups [AllowAnonymous] public async Task GetBackupContent(string app, Guid id) { - var backupGrain = grainFactory.GetGrain(AppId); - - var backups = await backupGrain.GetStateAsync(); - var backup = backups.Value.Find(x => x.Id == id); + var backup = await backupservice.GetBackupAsync(AppId, id); if (backup == null || backup.Status != JobStatus.Completed) { @@ -64,7 +61,7 @@ namespace Squidex.Areas.Api.Controllers.Backups return new FileCallbackResult("application/zip", fileName, false, bodyStream => { - return assetStore.DownloadAsync(id.ToString(), 0, null, bodyStream); + return backupArchiveStore.DownloadAsync(id, bodyStream); }); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs index e692bcfba..46734be7a 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs @@ -9,10 +9,10 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Orleans; using Squidex.Areas.Api.Controllers.Backups.Models; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Tasks; using Squidex.Shared; using Squidex.Web; @@ -25,12 +25,12 @@ namespace Squidex.Areas.Api.Controllers.Backups [ApiExplorerSettings(GroupName = nameof(Backups))] public class BackupsController : ApiController { - private readonly IGrainFactory grainFactory; + private readonly IBackupService backupService; - public BackupsController(ICommandBus commandBus, IGrainFactory grainFactory) + public BackupsController(ICommandBus commandBus, IBackupService backupService) : base(commandBus) { - this.grainFactory = grainFactory; + this.backupService = backupService; } /// @@ -48,11 +48,9 @@ namespace Squidex.Areas.Api.Controllers.Backups [ApiCosts(0)] public async Task GetBackups(string app) { - var backupGrain = grainFactory.GetGrain(AppId); + var jobs = await backupService.GetBackupsAsync(AppId); - var jobs = await backupGrain.GetStateAsync(); - - var response = BackupJobsDto.FromBackups(jobs.Value, this, app); + var response = BackupJobsDto.FromBackups(jobs, this, app); return Ok(response); } @@ -72,9 +70,7 @@ namespace Squidex.Areas.Api.Controllers.Backups [ApiCosts(0)] public IActionResult PostBackup(string app) { - var backupGrain = grainFactory.GetGrain(AppId); - - backupGrain.RunAsync().Forget(); + backupService.StartBackupAsync(App.Id, User.Token()!).Forget(); return NoContent(); } @@ -95,9 +91,7 @@ namespace Squidex.Areas.Api.Controllers.Backups [ApiCosts(0)] public async Task DeleteBackup(string app, Guid id) { - var backupGrain = grainFactory.GetGrain(AppId); - - await backupGrain.DeleteAsync(id); + await backupService.DeleteBackupAsync(AppId, id); return NoContent(); } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs b/backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs index 43e621981..5f71b3125 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs @@ -7,11 +7,9 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Orleans; using Squidex.Areas.Api.Controllers.Backups.Models; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Security; using Squidex.Shared; using Squidex.Web; @@ -24,12 +22,12 @@ namespace Squidex.Areas.Api.Controllers.Backups [ApiExplorerSettings(GroupName = nameof(Backups))] public class RestoreController : ApiController { - private readonly IGrainFactory grainFactory; + private readonly IBackupService backupService; - public RestoreController(ICommandBus commandBus, IGrainFactory grainFactory) + public RestoreController(ICommandBus commandBus, IBackupService backupService) : base(commandBus) { - this.grainFactory = grainFactory; + this.backupService = backupService; } /// @@ -44,16 +42,14 @@ namespace Squidex.Areas.Api.Controllers.Backups [ApiPermission(Permissions.AdminRestore)] public async Task GetRestoreJob() { - var restoreGrain = grainFactory.GetGrain(SingleGrain.Id); + var job = await backupService.GetRestoreAsync(); - var job = await restoreGrain.GetJobAsync(); - - if (job.Value == null) + if (job == null) { return NotFound(); } - var response = RestoreJobDto.FromJob(job.Value); + var response = RestoreJobDto.FromJob(job); return Ok(response); } @@ -70,9 +66,7 @@ namespace Squidex.Areas.Api.Controllers.Backups [ApiPermission(Permissions.AdminRestore)] public async Task PostRestoreJob([FromBody] RestoreRequestDto request) { - var restoreGrain = grainFactory.GetGrain(SingleGrain.Id); - - await restoreGrain.RestoreAsync(request.Url, User.Token()!, request.Name); + await backupService.StartRestoreAsync(User.Token()!, request.Url, request.Name); return NoContent(); } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs b/backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs index 64ab97444..0b8b9c778 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs @@ -164,7 +164,17 @@ namespace Squidex.Areas.Api.Controllers.Users { if (entity.IsPictureUrlStored()) { - return new FileStreamResult(await userPictureStore.DownloadAsync(entity.Id), "image/png"); + return new FileCallbackResult("image/png", null, false, async stream => + { + try + { + await userPictureStore.DownloadAsync(entity.Id, stream); + } + catch + { + await stream.WriteAsync(AvatarBytes); + } + }); } using (var client = new HttpClient()) diff --git a/backend/src/Squidex/Config/Authentication/IdentityServices.cs b/backend/src/Squidex/Config/Authentication/IdentityServices.cs index 43ad4a929..1b57970f4 100644 --- a/backend/src/Squidex/Config/Authentication/IdentityServices.cs +++ b/backend/src/Squidex/Config/Authentication/IdentityServices.cs @@ -22,7 +22,7 @@ namespace Squidex.Config.Authentication services.AddSingletonAs() .AsOptional(); - services.AddSingletonAs() + services.AddSingletonAs() .AsOptional(); } } diff --git a/backend/src/Squidex/Config/Domain/AppsServices.cs b/backend/src/Squidex/Config/Domain/AppsServices.cs index e7e7a6001..af5571e76 100644 --- a/backend/src/Squidex/Config/Domain/AppsServices.cs +++ b/backend/src/Squidex/Config/Domain/AppsServices.cs @@ -26,6 +26,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); diff --git a/backend/src/Squidex/Config/Domain/AssetServices.cs b/backend/src/Squidex/Config/Domain/AssetServices.cs index 8d05b8b67..5054c6103 100644 --- a/backend/src/Squidex/Config/Domain/AssetServices.cs +++ b/backend/src/Squidex/Config/Domain/AssetServices.cs @@ -33,6 +33,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .AsSelf(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); diff --git a/backend/src/Squidex/Config/Domain/BackupsServices.cs b/backend/src/Squidex/Config/Domain/BackupsServices.cs index fe2edb927..31b650c2c 100644 --- a/backend/src/Squidex/Config/Domain/BackupsServices.cs +++ b/backend/src/Squidex/Config/Domain/BackupsServices.cs @@ -22,20 +22,26 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + + services.AddTransientAs() + .As(); + services.AddTransientAs() - .As(); + .As(); services.AddTransientAs() - .As(); + .As(); services.AddTransientAs() - .As(); + .As(); services.AddTransientAs() - .As(); + .As(); services.AddTransientAs() - .As(); + .As(); } } } \ No newline at end of file diff --git a/backend/src/Squidex/Config/Domain/EventSourcingServices.cs b/backend/src/Squidex/Config/Domain/EventSourcingServices.cs index d35566e93..6e60a860e 100644 --- a/backend/src/Squidex/Config/Domain/EventSourcingServices.cs +++ b/backend/src/Squidex/Config/Domain/EventSourcingServices.cs @@ -14,6 +14,7 @@ using Microsoft.Extensions.DependencyInjection; using MongoDB.Driver; using Newtonsoft.Json; using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Diagnostics; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing.Grains; @@ -85,6 +86,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddTransientAs() + .AsSelf(); + services.AddSingletonAs() .As(); diff --git a/backend/src/Squidex/Config/Domain/MigrationServices.cs b/backend/src/Squidex/Config/Domain/MigrationServices.cs index e70b38dcf..ae1b917d0 100644 --- a/backend/src/Squidex/Config/Domain/MigrationServices.cs +++ b/backend/src/Squidex/Config/Domain/MigrationServices.cs @@ -23,9 +23,6 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .AsSelf(); - services.AddTransientAs() - .AsSelf(); - services.AddTransientAs() .AsSelf(); diff --git a/backend/src/Squidex/Config/Domain/QueryServices.cs b/backend/src/Squidex/Config/Domain/QueryServices.cs index f0c904be2..e43997fd6 100644 --- a/backend/src/Squidex/Config/Domain/QueryServices.cs +++ b/backend/src/Squidex/Config/Domain/QueryServices.cs @@ -13,8 +13,8 @@ using Microsoft.Extensions.Options; using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Entities; +using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Contents.GraphQL; -using Squidex.Infrastructure.Assets; using Squidex.Web; using Squidex.Web.Services; @@ -28,7 +28,7 @@ namespace Squidex.Config.Domain services.AddSingletonAs(c => new UrlGenerator( c.GetRequiredService>(), - c.GetRequiredService(), + c.GetRequiredService(), exposeSourceUrl)) .As().As().As().As(); diff --git a/backend/tests/RunCoverage.ps1 b/backend/tests/RunCoverage.ps1 index 88f88cb94..0334f8172 100644 --- a/backend/tests/RunCoverage.ps1 +++ b/backend/tests/RunCoverage.ps1 @@ -26,7 +26,8 @@ if ($all -Or $infrastructure) { -register:user ` -target:"C:\Program Files\dotnet\dotnet.exe" ` -targetargs:"test --filter Category!=Dependencies $folderWorking\Squidex.Infrastructure.Tests\Squidex.Infrastructure.Tests.csproj" ` - -filter:"+[Squidex.*]* -[Squidex.*]*CodeGen*" ` + -filter:"+[Squidex.*]* -[*.Tests]* -[Squidex.*]*CodeGen*" ` + -excludebyattribute:*.ExcludeFromCodeCoverage* ` -skipautoprops ` -output:"$folderWorking\$folderReports\Infrastructure.xml" ` -oldStyle @@ -37,7 +38,8 @@ if ($all -Or $appsCore) { -register:user ` -target:"C:\Program Files\dotnet\dotnet.exe" ` -targetargs:"test $folderWorking\Squidex.Domain.Apps.Core.Tests\Squidex.Domain.Apps.Core.Tests.csproj" ` - -filter:"+[Squidex.*]* -[Squidex.*]*CodeGen*" ` + -filter:"+[Squidex.*]* -[*.Tests]* -[Squidex.*]*CodeGen*" ` + -excludebyattribute:*.ExcludeFromCodeCoverage* ` -skipautoprops ` -output:"$folderWorking\$folderReports\Core.xml" ` -oldStyle @@ -48,7 +50,8 @@ if ($all -Or $appsEntities) { -register:user ` -target:"C:\Program Files\dotnet\dotnet.exe" ` -targetargs:"test $folderWorking\Squidex.Domain.Apps.Entities.Tests\Squidex.Domain.Apps.Entities.Tests.csproj" ` - -filter:"+[Squidex.*]* -[Squidex.*]*CodeGen*" ` + -filter:"+[Squidex.*]* -[*.Tests]* -[Squidex.*]*CodeGen*" ` + -excludebyattribute:*.ExcludeFromCodeCoverage* ` -skipautoprops ` -output:"$folderWorking\$folderReports\Entities.xml" ` -oldStyle @@ -59,7 +62,8 @@ if ($all -Or $users) { -register:user ` -target:"C:\Program Files\dotnet\dotnet.exe" ` -targetargs:"test $folderWorking\Squidex.Domain.Users.Tests\Squidex.Domain.Users.Tests.csproj" ` - -filter:"+[Squidex.*]* -[Squidex.*]*CodeGen*" ` + -filter:"+[Squidex.*]* -[*.Tests]* -[Squidex.*]*CodeGen*" ` + -excludebyattribute:*.ExcludeFromCodeCoverage* ` -skipautoprops ` -output:"$folderWorking\$folderReports\Users.xml" ` -oldStyle @@ -70,7 +74,8 @@ if ($all -Or $web) { -register:user ` -target:"C:\Program Files\dotnet\dotnet.exe" ` -targetargs:"test $folderWorking\Squidex.Web.Tests\Squidex.Web.Tests.csproj" ` - -filter:"+[Squidex.*]* -[Squidex.*]*CodeGen*" ` + -filter:"+[Squidex.*]* -[*.Tests]* -[Squidex.*]*CodeGen*" ` + -excludebyattribute:*.ExcludeFromCodeCoverage* ` -skipautoprops ` -output:"$folderWorking\$folderReports\Web.xml" ` -oldStyle diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs index 977e0fde9..72fe6b857 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs @@ -26,7 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Apps public class AppCommandMiddlewareTests : HandlerTestBase { private readonly IContextProvider contextProvider = A.Fake(); - private readonly IAssetStore assetStore = A.Fake(); + private readonly IAppImageStore appImageStore = A.Fake(); private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake(); private readonly Guid appId = Guid.NewGuid(); private readonly Context requestContext = Context.Anonymous(); @@ -46,7 +46,7 @@ namespace Squidex.Domain.Apps.Entities.Apps A.CallTo(() => contextProvider.Context) .Returns(requestContext); - sut = new AppCommandMiddleware(A.Fake(), assetStore, assetThumbnailGenerator, contextProvider); + sut = new AppCommandMiddleware(A.Fake(), appImageStore, assetThumbnailGenerator, contextProvider); } [Fact] @@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Apps await sut.HandleAsync(context); - A.CallTo(() => assetStore.UploadAsync(appId.ToString(), stream, true, A.Ignored)) + A.CallTo(() => appImageStore.UploadAsync(appId, stream, A.Ignored)) .MustHaveHappened(); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/BackupAppsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/BackupAppsTests.cs new file mode 100644 index 000000000..15f2773d2 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/BackupAppsTests.cs @@ -0,0 +1,369 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Entities.Apps.Indexes; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Assets; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Json.Objects; +using Xunit; + +#pragma warning disable IDE0067 // Dispose objects before losing scope + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public class BackupAppsTests + { + private readonly IAppsIndex index = A.Fake(); + private readonly IAppUISettings appUISettings = A.Fake(); + private readonly IAppImageStore appImageStore = A.Fake(); + private readonly Guid appId = Guid.NewGuid(); + private readonly RefToken actor = new RefToken(RefTokenType.Subject, "123"); + private readonly BackupApps sut; + + public BackupAppsTests() + { + sut = new BackupApps(appImageStore, index, appUISettings); + } + + [Fact] + public void Should_provide_name() + { + Assert.Equal("Apps", sut.Name); + } + + [Fact] + public async Task Should_reserve_app_name() + { + const string appName = "my-app"; + + var context = CreateRestoreContext(); + + A.CallTo(() => index.ReserveAsync(appId, appName)) + .Returns("Reservation"); + + await sut.RestoreEventAsync(Envelope.Create(new AppCreated + { + Name = appName + }), context); + + A.CallTo(() => index.ReserveAsync(appId, appName)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_complete_reservation_with_previous_token() + { + const string appName = "my-app"; + + var context = CreateRestoreContext(); + + A.CallTo(() => index.ReserveAsync(appId, appName)) + .Returns("Reservation"); + + await sut.RestoreEventAsync(Envelope.Create(new AppCreated + { + Name = appName + }), context); + + await sut.CompleteRestoreAsync(context); + + A.CallTo(() => index.AddAsync("Reservation")) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_cleanup_reservation_with_previous_token() + { + const string appName = "my-app"; + + var context = CreateRestoreContext(); + + A.CallTo(() => index.ReserveAsync(appId, appName)) + .Returns("Reservation"); + + await sut.RestoreEventAsync(Envelope.Create(new AppCreated + { + Name = appName + }), context); + + await sut.CleanupRestoreErrorAsync(appId); + + A.CallTo(() => index.RemoveReservationAsync("Reservation")) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_throw_exception_when_no_reservation_token_returned() + { + const string appName = "my-app"; + + var context = CreateRestoreContext(); + + A.CallTo(() => index.ReserveAsync(appId, appName)) + .Returns(Task.FromResult(null)); + + await Assert.ThrowsAsync(() => + { + return sut.RestoreEventAsync(Envelope.Create(new AppCreated + { + Name = appName + }), context); + }); + } + + [Fact] + public async Task Should_not_cleanup_reservation_when_no_reservation_token_hold() + { + var context = CreateRestoreContext(); + + await sut.CleanupRestoreErrorAsync(appId); + + A.CallTo(() => index.RemoveReservationAsync("Reservation")) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_writer_user_settings() + { + var settings = JsonValue.Object(); + + var context = CreateBackupContext(); + + A.CallTo(() => appUISettings.GetAsync(appId, null)) + .Returns(settings); + + await sut.BackupAsync(context); + + A.CallTo(() => context.Writer.WriteJsonAsync(A.Ignored, settings)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_read_user_settings() + { + var settings = JsonValue.Object(); + + var context = CreateRestoreContext(); + + A.CallTo(() => context.Reader.ReadJsonAttachmentAsync(A.Ignored)) + .Returns(settings); + + await sut.RestoreAsync(context); + + A.CallTo(() => appUISettings.SetAsync(appId, null, settings)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_map_contributor_id_when_assigned() + { + var context = CreateRestoreContext(); + + var @event = Envelope.Create(new AppContributorAssigned + { + ContributorId = "found" + }); + + var result = await sut.RestoreEventAsync(@event, context); + + Assert.True(result); + Assert.Equal("found_mapped", @event.Payload.ContributorId); + } + + [Fact] + public async Task Should_ignore_contributor_event_when_assigned_user_not_mapped() + { + var context = CreateRestoreContext(); + + var @event = Envelope.Create(new AppContributorAssigned + { + ContributorId = "unknown" + }); + + var result = await sut.RestoreEventAsync(@event, context); + + Assert.False(result); + Assert.Equal("unknown", @event.Payload.ContributorId); + } + + [Fact] + public async Task Should_map_contributor_id_when_revoked() + { + var context = CreateRestoreContext(); + + var @event = Envelope.Create(new AppContributorRemoved + { + ContributorId = "found" + }); + + var result = await sut.RestoreEventAsync(@event, context); + + Assert.True(result); + Assert.Equal("found_mapped", @event.Payload.ContributorId); + } + + [Fact] + public async Task Should_ignore_contributor_event_when_removed_user_not_mapped() + { + var context = CreateRestoreContext(); + + var @event = Envelope.Create(new AppContributorRemoved + { + ContributorId = "unknown" + }); + + var result = await sut.RestoreEventAsync(@event, context); + + Assert.False(result); + Assert.Equal("unknown", @event.Payload.ContributorId); + } + + [Fact] + public async Task Should_ignore_exception_when_app_image_to_backup_does_not_exist() + { + var imageStream = new MemoryStream(); + + var context = CreateBackupContext(); + + A.CallTo(() => context.Writer.WriteBlobAsync(A.Ignored, A>.Ignored)) + .Invokes((string _, Func handler) => handler(imageStream)); + + A.CallTo(() => appImageStore.DownloadAsync(appId, imageStream, default)) + .Throws(new AssetNotFoundException("Image")); + + await sut.BackupEventAsync(Envelope.Create(new AppImageUploaded()), context); + } + + [Fact] + public async Task Should_backup_app_image() + { + var imageStream = new MemoryStream(); + + var context = CreateBackupContext(); + + A.CallTo(() => context.Writer.WriteBlobAsync(A.Ignored, A>.Ignored)) + .Invokes((string _, Func handler) => handler(imageStream)); + + await sut.BackupEventAsync(Envelope.Create(new AppImageUploaded()), context); + + A.CallTo(() => appImageStore.DownloadAsync(appId, imageStream, default)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_restore_app_image() + { + var imageStream = new MemoryStream(); + + var context = CreateRestoreContext(); + + A.CallTo(() => context.Reader.ReadBlobAsync(A.Ignored, A>.Ignored)) + .Invokes((string _, Func handler) => handler(imageStream)); + + await sut.RestoreEventAsync(Envelope.Create(new AppImageUploaded()), context); + + A.CallTo(() => appImageStore.UploadAsync(appId, imageStream, default)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_ignore_exception_when_app_image_cannot_be_overriden() + { + var imageStream = new MemoryStream(); + + var context = CreateRestoreContext(); + + A.CallTo(() => context.Reader.ReadBlobAsync(A.Ignored, A>.Ignored)) + .Invokes((string _, Func handler) => handler(imageStream)); + + A.CallTo(() => appImageStore.UploadAsync(appId, imageStream, default)) + .Throws(new AssetAlreadyExistsException("Image")); + + await sut.RestoreEventAsync(Envelope.Create(new AppImageUploaded()), context); + } + + [Fact] + public async Task Should_restore_indices_for_all_non_deleted_schemas() + { + var userId1 = "found1"; + var userId2 = "found2"; + var userId3 = "found3"; + var context = CreateRestoreContext(); + + await sut.RestoreEventAsync(Envelope.Create(new AppContributorAssigned + { + ContributorId = userId1 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new AppContributorAssigned + { + ContributorId = userId2 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new AppContributorAssigned + { + ContributorId = userId3 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new AppContributorRemoved + { + ContributorId = userId3 + }), context); + + HashSet? newIndex = null; + + A.CallTo(() => index.RebuildByContributorsAsync(appId, A>.Ignored)) + .Invokes(new Action>((_, i) => newIndex = i)); + + await sut.CompleteRestoreAsync(context); + + Assert.Equal(new HashSet + { + "found1_mapped", + "found2_mapped", + }, newIndex); + } + + private BackupContext CreateBackupContext() + { + return new BackupContext(appId, CreateUserMapping(), A.Fake()); + } + + private RestoreContext CreateRestoreContext() + { + return new RestoreContext(appId, CreateUserMapping(), A.Fake()); + } + + private IUserMapping CreateUserMapping() + { + var mapping = A.Fake(); + + A.CallTo(() => mapping.Initiator).Returns(actor); + + RefToken mapped; + + A.CallTo(() => mapping.TryMap(A.That.Matches(x => x.StartsWith("found", StringComparison.OrdinalIgnoreCase)), out mapped)) + .Returns(true) + .AssignsOutAndRefParametersLazily( + new Func((x, _) => + new[] { new RefToken(RefTokenType.Subject, $"{x}_mapped") })); + + A.CallTo(() => mapping.TryMap("notfound", out mapped)) + .Returns(false); + + return mapping; + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DefaultAppImageStoreTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DefaultAppImageStoreTests.cs new file mode 100644 index 000000000..161dcd6ee --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DefaultAppImageStoreTests.cs @@ -0,0 +1,54 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Infrastructure.Assets; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public class DefaultAppImageStoreTests + { + private readonly IAssetStore assetStore = A.Fake(); + private readonly Guid appId = Guid.NewGuid(); + private readonly string fileName; + private readonly DefaultAppImageStore sut; + + public DefaultAppImageStoreTests() + { + fileName = appId.ToString(); + + sut = new DefaultAppImageStore(assetStore); + } + + [Fact] + public async Task Should_invoke_asset_store_to_upload_archive() + { + var stream = new MemoryStream(); + + await sut.UploadAsync(appId, stream); + + A.CallTo(() => assetStore.UploadAsync(fileName, stream, true, CancellationToken.None)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_invoke_asset_store_to_download_archive() + { + var stream = new MemoryStream(); + + await sut.DownloadAsync(appId, stream); + + A.CallTo(() => assetStore.DownloadAsync(fileName, stream, CancellationToken.None)) + .MustHaveHappened(); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs index 3dd7d9ca3..f02f2b91c 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs @@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards [Fact] public async Task CanAssign_should_not_throw_exception_if_user_already_exists_with_some_role_but_is_from_restore() { - var command = new AssignContributor { ContributorId = "1", Role = Role.Owner, IsRestore = true }; + var command = new AssignContributor { ContributorId = "1", Role = Role.Owner, Restoring = true }; var contributors_1 = contributors_0.Assign("1", Role.Owner); @@ -172,7 +172,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards A.CallTo(() => appPlan.MaxContributors) .Returns(2); - var command = new AssignContributor { ContributorId = "3", IsRestore = true }; + var command = new AssignContributor { ContributorId = "3", Restoring = true }; var contributors_1 = contributors_0.Assign("1", Role.Editor); var contributors_2 = contributors_1.Assign("2", Role.Editor); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs index 9e808a16d..447f0a013 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs @@ -36,14 +36,14 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation new CommandContext(new AssignContributor { ContributorId = "me@email.com", Invite = true }, commandBus) .Complete(app); - A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true)) + A.CallTo(() => userResolver.CreateUserIfNotExistsAsync("me@email.com", true)) .Returns(true); await sut.HandleAsync(context); Assert.Same(context.Result().App, app); - A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true)) + A.CallTo(() => userResolver.CreateUserIfNotExistsAsync("me@email.com", true)) .MustHaveHappened(); } @@ -54,14 +54,14 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation new CommandContext(new AssignContributor { ContributorId = "me@email.com", Invite = true }, commandBus) .Complete(app); - A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true)) + A.CallTo(() => userResolver.CreateUserIfNotExistsAsync("me@email.com", true)) .Returns(false); await sut.HandleAsync(context); Assert.Same(context.Result(), app); - A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true)) + A.CallTo(() => userResolver.CreateUserIfNotExistsAsync("me@email.com", true)) .MustHaveHappened(); } @@ -74,7 +74,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation await sut.HandleAsync(context); - A.CallTo(() => userResolver.CreateUserIfNotExists(A.Ignored, A.Ignored)) + A.CallTo(() => userResolver.CreateUserIfNotExistsAsync(A.Ignored, A.Ignored)) .MustNotHaveHappened(); } @@ -87,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation await sut.HandleAsync(context); - A.CallTo(() => userResolver.CreateUserIfNotExists(A.Ignored, A.Ignored)) + A.CallTo(() => userResolver.CreateUserIfNotExistsAsync(A.Ignored, A.Ignored)) .MustNotHaveHappened(); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs index 38caa4e0e..acf5de44d 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs @@ -29,14 +29,14 @@ namespace Squidex.Domain.Apps.Entities.Assets { public class AssetCommandMiddlewareTests : HandlerTestBase { - private readonly IAssetQueryService assetQuery = A.Fake(); private readonly IAssetEnricher assetEnricher = A.Fake(); + private readonly IAssetFileStore assetFileStore = A.Fake(); + private readonly IAssetQueryService assetQuery = A.Fake(); private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake(); - private readonly IAssetStore assetStore = A.Fake(); private readonly IContextProvider contextProvider = A.Fake(); - private readonly ITagService tagService = A.Fake(); - private readonly ITagGenerator tagGenerator = A.Fake>(); private readonly IGrainFactory grainFactory = A.Fake(); + private readonly ITagGenerator tagGenerator = A.Fake>(); + private readonly ITagService tagService = A.Fake(); private readonly Guid assetId = Guid.NewGuid(); private readonly Stream stream = new MemoryStream(); private readonly ImageInfo image = new ImageInfo(2048, 2048); @@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Assets sut = new AssetCommandMiddleware(grainFactory, assetEnricher, assetQuery, - assetStore, + assetFileStore, assetThumbnailGenerator, contextProvider, new[] { tagGenerator }); } @@ -147,6 +147,9 @@ namespace Squidex.Domain.Apps.Entities.Assets var result = context.Result(); result.Asset.Should().BeEquivalentTo(asset.Snapshot, x => x.ExcludingMissingMembers()); + + AssertAssetHasBeenUploaded(0); + AssertAssetImageChecked(); } [Fact] @@ -230,7 +233,7 @@ namespace Squidex.Domain.Apps.Entities.Assets await sut.HandleAsync(context); - AssertAssetHasBeenUploaded(1, context.ContextId); + AssertAssetHasBeenUploaded(1); AssertAssetImageChecked(); } @@ -282,15 +285,13 @@ namespace Squidex.Domain.Apps.Entities.Assets return asset.ExecuteAsync(CreateCommand(new CreateAsset { AssetId = Id, File = file })); } - private void AssertAssetHasBeenUploaded(long version, Guid commitId) + private void AssertAssetHasBeenUploaded(long version) { - var fileName = AssetStoreExtensions.GetFileName(assetId.ToString(), version); - - A.CallTo(() => assetStore.UploadAsync(commitId.ToString(), A.Ignored, false, CancellationToken.None)) + A.CallTo(() => assetFileStore.UploadAsync(A.Ignored, A.Ignored, CancellationToken.None)) .MustHaveHappened(); - A.CallTo(() => assetStore.CopyAsync(commitId.ToString(), fileName, CancellationToken.None)) + A.CallTo(() => assetFileStore.CopyAsync(A.Ignored, Id, version, CancellationToken.None)) .MustHaveHappened(); - A.CallTo(() => assetStore.DeleteAsync(commitId.ToString())) + A.CallTo(() => assetFileStore.DeleteAsync(A.Ignored)) .MustHaveHappened(); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/BackupAssetsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/BackupAssetsTests.cs new file mode 100644 index 000000000..498c3e5d3 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/BackupAssetsTests.cs @@ -0,0 +1,256 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Core.Tags; +using Squidex.Domain.Apps.Entities.Assets.State; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Tasks; +using Xunit; + +#pragma warning disable IDE0067 // Dispose objects before losing scope + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public class BackupAssetsTests + { + private readonly Rebuilder rebuilder = A.Fake(); + private readonly IAssetFileStore assetFileStore = A.Fake(); + private readonly ITagService tagService = A.Fake(); + private readonly Guid appId = Guid.NewGuid(); + private readonly RefToken actor = new RefToken(RefTokenType.Subject, "123"); + private readonly BackupAssets sut; + + public BackupAssetsTests() + { + sut = new BackupAssets(rebuilder, assetFileStore, tagService); + } + + [Fact] + public void Should_provide_name() + { + Assert.Equal("Assets", sut.Name); + } + + [Fact] + public async Task Should_writer_tags() + { + var tags = new TagsExport(); + + var context = CreateBackupContext(); + + A.CallTo(() => tagService.GetExportableTagsAsync(appId, TagGroups.Assets)) + .Returns(tags); + + await sut.BackupAsync(context); + + A.CallTo(() => context.Writer.WriteJsonAsync(A.Ignored, tags)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_read_tags() + { + var tags = new TagsExport(); + + var context = CreateRestoreContext(); + + A.CallTo(() => context.Reader.ReadJsonAttachmentAsync(A.Ignored)) + .Returns(tags); + + await sut.RestoreAsync(context); + + A.CallTo(() => tagService.RebuildTagsAsync(appId, TagGroups.Assets, tags)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_backup_created_asset() + { + var @event = new AssetCreated { AssetId = Guid.NewGuid() }; + + await TestBackupEventAsync(@event, 0); + } + + [Fact] + public async Task Should_backup_updated_asset() + { + var @event = new AssetUpdated { AssetId = Guid.NewGuid(), FileVersion = 3 }; + + await TestBackupEventAsync(@event, @event.FileVersion); + } + + private async Task TestBackupEventAsync(AssetEvent @event, long version) + { + var assetStream = new MemoryStream(); + var assetId = @event.AssetId; + + var context = CreateBackupContext(); + + A.CallTo(() => context.Writer.WriteBlobAsync($"{assetId}_{version}.asset", A>.Ignored)) + .Invokes((string _, Func handler) => handler(assetStream)); + + await sut.BackupEventAsync(Envelope.Create(@event), context); + + A.CallTo(() => assetFileStore.DownloadAsync(assetId, version, assetStream, default)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_restore_created_asset() + { + var @event = new AssetCreated { AssetId = Guid.NewGuid() }; + + await TestRestoreAsync(@event, 0); + } + + [Fact] + public async Task Should_restore_updated_asset() + { + var @event = new AssetUpdated { AssetId = Guid.NewGuid(), FileVersion = 3 }; + + await TestRestoreAsync(@event, @event.FileVersion); + } + + private async Task TestRestoreAsync(AssetEvent @event, long version) + { + var oldId = Guid.NewGuid(); + + var assetStream = new MemoryStream(); + var assetId = @event.AssetId; + + var context = CreateRestoreContext(); + + A.CallTo(() => context.Reader.OldGuid(assetId)) + .Returns(oldId); + + A.CallTo(() => context.Reader.ReadBlobAsync($"{oldId}_{version}.asset", A>.Ignored)) + .Invokes((string _, Func handler) => handler(assetStream)); + + await sut.RestoreEventAsync(Envelope.Create(@event), context); + + A.CallTo(() => assetFileStore.UploadAsync(assetId, version, assetStream, default)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_restore_states_for_all_assets() + { + var assetId1 = Guid.NewGuid(); + var assetId2 = Guid.NewGuid(); + + var context = CreateRestoreContext(); + + await sut.RestoreEventAsync(Envelope.Create(new AssetCreated + { + AssetId = assetId1 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new AssetCreated + { + AssetId = assetId2 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new AssetDeleted + { + AssetId = assetId2 + }), context); + + var rebuildAssets = new HashSet(); + + var add = new Func(id => + { + rebuildAssets.Add(id); + + return TaskHelper.Done; + }); + + A.CallTo(() => rebuilder.InsertManyAsync(A.Ignored, A.Ignored)) + .Invokes((IdSource source, CancellationToken _) => source(add)); + + await sut.RestoreAsync(context); + + Assert.Equal(new HashSet + { + assetId1, + assetId2 + }, rebuildAssets); + } + + [Fact] + public async Task Should_restore_states_for_all_asset_folders() + { + var assetFolderId1 = Guid.NewGuid(); + var assetFolderId2 = Guid.NewGuid(); + + var context = CreateRestoreContext(); + + await sut.RestoreEventAsync(Envelope.Create(new AssetFolderCreated + { + AssetFolderId = assetFolderId1 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new AssetFolderCreated + { + AssetFolderId = assetFolderId2 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new AssetFolderDeleted + { + AssetFolderId = assetFolderId2 + }), context); + + var rebuildAssets = new HashSet(); + + var add = new Func(id => + { + rebuildAssets.Add(id); + + return TaskHelper.Done; + }); + + A.CallTo(() => rebuilder.InsertManyAsync(A.Ignored, A.Ignored)) + .Invokes((IdSource source, CancellationToken _) => source(add)); + + await sut.RestoreAsync(context); + + Assert.Equal(new HashSet + { + assetFolderId1, + assetFolderId2 + }, rebuildAssets); + } + + private BackupContext CreateBackupContext() + { + return new BackupContext(appId, CreateUserMapping(), A.Fake()); + } + + private RestoreContext CreateRestoreContext() + { + return new RestoreContext(appId, CreateUserMapping(), A.Fake()); + } + + private IUserMapping CreateUserMapping() + { + var mapping = A.Fake(); + + A.CallTo(() => mapping.Initiator).Returns(actor); + + return mapping; + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DefaultAssetFileStoreTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DefaultAssetFileStoreTests.cs new file mode 100644 index 000000000..30355a38d --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DefaultAssetFileStoreTests.cs @@ -0,0 +1,106 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Infrastructure.Assets; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public class DefaultAssetFileStoreTests + { + private readonly IAssetStore assetStore = A.Fake(); + private readonly Guid assetId = Guid.NewGuid(); + private readonly long assetFileVersion = 21; + private readonly string fileName; + private readonly DefaultAssetFileStore sut; + + public DefaultAssetFileStoreTests() + { + fileName = $"{assetId}_{assetFileVersion}"; + + sut = new DefaultAssetFileStore(assetStore); + } + + [Fact] + public void Should_invoke_asset_store_to_generate_public_url() + { + var url = "http_//squidex.io/assets"; + + A.CallTo(() => assetStore.GeneratePublicUrl(fileName)) + .Returns(url); + + var result = sut.GeneratePublicUrl(assetId, assetFileVersion); + + Assert.Equal(url, result); + } + + [Fact] + public async Task Should_invoke_asset_store_to_temporary_upload_file() + { + var stream = new MemoryStream(); + + await sut.UploadAsync("Temp", stream); + + A.CallTo(() => assetStore.UploadAsync("Temp", stream, false, CancellationToken.None)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_invoke_asset_store_to_upload_file() + { + var stream = new MemoryStream(); + + await sut.UploadAsync(assetId, assetFileVersion, stream); + + A.CallTo(() => assetStore.UploadAsync(fileName, stream, true, CancellationToken.None)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_invoke_asset_store_to_download_file() + { + var stream = new MemoryStream(); + + await sut.DownloadAsync(assetId, assetFileVersion, stream); + + A.CallTo(() => assetStore.DownloadAsync(fileName, stream, CancellationToken.None)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_invoke_asset_store_to_copy_from_temporary_file() + { + await sut.CopyAsync("Temp", assetId, assetFileVersion); + + A.CallTo(() => assetStore.CopyAsync("Temp", fileName, CancellationToken.None)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_invoke_asset_store_to_delete_temporary_file() + { + await sut.DeleteAsync("Temp"); + + A.CallTo(() => assetStore.DeleteAsync("Temp")) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_invoke_asset_store_to_delete_file() + { + await sut.DeleteAsync(assetId, assetFileVersion); + + A.CallTo(() => assetStore.DeleteAsync(fileName)) + .MustHaveHappened(); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs index 8511174f9..8f8829212 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs @@ -34,6 +34,8 @@ namespace Squidex.Domain.Apps.Entities.Backup { public Guid GuidRaw { get; set; } + public Guid GuidEmpty { get; set; } + public NamedId GuidNamed { get; set; } public Dictionary Values { get; set; } @@ -158,14 +160,16 @@ namespace Squidex.Domain.Apps.Entities.Backup for (var i = 0; i < targetEvents.Count; i++) { - var source = targetEvents[i].Event.To(); + var target = targetEvents[i].Event.To(); + + var source = sourceEvents[i].Event.To(); - var target = sourceEvents[i].Event.To(); + CompareGuid(source.Payload.Values.First().Key, target.Payload.Values.First().Key); + CompareGuid(source.Payload.GuidRaw, target.Payload.GuidRaw); + CompareGuid(source.Payload.GuidNamed.Id, target.Payload.GuidNamed.Id); + CompareGuid(source.Headers.GetGuid("Id"), target.Headers.GetGuid("Id")); - CompareGuid(target.Payload.Values.First().Key, source.Payload.Values.First().Key); - CompareGuid(target.Payload.GuidRaw, source.Payload.GuidRaw); - CompareGuid(target.Payload.GuidNamed.Id, source.Payload.GuidNamed.Id); - CompareGuid(target.Headers.GetGuid("Id"), source.Headers.GetGuid("Id")); + Assert.Equal(Guid.Empty, target.Payload.GuidEmpty); } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupServiceTests.cs new file mode 100644 index 000000000..96faca043 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupServiceTests.cs @@ -0,0 +1,142 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FakeItEasy; +using Orleans; +using Squidex.Domain.Apps.Entities.Backup.State; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Orleans; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public class BackupServiceTests + { + private readonly IGrainFactory grainFactory = A.Fake(); + private readonly Guid appId = Guid.NewGuid(); + private readonly Guid backupId = Guid.NewGuid(); + private readonly RefToken actor = new RefToken(RefTokenType.Subject, "me"); + private readonly BackupService sut; + + public BackupServiceTests() + { + sut = new BackupService(grainFactory); + } + + [Fact] + public async Task Should_call_grain_when_restoring_backup() + { + var grain = A.Fake(); + + A.CallTo(() => grainFactory.GetGrain(SingleGrain.Id, null)) + .Returns(grain); + + var initiator = new RefToken(RefTokenType.Subject, "me"); + + var restoreUrl = new Uri("http://squidex.io"); + var restoreAppName = "New App"; + + await sut.StartRestoreAsync(initiator, restoreUrl, restoreAppName); + + A.CallTo(() => grain.RestoreAsync(restoreUrl, initiator, restoreAppName)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_call_grain_to_get_restore_status() + { + IRestoreJob state = new RestoreJob(); + + var grain = A.Fake(); + + A.CallTo(() => grainFactory.GetGrain(SingleGrain.Id, null)) + .Returns(grain); + + A.CallTo(() => grain.GetStateAsync()) + .Returns(state.AsJ()); + + var result = await sut.GetRestoreAsync(); + + Assert.Same(state, result); + } + + [Fact] + public async Task Should_call_grain_to_start_backup() + { + var grain = A.Fake(); + + A.CallTo(() => grainFactory.GetGrain(appId, null)) + .Returns(grain); + + await sut.StartBackupAsync(appId, actor); + + A.CallTo(() => grain.BackupAsync(actor)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_call_grain_to_get_backups() + { + var state = new List + { + new BackupJob { Id = backupId } + }; + + var grain = A.Fake(); + + A.CallTo(() => grainFactory.GetGrain(appId, null)) + .Returns(grain); + + A.CallTo(() => grain.GetStateAsync()) + .Returns(state.AsJ()); + + var result = await sut.GetBackupsAsync(appId); + + Assert.Same(state, result); + } + + [Fact] + public async Task Should_call_grain_to_get_backup() + { + var state = new List + { + new BackupJob { Id = backupId } + }; + + var grain = A.Fake(); + + A.CallTo(() => grainFactory.GetGrain(appId, null)) + .Returns(grain); + + A.CallTo(() => grain.GetStateAsync()) + .Returns(state.AsJ()); + + var result1 = await sut.GetBackupAsync(appId, backupId); + var result2 = await sut.GetBackupAsync(appId, Guid.NewGuid()); + + Assert.Same(state[0], result1); + Assert.Null(result2); + } + + [Fact] + public async Task Should_call_grain_to_delete_backup() + { + var grain = A.Fake(); + + A.CallTo(() => grainFactory.GetGrain(appId, null)) + .Returns(grain); + + await sut.DeleteBackupAsync(appId, backupId); + + A.CallTo(() => grain.DeleteAsync(backupId)) + .MustHaveHappened(); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/DefaultBackupArchiveStoreTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/DefaultBackupArchiveStoreTests.cs new file mode 100644 index 000000000..e07c96337 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/DefaultBackupArchiveStoreTests.cs @@ -0,0 +1,63 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Infrastructure.Assets; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public class DefaultBackupArchiveStoreTests + { + private readonly IAssetStore assetStore = A.Fake(); + private readonly Guid backupId = Guid.NewGuid(); + private readonly string fileName; + private readonly DefaultBackupArchiveStore sut; + + public DefaultBackupArchiveStoreTests() + { + fileName = $"{backupId}_0"; + + sut = new DefaultBackupArchiveStore(assetStore); + } + + [Fact] + public async Task Should_invoke_asset_store_to_upload_archive_using_suffix_for_compatibility() + { + var stream = new MemoryStream(); + + await sut.UploadAsync(backupId, stream); + + A.CallTo(() => assetStore.UploadAsync(fileName, stream, true, CancellationToken.None)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_invoke_asset_store_to_download_archive_using_suffix_for_compatibility() + { + var stream = new MemoryStream(); + + await sut.DownloadAsync(backupId, stream); + + A.CallTo(() => assetStore.DownloadAsync(fileName, stream, CancellationToken.None)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_invoke_asset_store_to_delete_archive_using_suffix_for_compatibility() + { + await sut.DeleteAsync(backupId); + + A.CallTo(() => assetStore.DeleteAsync(fileName)) + .MustHaveHappened(); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/UserMappingTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/UserMappingTests.cs new file mode 100644 index 000000000..9f8204a7d --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/UserMappingTests.cs @@ -0,0 +1,160 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Squidex.Shared.Users; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public class UserMappingTests + { + private readonly RefToken initiator = Subject("me"); + private readonly UserMapping sut; + + public UserMappingTests() + { + sut = new UserMapping(initiator); + } + + [Fact] + public async Task Should_backup_users_but_no_clients() + { + sut.Backup("user1"); + sut.Backup(Subject("user2")); + + sut.Backup(Client("client")); + + var user1 = CreateUser("user1", "mail1@squidex.io"); + var user2 = CreateUser("user2", "mail2@squidex.io"); + + var users = new Dictionary + { + [user1.Id] = user1, + [user2.Id] = user2 + }; + + var userResolver = A.Fake(); + + A.CallTo(() => userResolver.QueryManyAsync(A.That.Is(user1.Id, user2.Id))) + .Returns(users); + + var writer = A.Fake(); + + Dictionary? storedUsers = null; + + A.CallTo(() => writer.WriteJsonAsync(A.Ignored, A.Ignored)) + .Invokes((string _, object json) => storedUsers = (Dictionary)json); + + await sut.StoreAsync(writer, userResolver); + + Assert.Equal(new Dictionary + { + [user1.Id] = user1.Email, + [user2.Id] = user2.Email + }, storedUsers); + } + + [Fact] + public async Task Should_restore_users() + { + var user1 = CreateUser("user1", "mail1@squidex.io"); + var user2 = CreateUser("user2", "mail2@squidex.io"); + + var reader = SetupReader(user1, user2); + + var userResolver = A.Fake(); + + A.CallTo(() => userResolver.FindByIdOrEmailAsync(user1.Email)) + .Returns(user1); + + A.CallTo(() => userResolver.FindByIdOrEmailAsync(user2.Email)) + .Returns(user2); + + await sut.RestoreAsync(reader, userResolver); + + Assert.True(sut.TryMap("user1_old", out var mapped1)); + Assert.Equal(Subject("user1"), mapped1); + + Assert.True(sut.TryMap(Subject("user2_old"), out var mapped2)); + Assert.Equal(Subject("user2"), mapped2); + } + + [Fact] + public async Task Should_create_user_if_not_found() + { + var user = CreateUser("newId1", "mail1@squidex.io"); + + var reader = SetupReader(user); + + var userResolver = A.Fake(); + + A.CallTo(() => userResolver.FindByIdOrEmailAsync(user.Email)) + .Returns(Task.FromResult(null)); + + await sut.RestoreAsync(reader, userResolver); + + A.CallTo(() => userResolver.CreateUserIfNotExistsAsync(user.Email, false)) + .MustHaveHappened(); + } + + [Fact] + public void Should_return_initiator_if_user_not_found() + { + var user = Subject("user1"); + + Assert.False(sut.TryMap(user, out var mapped)); + Assert.Same(initiator, mapped); + } + + [Fact] + public void Should_create_same_token_if_mapping_client() + { + var client = Client("client1"); + + Assert.True(sut.TryMap(client, out var mapped)); + Assert.Same(client, mapped); + } + + private IUser CreateUser(string id, string email) + { + var user = A.Fake(); + + A.CallTo(() => user.Id).Returns(id); + A.CallTo(() => user.Email).Returns(email); + + return user; + } + + private static IBackupReader SetupReader(params IUser[] users) + { + var storedUsers = users.ToDictionary(x => $"{x.Id}_old", x => x.Email); + + var reader = A.Fake(); + + A.CallTo(() => reader.ReadJsonAttachmentAsync>(A.Ignored)) + .Returns(storedUsers); + + return reader; + } + + private static RefToken Client(string identifier) + { + return new RefToken(RefTokenType.Client, identifier); + } + + private static RefToken Subject(string identifier) + { + return new RefToken(RefTokenType.Subject, identifier); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs new file mode 100644 index 000000000..c4d93f304 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs @@ -0,0 +1,105 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Contents.State; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Domain.Apps.Events.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Tasks; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public class BackupContentsTests + { + private readonly Rebuilder rebuilder = A.Fake(); + private readonly BackupContents sut; + + public BackupContentsTests() + { + sut = new BackupContents(rebuilder); + } + + [Fact] + public void Should_provide_name() + { + Assert.Equal("Contents", sut.Name); + } + + [Fact] + public async Task Should_restore_states_for_all_contents() + { + var appId = Guid.NewGuid(); + + var schemaId1 = NamedId.Of(Guid.NewGuid(), "my-schema1"); + var schemaId2 = NamedId.Of(Guid.NewGuid(), "my-schema2"); + + var contentId1 = Guid.NewGuid(); + var contentId2 = Guid.NewGuid(); + var contentId3 = Guid.NewGuid(); + + var context = new RestoreContext(appId, new UserMapping(new RefToken(RefTokenType.Subject, "123")), A.Fake()); + + await sut.RestoreEventAsync(Envelope.Create(new ContentCreated + { + ContentId = contentId1, + SchemaId = schemaId1 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new ContentCreated + { + ContentId = contentId2, + SchemaId = schemaId1 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new ContentCreated + { + ContentId = contentId3, + SchemaId = schemaId2 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new ContentDeleted + { + ContentId = contentId2, + SchemaId = schemaId1 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new SchemaDeleted + { + SchemaId = schemaId2 + }), context); + + var rebuildContents = new HashSet(); + + var add = new Func(id => + { + rebuildContents.Add(id); + + return TaskHelper.Done; + }); + + A.CallTo(() => rebuilder.InsertManyAsync(A.Ignored, A.Ignored)) + .Invokes((IdSource source, CancellationToken _) => source(add)); + + await sut.RestoreAsync(context); + + Assert.Equal(new HashSet + { + contentId1, + contentId2 + }, rebuildContents); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs index cacfbe1a4..e8319d568 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs @@ -26,7 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData return $"assets/{asset.Id}?width=100"; } - public string GenerateAssetSourceUrl(IAppEntity app, IAssetEntity asset) + public string GenerateAssetSourceUrl(IAssetEntity asset) { return $"assets/source/{asset.Id}"; } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/BackupRulesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/BackupRulesTests.cs new file mode 100644 index 000000000..dc5d9b1e8 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/BackupRulesTests.cs @@ -0,0 +1,82 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Rules.Indexes; +using Squidex.Domain.Apps.Events.Rules; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public class BackupRulesTests + { + private readonly IRulesIndex index = A.Fake(); + private readonly BackupRules sut; + + public BackupRulesTests() + { + sut = new BackupRules(index); + } + + [Fact] + public void Should_provide_name() + { + Assert.Equal("Rules", sut.Name); + } + + [Fact] + public async Task Should_restore_indices_for_all_non_deleted_rules() + { + var appId = Guid.NewGuid(); + + var ruleId1 = Guid.NewGuid(); + var ruleId2 = Guid.NewGuid(); + var ruleId3 = Guid.NewGuid(); + + var context = new RestoreContext(appId, new UserMapping(new RefToken(RefTokenType.Subject, "123")), A.Fake()); + + await sut.RestoreEventAsync(Envelope.Create(new RuleCreated + { + RuleId = ruleId1 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new RuleCreated + { + RuleId = ruleId2 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new RuleCreated + { + RuleId = ruleId3 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new RuleDeleted + { + RuleId = ruleId3 + }), context); + + HashSet? newIndex = null; + + A.CallTo(() => index.RebuildAsync(appId, A>.Ignored)) + .Invokes(new Action>((_, i) => newIndex = i)); + + await sut.RestoreAsync(context); + + Assert.Equal(new HashSet + { + ruleId1, + ruleId2, + }, newIndex); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/BackupSchemasTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/BackupSchemasTests.cs new file mode 100644 index 000000000..0834c03ae --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/BackupSchemasTests.cs @@ -0,0 +1,82 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Schemas.Indexes; +using Squidex.Domain.Apps.Events.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Schemas +{ + public class BackupSchemasTests + { + private readonly ISchemasIndex index = A.Fake(); + private readonly BackupSchemas sut; + + public BackupSchemasTests() + { + sut = new BackupSchemas(index); + } + + [Fact] + public void Should_provide_name() + { + Assert.Equal("Schemas", sut.Name); + } + + [Fact] + public async Task Should_restore_indices_for_all_non_deleted_schemas() + { + var appId = Guid.NewGuid(); + + var schemaId1 = NamedId.Of(Guid.NewGuid(), "my-schema1"); + var schemaId2 = NamedId.Of(Guid.NewGuid(), "my-schema2"); + var schemaId3 = NamedId.Of(Guid.NewGuid(), "my-schema3"); + + var context = new RestoreContext(appId, new UserMapping(new RefToken(RefTokenType.Subject, "123")), A.Fake()); + + await sut.RestoreEventAsync(Envelope.Create(new SchemaCreated + { + SchemaId = schemaId1 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new SchemaCreated + { + SchemaId = schemaId2 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new SchemaCreated + { + SchemaId = schemaId3 + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new SchemaDeleted + { + SchemaId = schemaId3 + }), context); + + Dictionary? newIndex = null; + + A.CallTo(() => index.RebuildAsync(appId, A>.Ignored)) + .Invokes(new Action>((_, i) => newIndex = i)); + + await sut.RestoreAsync(context); + + Assert.Equal(new Dictionary + { + [schemaId1.Name] = schemaId1.Id, + [schemaId2.Name] = schemaId2.Id + }, newIndex); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs index 2d04e04a5..213eb81a9 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs @@ -67,16 +67,10 @@ namespace Squidex.Domain.Apps.Entities.TestHelpers .Returns(persistence2); A.CallTo(() => persistence1.WriteEventsAsync(A>>.Ignored)) - .Invokes(new Action>>(events => - { - LastEvents = events; - })); + .Invokes((IEnumerable> events) => LastEvents = events); A.CallTo(() => persistence2.WriteEventsAsync(A>>.Ignored)) - .Invokes(new Action>>(events => - { - LastEvents = events; - })); + .Invokes((IEnumerable> events) => LastEvents = events); } protected CommandContext CreateContextForCommand(TCommand command) where TCommand : SquidexCommand diff --git a/backend/tests/Squidex.Domain.Users.Tests/AssetUserPictureStoreTests.cs b/backend/tests/Squidex.Domain.Users.Tests/DefaultUserPictureStoreTests.cs similarity index 56% rename from backend/tests/Squidex.Domain.Users.Tests/AssetUserPictureStoreTests.cs rename to backend/tests/Squidex.Domain.Users.Tests/DefaultUserPictureStoreTests.cs index 4281e1f6a..765c6619b 100644 --- a/backend/tests/Squidex.Domain.Users.Tests/AssetUserPictureStoreTests.cs +++ b/backend/tests/Squidex.Domain.Users.Tests/DefaultUserPictureStoreTests.cs @@ -13,26 +13,24 @@ using FakeItEasy; using Squidex.Infrastructure.Assets; using Xunit; -#pragma warning disable RECS0165 // Asynchronous methods should return a Task instead of void - namespace Squidex.Domain.Users { - public class AssetUserPictureStoreTests + public class DefaultUserPictureStoreTests { private readonly IAssetStore assetStore = A.Fake(); - private readonly AssetUserPictureStore sut; + private readonly DefaultUserPictureStore sut; private readonly string userId = Guid.NewGuid().ToString(); private readonly string file; - public AssetUserPictureStoreTests() + public DefaultUserPictureStoreTests() { - file = AssetStoreExtensions.GetFileName(userId, 0, "picture"); + file = $"{userId}_0_picture"; - sut = new AssetUserPictureStore(assetStore); + sut = new DefaultUserPictureStore(assetStore); } [Fact] - public async Task Should_invoke_asset_store_to_upload_picture() + public async Task Should_invoke_asset_store_to_upload_picture_using_suffix_for_compatibility() { var stream = new MemoryStream(); @@ -43,22 +41,13 @@ namespace Squidex.Domain.Users } [Fact] - public async Task Should_invoke_asset_store_to_download_picture() + public async Task Should_invoke_asset_store_to_download_picture_using_suffix_for_compatibility() { - A.CallTo(() => assetStore.DownloadAsync(file, A.Ignored, CancellationToken.None)) - .Invokes(async call => - { - var stream = call.GetArgument(1); - - await stream.WriteAsync(new byte[] { 1, 2, 3, 4 }, 0, 4); - }); - - var result = await sut.DownloadAsync(userId); + var stream = new MemoryStream(); - Assert.Equal(0, result.Position); - Assert.Equal(4, result.Length); + await sut.DownloadAsync(userId, stream); - A.CallTo(() => assetStore.DownloadAsync(file, A.Ignored, CancellationToken.None)) + A.CallTo(() => assetStore.DownloadAsync(file, stream, CancellationToken.None)) .MustHaveHappened(); } } diff --git a/backend/tests/Squidex.Infrastructure.Tests/Assets/AssetExtensionTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Assets/AssetExtensionTests.cs deleted file mode 100644 index c507e7529..000000000 --- a/backend/tests/Squidex.Infrastructure.Tests/Assets/AssetExtensionTests.cs +++ /dev/null @@ -1,112 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.IO; -using FakeItEasy; -using Xunit; - -namespace Squidex.Infrastructure.Assets -{ - public class AssetExtensionTests - { - private readonly IAssetStore sut = A.Fake(); - private readonly Guid id = Guid.NewGuid(); - private readonly Stream stream = new MemoryStream(); - private readonly string fileName = Guid.NewGuid().ToString(); - - [Fact] - public void Should_copy_with_id_and_version() - { - sut.CopyAsync(fileName, id, 1, string.Empty); - - A.CallTo(() => sut.CopyAsync(fileName, $"{id}_1", default)) - .MustHaveHappened(); - } - - [Fact] - public void Should_copy_with_id_and_version_and_suffix() - { - sut.CopyAsync(fileName, id, 1, "Crop"); - - A.CallTo(() => sut.CopyAsync(fileName, $"{id}_1_Crop", default)) - .MustHaveHappened(); - } - - [Fact] - public void Should_upload_with_id_and_version() - { - sut.UploadAsync(id, 1, string.Empty, stream, true); - - A.CallTo(() => sut.UploadAsync($"{id}_1", stream, true, default)) - .MustHaveHappened(); - } - - [Fact] - public void Should_upload_with_id_and_version_and_suffix() - { - sut.UploadAsync(id, 1, "Crop", stream, true); - - A.CallTo(() => sut.UploadAsync($"{id}_1_Crop", stream, true, default)) - .MustHaveHappened(); - } - - [Fact] - public void Should_download_with_id_and_version() - { - sut.DownloadAsync(id, 1, string.Empty, stream); - - A.CallTo(() => sut.DownloadAsync($"{id}_1", stream, default)) - .MustHaveHappened(); - } - - [Fact] - public void Should_download_with_id_and_version_and_suffix() - { - sut.DownloadAsync(id, 1, "Crop", stream); - - A.CallTo(() => sut.DownloadAsync($"{id}_1_Crop", stream, default)) - .MustHaveHappened(); - } - - [Fact] - public void Should_delete_with_id_and_version() - { - sut.DeleteAsync(id, 1, string.Empty); - - A.CallTo(() => sut.DeleteAsync($"{id}_1")) - .MustHaveHappened(); - } - - [Fact] - public void Should_delete_with_id_and_version_and_suffix() - { - sut.DeleteAsync(id, 1, "Crop"); - - A.CallTo(() => sut.DeleteAsync($"{id}_1_Crop")) - .MustHaveHappened(); - } - - [Fact] - public void Should_generate_url_with_id_and_version() - { - sut.GeneratePublicUrl(id, 1, string.Empty); - - A.CallTo(() => sut.GeneratePublicUrl($"{id}_1")) - .MustHaveHappened(); - } - - [Fact] - public void Should_generate_url_with_id_and_version_and_suffix() - { - sut.GeneratePublicUrl(id, 1, "Crop"); - - A.CallTo(() => sut.GeneratePublicUrl($"{id}_1_Crop")) - .MustHaveHappened(); - } - } -} diff --git a/backend/tools/Migrate_01/Migrations/RebuildApps.cs b/backend/tools/Migrate_01/Migrations/RebuildApps.cs index 97e1aad6c..2dffb5852 100644 --- a/backend/tools/Migrate_01/Migrations/RebuildApps.cs +++ b/backend/tools/Migrate_01/Migrations/RebuildApps.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Threading.Tasks; +using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Migrations; namespace Migrate_01.Migrations diff --git a/backend/tools/Migrate_01/Migrations/RebuildAssets.cs b/backend/tools/Migrate_01/Migrations/RebuildAssets.cs index aff9832c1..045825b73 100644 --- a/backend/tools/Migrate_01/Migrations/RebuildAssets.cs +++ b/backend/tools/Migrate_01/Migrations/RebuildAssets.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Threading.Tasks; +using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Migrations; namespace Migrate_01.Migrations diff --git a/backend/tools/Migrate_01/Migrations/RebuildContents.cs b/backend/tools/Migrate_01/Migrations/RebuildContents.cs index 53cd96961..e0912931d 100644 --- a/backend/tools/Migrate_01/Migrations/RebuildContents.cs +++ b/backend/tools/Migrate_01/Migrations/RebuildContents.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Threading.Tasks; +using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Migrations; namespace Migrate_01.Migrations diff --git a/backend/tools/Migrate_01/Migrations/RebuildSnapshots.cs b/backend/tools/Migrate_01/Migrations/RebuildSnapshots.cs index 2e6472a7d..b8c0392f3 100644 --- a/backend/tools/Migrate_01/Migrations/RebuildSnapshots.cs +++ b/backend/tools/Migrate_01/Migrations/RebuildSnapshots.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Threading.Tasks; +using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Migrations; namespace Migrate_01.Migrations diff --git a/backend/tools/Migrate_01/RebuildRunner.cs b/backend/tools/Migrate_01/RebuildRunner.cs index 32dea1cbe..add63c577 100644 --- a/backend/tools/Migrate_01/RebuildRunner.cs +++ b/backend/tools/Migrate_01/RebuildRunner.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Options; using Migrate_01.Migrations; using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; namespace Migrate_01 { diff --git a/backend/tools/Migrate_01/Rebuilder.cs b/backend/tools/Migrate_01/Rebuilder.cs deleted file mode 100644 index 3c034e6a2..000000000 --- a/backend/tools/Migrate_01/Rebuilder.cs +++ /dev/null @@ -1,125 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using System.Threading.Tasks.Dataflow; -using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Apps.State; -using Squidex.Domain.Apps.Entities.Assets; -using Squidex.Domain.Apps.Entities.Assets.State; -using Squidex.Domain.Apps.Entities.Contents; -using Squidex.Domain.Apps.Entities.Contents.State; -using Squidex.Domain.Apps.Entities.Rules; -using Squidex.Domain.Apps.Entities.Rules.State; -using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Domain.Apps.Entities.Schemas.State; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Caching; -using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.States; - -namespace Migrate_01 -{ - public sealed class Rebuilder - { - private readonly ILocalCache localCache; - private readonly IStore store; - private readonly IEventStore eventStore; - - public Rebuilder( - ILocalCache localCache, - IStore store, - IEventStore eventStore) - { - this.eventStore = eventStore; - this.localCache = localCache; - this.store = store; - } - - public Task RebuildAppsAsync(CancellationToken ct = default) - { - return RebuildManyAsync("^app\\-", ct); - } - - public Task RebuildSchemasAsync(CancellationToken ct = default) - { - return RebuildManyAsync("^schema\\-", ct); - } - - public Task RebuildRulesAsync(CancellationToken ct = default) - { - return RebuildManyAsync("^rule\\-", ct); - } - - public Task RebuildAssetsAsync(CancellationToken ct = default) - { - return RebuildManyAsync("^asset\\-", ct); - } - - public Task RebuildContentAsync(CancellationToken ct = default) - { - return RebuildManyAsync("^content\\-", ct); - } - - private async Task RebuildManyAsync(string filter, CancellationToken ct) where TState : IDomainState, new() - { - var handledIds = new HashSet(); - - var worker = new ActionBlock(async id => - { - try - { - var state = new TState - { - Version = EtagVersion.Empty - }; - - var persistence = store.WithSnapshotsAndEventSourcing(typeof(TGrain), id, (TState s) => state = s, e => - { - state = state.Apply(e); - - state.Version++; - }); - - await persistence.ReadAsync(); - await persistence.WriteSnapshotAsync(state); - } - catch (DomainObjectNotFoundException) - { - return; - } - }, - new ExecutionDataflowBlockOptions - { - MaxDegreeOfParallelism = Environment.ProcessorCount * 2 - }); - - using (localCache.StartContext()) - { - await store.GetSnapshotStore().ClearAsync(); - - await eventStore.QueryAsync(async storedEvent => - { - var id = storedEvent.Data.Headers.AggregateId(); - - if (handledIds.Add(id)) - { - await worker.SendAsync(id, ct); - } - }, filter, ct: ct); - - worker.Complete(); - - await worker.Completion; - } - } - } -} \ No newline at end of file diff --git a/backend/tools/Migrate_01/RebuilderExtensions.cs b/backend/tools/Migrate_01/RebuilderExtensions.cs new file mode 100644 index 000000000..9baaf6d6a --- /dev/null +++ b/backend/tools/Migrate_01/RebuilderExtensions.cs @@ -0,0 +1,51 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Apps.State; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Assets.State; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Entities.Contents.State; +using Squidex.Domain.Apps.Entities.Rules; +using Squidex.Domain.Apps.Entities.Rules.State; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.State; +using Squidex.Infrastructure.Commands; + +namespace Migrate_01 +{ + public static class RebuilderExtensions + { + public static Task RebuildAppsAsync(this Rebuilder rebuilder, CancellationToken ct = default) + { + return rebuilder.RebuildAsync("^app\\-", ct); + } + + public static Task RebuildSchemasAsync(this Rebuilder rebuilder, CancellationToken ct = default) + { + return rebuilder.RebuildAsync("^schema\\-", ct); + } + + public static Task RebuildRulesAsync(this Rebuilder rebuilder, CancellationToken ct = default) + { + return rebuilder.RebuildAsync("^rule\\-", ct); + } + + public static Task RebuildAssetsAsync(this Rebuilder rebuilder, CancellationToken ct = default) + { + return rebuilder.RebuildAsync("^asset\\-", ct); + } + + public static Task RebuildContentAsync(this Rebuilder rebuilder, CancellationToken ct = default) + { + return rebuilder.RebuildAsync("^content\\-", ct); + } + } +} \ No newline at end of file