diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs index 778210249..e1c757c65 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs @@ -209,7 +209,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules { if (@event.Actor != null) { - if (@event.Actor.Type.Equals("client", StringComparison.OrdinalIgnoreCase)) + if (@event.Actor.Type.Equals(RefTokenType.Client, StringComparison.OrdinalIgnoreCase)) { return @event.Actor.ToString(); } @@ -227,7 +227,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules { if (@event.Actor != null) { - if (@event.Actor.Type.Equals("client", StringComparison.OrdinalIgnoreCase)) + if (@event.Actor.Type.Equals(RefTokenType.Client, StringComparison.OrdinalIgnoreCase)) { return @event.Actor.ToString(); } diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs index ea3a19385..6ea3cd584 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs @@ -43,18 +43,18 @@ namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper case JTokenType.Object: return FromObject(value, engine); case JTokenType.Array: - { - var arr = (JArray)value; + { + var arr = (JArray)value; - var target = new JsValue[arr.Count]; + var target = new JsValue[arr.Count]; - for (var i = 0; i < arr.Count; i++) - { - target[i] = Map(arr[i], engine); - } + for (var i = 0; i < arr.Count; i++) + { + target[i] = Map(arr[i], engine); + } - return engine.Array.Construct(target); - } + return engine.Array.Construct(target); + } } throw new ArgumentException("Invalid json type.", nameof(value)); diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index d3e85b6e8..b3d54fa2c 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -120,5 +120,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets return assetEntity; } } + + public Task RemoveAsync(Guid appId) + { + return Collection.DeleteManyAsync(x => x.IndexedAppId == appId); + } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs index 6cdbfecc6..189f08052 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs @@ -18,11 +18,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { public sealed partial class MongoAssetRepository : ISnapshotStore { - Task ISnapshotStore.ReadAllAsync(Func callback) - { - throw new NotSupportedException(); - } - public async Task<(AssetState Value, long Version)> ReadAsync(Guid key) { using (Profiler.TraceMethod()) @@ -52,5 +47,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets await Collection.ReplaceOneAsync(x => x.Id == key && x.Version == oldVersion, entity, Upsert); } } + + Task ISnapshotStore.ReadAllAsync(Func callback) + { + throw new NotSupportedException(); + } + + Task ISnapshotStore.RemoveAsync(Guid key) + { + throw new NotSupportedException(); + } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs index ad6854d8b..eccc17915 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs @@ -117,5 +117,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents Filter.AnyNe(x => x.ReferencedIdsDeleted, id)), Update.AddToSet(x => x.ReferencedIdsDeleted, id)); } + + public Task RemoveAsync(Guid id) + { + return Collection.DeleteOneAsync(x => x.Id == id); + } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs index 42a5050c9..ae894f6cf 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs @@ -58,6 +58,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents return ids.Except(contentEntities.Select(x => Guid.Parse(x["_id"].AsString))).ToList(); } + public async Task> QueryIdsAsync(Guid appId) + { + var contentEntities = + await Collection.Find(x => x.IndexedAppId == appId).Only(x => x.Id) + .ToListAsync(); + + return contentEntities.Select(x => Guid.Parse(x["_id"].AsString)).ToList(); + } + public Task QueryScheduledWithoutDataAsync(Instant now, Func callback) { return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted != true) diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs index 5afce42e0..ec1d2da7c 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs @@ -56,10 +56,5 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents return Collection.ReplaceOneAsync(x => x.Id == content.Id, content, new UpdateOptions { IsUpsert = true }); } - - public Task RemoveAsync(Guid id) - { - return Collection.DeleteOneAsync(x => x.Id == id); - } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index 0d1f94f84..0e13c1345 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -99,6 +99,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents } } + public async Task> QueryIdsAsync(Guid appId) + { + using (Profiler.TraceMethod()) + { + return await contentsDraft.QueryIdsAsync(appId); + } + } + public async Task QueryScheduledWithoutDataAsync(Instant now, Func callback) { using (Profiler.TraceMethod()) @@ -107,6 +115,13 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents } } + public Task RemoveAsync(Guid appId) + { + return Task.WhenAll( + contentsDraft.RemoveAsync(appId), + contentsPublished.RemoveAsync(appId)); + } + public Task ClearAsync() { return Task.WhenAll( diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs index 8393c28c3..cd6700635 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs @@ -83,6 +83,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents return schema; } + Task ISnapshotStore.RemoveAsync(Guid key) + { + throw new NotSupportedException(); + } + Task ISnapshotStore.ReadAllAsync(Func callback) { throw new NotSupportedException(); diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs index e0c172251..fb40a3062 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs @@ -112,5 +112,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History } } } + + public Task RemoveAsync(Guid appId) + { + return Collection.DeleteManyAsync(x => x.AppId == appId); + } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs index d7858ebcf..b775e9aba 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs @@ -65,6 +65,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules return ruleEvent; } + public Task RemoveAsync(Guid appId) + { + return Collection.DeleteManyAsync(x => x.AppId == appId); + } + public async Task CountByAppAsync(Guid appId) { return (int)await Collection.CountDocumentsAsync(x => x.AppId == appId); diff --git a/src/Squidex.Domain.Apps.Entities/AppProvider.cs b/src/Squidex.Domain.Apps.Entities/AppProvider.cs index c8c821f49..394cb55c4 100644 --- a/src/Squidex.Domain.Apps.Entities/AppProvider.cs +++ b/src/Squidex.Domain.Apps.Entities/AppProvider.cs @@ -11,8 +11,11 @@ using System.Linq; using System.Threading.Tasks; using Orleans; using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Apps.Indexes; using Squidex.Domain.Apps.Entities.Rules; +using Squidex.Domain.Apps.Entities.Rules.Indexes; using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Indexes; using Squidex.Infrastructure; using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Log; diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs index c45e1bd03..1efd1e404 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs @@ -29,7 +29,6 @@ namespace Squidex.Domain.Apps.Entities.Apps public sealed class AppGrain : SquidexDomainObjectGrain, IAppGrain { private readonly InitialPatterns initialPatterns; - private readonly IAppProvider appProvider; private readonly IAppPlansProvider appPlansProvider; private readonly IAppPlanBillingManager appPlansBillingManager; private readonly IUserResolver userResolver; @@ -38,20 +37,17 @@ namespace Squidex.Domain.Apps.Entities.Apps InitialPatterns initialPatterns, IStore store, ISemanticLog log, - IAppProvider appProvider, IAppPlansProvider appPlansProvider, IAppPlanBillingManager appPlansBillingManager, IUserResolver userResolver) : base(store, log) { Guard.NotNull(initialPatterns, nameof(initialPatterns)); - Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(userResolver, nameof(userResolver)); Guard.NotNull(appPlansProvider, nameof(appPlansProvider)); Guard.NotNull(appPlansBillingManager, nameof(appPlansBillingManager)); this.userResolver = userResolver; - this.appProvider = appProvider; this.appPlansProvider = appPlansProvider; this.appPlansBillingManager = appPlansBillingManager; this.initialPatterns = initialPatterns; @@ -64,9 +60,9 @@ namespace Squidex.Domain.Apps.Entities.Apps switch (command) { case CreateApp createApp: - return CreateAsync(createApp, async c => + return CreateAsync(createApp, c => { - await GuardApp.CanCreate(c, appProvider); + GuardApp.CanCreate(c); Create(c); }); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs b/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs new file mode 100644 index 000000000..eed1cdf70 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs @@ -0,0 +1,190 @@ +// ========================================================================== +// 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 Newtonsoft.Json.Linq; +using Orleans; +using Squidex.Domain.Apps.Entities.Apps.Indexes; +using Squidex.Domain.Apps.Entities.Apps.State; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public sealed class BackupApps : BackupHandlerWithStore + { + private const string UsersFile = "Users.json"; + private readonly IGrainFactory grainFactory; + private readonly IUserResolver userResolver; + private readonly HashSet activeUsers = new HashSet(); + private Dictionary usersWithEmail = new Dictionary(); + private Dictionary userMapping = new Dictionary(); + private bool isReserved; + private bool isActorAssigned; + private AppCreated appCreated; + + public override string Name { get; } = "Apps"; + + public BackupApps(IStore store, IGrainFactory grainFactory, IUserResolver userResolver) + : base(store) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + Guard.NotNull(userResolver, nameof(userResolver)); + + this.grainFactory = grainFactory; + + this.userResolver = userResolver; + } + + public override async Task BackupEventAsync(Envelope @event, Guid appId, BackupWriter writer) + { + if (@event.Payload is AppContributorAssigned appContributorAssigned) + { + var userId = appContributorAssigned.ContributorId; + + if (!usersWithEmail.ContainsKey(userId)) + { + var user = await userResolver.FindByIdOrEmailAsync(userId); + + if (user != null) + { + usersWithEmail.Add(userId, user.Email); + } + } + } + } + + public override Task BackupAsync(Guid appId, BackupWriter writer) + { + return WriterUsersAsync(writer); + } + + public async override Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + { + switch (@event.Payload) + { + case AppCreated appCreated: + { + this.appCreated = appCreated; + + await ResolveUsersAsync(reader, actor); + await ReserveAppAsync(); + + break; + } + + case AppContributorAssigned contributorAssigned: + { + if (isActorAssigned) + { + contributorAssigned.ContributorId = MapUser(contributorAssigned.ContributorId, actor).Identifier; + } + else + { + isActorAssigned = true; + + contributorAssigned.ContributorId = actor.Identifier; + } + + activeUsers.Add(contributorAssigned.ContributorId); + break; + } + + case AppContributorRemoved contributorRemoved: + { + contributorRemoved.ContributorId = MapUser(contributorRemoved.ContributorId, actor).Identifier; + + activeUsers.Remove(contributorRemoved.ContributorId); + break; + } + } + + if (@event.Payload is SquidexEvent squidexEvent) + { + squidexEvent.Actor = MapUser(squidexEvent.Actor.Identifier, actor); + } + } + + private async Task ReserveAppAsync() + { + var index = grainFactory.GetGrain(SingleGrain.Id); + + if (!(isReserved = await index.ReserveAppAsync(appCreated.AppId.Id, appCreated.AppId.Name))) + { + throw new BackupRestoreException("The app id or name is not available."); + } + } + + private RefToken MapUser(string userId, RefToken fallback) + { + return userMapping.GetOrAdd(userId, fallback); + } + + private async Task ResolveUsersAsync(BackupReader reader, RefToken actor) + { + await ReadUsersAsync(reader); + + foreach (var kvp in usersWithEmail) + { + var user = await userResolver.FindByIdOrEmailAsync(kvp.Value); + + if (user != null) + { + userMapping[kvp.Key] = new RefToken(RefTokenType.Subject, user.Id); + } + else + { + userMapping[kvp.Key] = actor; + } + } + } + + private async Task ReadUsersAsync(BackupReader reader) + { + var json = await reader.ReadJsonAttachmentAsync(UsersFile); + + usersWithEmail = json.ToObject>(); + } + + private Task WriterUsersAsync(BackupWriter writer) + { + var json = JObject.FromObject(usersWithEmail); + + return writer.WriteJsonAsync(UsersFile, json); + } + + public override async Task CompleteRestoreAsync(Guid appId, BackupReader reader) + { + await RebuildAsync(appId, (e, s) => s.Apply(e)); + + await grainFactory.GetGrain(SingleGrain.Id).AddAppAsync(appCreated.AppId.Id, appCreated.AppId.Name); + + foreach (var user in activeUsers) + { + await grainFactory.GetGrain(user).AddAppAsync(appCreated.AppId.Id); + } + } + + public override async Task CleanupRestoreAsync(Guid appId) + { + if (isReserved) + { + var index = grainFactory.GetGrain(SingleGrain.Id); + + await index.ReserveAppAsync(appCreated.AppId.Id, appCreated.AppId.Name); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs index ce81ef926..49787bba8 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs @@ -6,7 +6,6 @@ // ========================================================================== using System; -using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Services; @@ -16,20 +15,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { public static class GuardApp { - public static Task CanCreate(CreateApp command, IAppProvider appProvider) + public static void CanCreate(CreateApp command) { Guard.NotNull(command, nameof(command)); - return Validate.It(() => "Cannot create app.", async e => + Validate.It(() => "Cannot create app.", e => { if (!command.Name.IsSlug()) { e("Name must be a valid slug.", nameof(command.Name)); } - else if (await appProvider.GetAppAsync(command.Name) != null) - { - e("An app with the same name already exists.", nameof(command.Name)); - } }); } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexCommandMiddleware.cs index 798fd6f1b..1722bc363 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexCommandMiddleware.cs @@ -28,20 +28,44 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes public async Task HandleAsync(CommandContext context, Func next) { - if (context.IsCompleted) + var createApp = context.Command as CreateApp; + + var isReserved = false; + try { - switch (context.Command) + if (createApp != null) + { + isReserved = await index.ReserveAppAsync(createApp.AppId, createApp.Name); + + if (!isReserved) + { + var error = new ValidationError("An app with the same name already exists.", nameof(createApp.Name)); + + throw new ValidationException("Cannot create app.", error); + } + } + + await next(); + + if (context.IsCompleted) { - case CreateApp createApp: + if (createApp != null) + { await index.AddAppAsync(createApp.AppId, createApp.Name); - break; - case ArchiveApp archiveApp: + } + else if (context.Command is ArchiveApp archiveApp) + { await index.RemoveAppAsync(archiveApp.AppId); - break; + } + } + } + finally + { + if (isReserved && createApp != null) + { + await index.RemoveReservationAsync(createApp.AppId, createApp.Name); } } - - await next(); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexGrain.cs index db53e1dc3..29da56d76 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexGrain.cs @@ -12,12 +12,15 @@ using System.Threading.Tasks; using Squidex.Infrastructure; using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Entities.Apps.Indexes { public sealed class AppsByNameIndexGrain : GrainOfString, IAppsByNameIndex { private readonly IStore store; + private readonly HashSet reservedIds = new HashSet(); + private readonly HashSet reservedNames = new HashSet(); private IPersistence persistence; private State state = new State(); @@ -51,16 +54,52 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes return persistence.WriteSnapshotAsync(state); } + public Task ReserveAppAsync(Guid appId, string name) + { + var canReserve = + !state.Apps.ContainsKey(name) && + !state.Apps.Any(x => x.Value == appId) && + !reservedIds.Contains(appId) && + !reservedNames.Contains(name); + + if (canReserve) + { + reservedIds.Add(appId); + reservedNames.Add(name); + } + + return Task.FromResult(canReserve); + } + + public Task RemoveReservationAsync(Guid appId, string name) + { + reservedIds.Remove(appId); + reservedNames.Remove(name); + + return TaskHelper.Done; + } + public Task AddAppAsync(Guid appId, string name) { state.Apps[name] = appId; + reservedIds.Remove(appId); + reservedNames.Remove(name); + return persistence.WriteSnapshotAsync(state); } public Task RemoveAppAsync(Guid appId) { - state.Apps.Remove(state.Apps.FirstOrDefault(x => x.Value == appId).Key ?? string.Empty); + var name = state.Apps.FirstOrDefault(x => x.Value == appId).Key; + + if (!string.IsNullOrWhiteSpace(name)) + { + state.Apps.Remove(name); + + reservedIds.Remove(appId); + reservedNames.Remove(name); + } return persistence.WriteSnapshotAsync(state); } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsByNameIndex.cs b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsByNameIndex.cs index 9ba30cd1a..2580ab694 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsByNameIndex.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsByNameIndex.cs @@ -10,16 +10,20 @@ using System.Collections.Generic; using System.Threading.Tasks; using Orleans; -namespace Squidex.Domain.Apps.Entities.Apps +namespace Squidex.Domain.Apps.Entities.Apps.Indexes { public interface IAppsByNameIndex : IGrainWithStringKey { + Task ReserveAppAsync(Guid appId, string name); + Task AddAppAsync(Guid appId, string name); Task RemoveAppAsync(Guid appId); Task RebuildAsync(Dictionary apps); + Task RemoveReservationAsync(Guid appId, string name); + Task GetAppIdAsync(string name); Task> GetAppIdsAsync(); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsByUserIndex.cs b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsByUserIndex.cs index 8b960b13d..e769f8803 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsByUserIndex.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsByUserIndex.cs @@ -10,7 +10,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Orleans; -namespace Squidex.Domain.Apps.Entities.Apps +namespace Squidex.Domain.Apps.Entities.Apps.Indexes { public interface IAppsByUserIndex : IGrainWithStringKey { diff --git a/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs b/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs new file mode 100644 index 000000000..a63d894b6 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs @@ -0,0 +1,132 @@ +// ========================================================================== +// 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 Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Domain.Apps.Entities.Assets.State; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Tags; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Assets; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public sealed class BackupAssets : BackupHandlerWithStore + { + private const string TagsFile = "AssetTags.json"; + private readonly HashSet assetIds = new HashSet(); + private readonly IAssetStore assetStore; + private readonly IAssetRepository assetRepository; + private readonly ITagService tagService; + + public override string Name { get; } = "Assets"; + + public BackupAssets(IStore store, + IAssetStore assetStore, + IAssetRepository assetRepository, + ITagService tagService) + : base(store) + { + Guard.NotNull(assetStore, nameof(assetStore)); + Guard.NotNull(assetRepository, nameof(assetRepository)); + Guard.NotNull(tagService, nameof(tagService)); + + this.assetStore = assetStore; + this.assetRepository = assetRepository; + this.tagService = tagService; + } + + public override Task BackupAsync(Guid appId, BackupWriter writer) + { + return BackupTagsAsync(appId, writer); + } + + public override Task BackupEventAsync(Envelope @event, Guid appId, BackupWriter writer) + { + switch (@event.Payload) + { + case AssetCreated assetCreated: + return WriteAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, writer); + case AssetUpdated assetUpdated: + return WriteAssetAsync(assetUpdated.AssetId, assetUpdated.FileVersion, writer); + } + + return TaskHelper.Done; + } + + public override Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + { + switch (@event.Payload) + { + case AssetCreated assetCreated: + return ReadAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, reader); + case AssetUpdated assetUpdated: + return ReadAssetAsync(assetUpdated.AssetId, assetUpdated.FileVersion, reader); + } + + return TaskHelper.Done; + } + + public override async Task RestoreAsync(Guid appId, BackupReader reader) + { + await RestoreTagsAsync(appId, reader); + + await RebuildManyAsync(assetIds, id => RebuildAsync(id, (e, s) => s.Apply(e))); + } + + private async Task RestoreTagsAsync(Guid appId, BackupReader reader) + { + var tags = await reader.ReadJsonAttachmentAsync(TagsFile); + + await tagService.RebuildTagsAsync(appId, TagGroups.Assets, tags.ToObject()); + } + + private async Task BackupTagsAsync(Guid appId, BackupWriter writer) + { + var tags = await tagService.GetExportableTagsAsync(appId, TagGroups.Assets); + + await writer.WriteJsonAsync(TagsFile, JObject.FromObject(tags)); + } + + private Task WriteAssetAsync(Guid assetId, long fileVersion, BackupWriter writer) + { + return writer.WriteBlobAsync(GetName(assetId, fileVersion), stream => + { + return assetStore.DownloadAsync(assetId.ToString(), fileVersion, null, stream); + }); + } + + private Task ReadAssetAsync(Guid assetId, long fileVersion, BackupReader reader) + { + assetIds.Add(assetId); + + return reader.ReadBlobAsync(GetName(reader.OldGuid(assetId), fileVersion), async stream => + { + try + { + await assetStore.UploadAsync(assetId.ToString(), fileVersion, null, stream); + } + catch (AssetAlreadyExistsException) + { + return; + } + }); + } + + private static string GetName(Guid assetId, long fileVersion) + { + return $"{assetId}_{fileVersion}.asset"; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs index f748b4936..0410a5b4f 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -19,5 +19,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories Task> QueryAsync(Guid appId, HashSet ids); Task FindAssetAsync(Guid id); + + Task RemoveAsync(Guid appId); } } diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs index f1d0a8882..21067ce66 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs @@ -11,36 +11,37 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using NodaTime; -using Orleans; using Orleans.Concurrency; +using Squidex.Domain.Apps.Entities.Backup.Helpers; using Squidex.Domain.Apps.Entities.Backup.State; using Squidex.Domain.Apps.Events; -using Squidex.Domain.Apps.Events.Assets; using Squidex.Infrastructure; using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Entities.Backup { [Reentrant] - public sealed class BackupGrain : Grain, IBackupGrain + public sealed class BackupGrain : GrainOfGuid, IBackupGrain { private const int MaxBackups = 10; private static readonly Duration UpdateDuration = Duration.FromSeconds(1); - private readonly IClock clock; private readonly IAssetStore assetStore; + private readonly IBackupArchiveLocation backupArchiveLocation; + private readonly IClock clock; + private readonly IEnumerable handlers; private readonly IEventDataFormatter eventDataFormatter; - private readonly ISemanticLog log; private readonly IEventStore eventStore; - private readonly IBackupArchiveLocation backupArchiveLocation; + private readonly ISemanticLog log; private readonly IStore store; private CancellationTokenSource currentTask; private BackupStateJob currentJob; - private Guid appId; private BackupState state = new BackupState(); + private Guid appId; private IPersistence persistence; public BackupGrain( @@ -49,6 +50,7 @@ namespace Squidex.Domain.Apps.Entities.Backup IClock clock, IEventStore eventStore, IEventDataFormatter eventDataFormatter, + IEnumerable handlers, ISemanticLog log, IStore store) { @@ -57,6 +59,7 @@ namespace Squidex.Domain.Apps.Entities.Backup Guard.NotNull(clock, nameof(clock)); Guard.NotNull(eventStore, nameof(eventStore)); Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter)); + Guard.NotNull(handlers, nameof(handlers)); Guard.NotNull(store, nameof(store)); Guard.NotNull(log, nameof(log)); @@ -65,36 +68,28 @@ namespace Squidex.Domain.Apps.Entities.Backup this.clock = clock; this.eventStore = eventStore; this.eventDataFormatter = eventDataFormatter; + this.handlers = handlers; this.store = store; this.log = log; } - public override Task OnActivateAsync() - { - return OnActivateAsync(this.GetPrimaryKey()); - } - - public async Task OnActivateAsync(Guid appId) + public override async Task OnActivateAsync(Guid key) { - this.appId = appId; + appId = key; - persistence = store.WithSnapshots(GetType(), appId, s => state = s); + persistence = store.WithSnapshots(GetType(), key, s => state = s); await ReadAsync(); - await CleanupAsync(); - } - private async Task ReadAsync() - { - await persistence.ReadAsync(); + RecoverAfterRestart(); } - private async Task WriteAsync() + private void RecoverAfterRestart() { - await persistence.WriteSnapshotAsync(state); + RecoverAfterRestartAsync().Forget(); } - private async Task CleanupAsync() + private async Task RecoverAfterRestartAsync() { foreach (var job in state.Jobs) { @@ -102,46 +97,16 @@ namespace Squidex.Domain.Apps.Entities.Backup { job.Stopped = clock.GetCurrentInstant(); - await CleanupArchiveAsync(job); - await CleanupBackupAsync(job); + await Safe.DeleteAsync(backupArchiveLocation, job.Id, log); + await Safe.DeleteAsync(assetStore, job.Id, log); - job.IsFailed = true; + job.Status = JobStatus.Failed; await WriteAsync(); } } } - private async Task CleanupBackupAsync(BackupStateJob job) - { - try - { - await assetStore.DeleteAsync(job.Id.ToString(), 0, null); - } - catch (Exception ex) - { - log.LogError(ex, w => w - .WriteProperty("action", "deleteBackup") - .WriteProperty("status", "failed") - .WriteProperty("backupId", job.Id.ToString())); - } - } - - private async Task CleanupArchiveAsync(BackupStateJob job) - { - try - { - await backupArchiveLocation.DeleteArchiveAsync(job.Id); - } - catch (Exception ex) - { - log.LogError(ex, w => w - .WriteProperty("action", "deleteArchive") - .WriteProperty("status", "failed") - .WriteProperty("backupId", job.Id.ToString())); - } - } - public async Task RunAsync() { if (currentTask != null) @@ -154,7 +119,12 @@ namespace Squidex.Domain.Apps.Entities.Backup throw new DomainException($"You cannot have more than {MaxBackups} backups."); } - var job = new BackupStateJob { Id = Guid.NewGuid(), Started = clock.GetCurrentInstant() }; + var job = new BackupStateJob + { + Id = Guid.NewGuid(), + Started = clock.GetCurrentInstant(), + Status = JobStatus.Started + }; currentTask = new CancellationTokenSource(); currentJob = job; @@ -169,55 +139,34 @@ namespace Squidex.Domain.Apps.Entities.Backup { using (var stream = await backupArchiveLocation.OpenStreamAsync(job.Id)) { - using (var writer = new EventStreamWriter(stream)) + using (var writer = new BackupWriter(stream, true)) { - await eventStore.QueryAsync(async @event => + await eventStore.QueryAsync(async storedEvent => { - var eventData = @event.Data; - - if (eventData.Type == "AssetCreatedEvent" || - eventData.Type == "AssetUpdatedEvent") - { - var parsedEvent = eventDataFormatter.Parse(eventData); - - var assetVersion = 0L; - var assetId = Guid.Empty; + var @event = eventDataFormatter.Parse(storedEvent.Data); - if (parsedEvent.Payload is AssetCreated assetCreated) - { - assetId = assetCreated.AssetId; - assetVersion = assetCreated.FileVersion; - } + writer.WriteEvent(storedEvent); - if (parsedEvent.Payload is AssetUpdated asetUpdated) - { - assetId = asetUpdated.AssetId; - assetVersion = asetUpdated.FileVersion; - } - - await writer.WriteEventAsync(eventData, async attachmentStream => - { - await assetStore.DownloadAsync(assetId.ToString(), assetVersion, null, attachmentStream); - }); - - job.HandledAssets++; - } - else + foreach (var handler in handlers) { - await writer.WriteEventAsync(eventData); + await handler.BackupEventAsync(@event, appId, writer); } - job.HandledEvents++; + job.HandledEvents = writer.WrittenEvents; + job.HandledAssets = writer.WrittenAttachments; - var now = clock.GetCurrentInstant(); + lastTimestamp = await WritePeriodically(lastTimestamp); + }, SquidexHeaders.AppId, appId.ToString(), null, currentTask.Token); - if ((now - lastTimestamp) >= UpdateDuration) - { - lastTimestamp = now; + foreach (var handler in handlers) + { + await handler.BackupAsync(appId, writer); + } - await WriteAsync(); - } - }, SquidexHeaders.AppId, appId.ToString(), null, currentTask.Token); + foreach (var handler in handlers) + { + await handler.CompleteBackupAsync(appId, writer); + } } stream.Position = 0; @@ -226,6 +175,8 @@ namespace Squidex.Domain.Apps.Entities.Backup await assetStore.UploadAsync(job.Id.ToString(), 0, null, stream, currentTask.Token); } + + job.Status = JobStatus.Completed; } catch (Exception ex) { @@ -234,11 +185,11 @@ namespace Squidex.Domain.Apps.Entities.Backup .WriteProperty("status", "failed") .WriteProperty("backupId", job.Id.ToString())); - job.IsFailed = true; + job.Status = JobStatus.Failed; } finally { - await CleanupArchiveAsync(job); + await Safe.DeleteAsync(backupArchiveLocation, job.Id, log); job.Stopped = clock.GetCurrentInstant(); @@ -249,6 +200,20 @@ namespace Squidex.Domain.Apps.Entities.Backup } } + private async Task WritePeriodically(Instant lastTimestamp) + { + var now = clock.GetCurrentInstant(); + + if ((now - lastTimestamp) >= UpdateDuration) + { + lastTimestamp = now; + + await WriteAsync(); + } + + return lastTimestamp; + } + public async Task DeleteAsync(Guid id) { var job = state.Jobs.FirstOrDefault(x => x.Id == id); @@ -264,8 +229,8 @@ namespace Squidex.Domain.Apps.Entities.Backup } else { - await CleanupArchiveAsync(job); - await CleanupBackupAsync(job); + await Safe.DeleteAsync(backupArchiveLocation, job.Id, log); + await Safe.DeleteAsync(assetStore, job.Id, log); state.Jobs.Remove(job); @@ -278,9 +243,14 @@ namespace Squidex.Domain.Apps.Entities.Backup return J.AsTask(state.Jobs.OfType().ToList()); } - private bool IsRunning() + private async Task ReadAsync() + { + await persistence.ReadAsync(); + } + + private async Task WriteAsync() { - return state.Jobs.Any(x => !x.Stopped.HasValue); + await persistence.WriteSnapshotAsync(state); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs new file mode 100644 index 000000000..d640535e0 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs @@ -0,0 +1,55 @@ +// ========================================================================== +// 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; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public abstract class BackupHandler + { + public abstract string Name { get; } + + public virtual Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + { + return TaskHelper.Done; + } + + public virtual Task BackupEventAsync(Envelope @event, Guid appId, BackupWriter writer) + { + return TaskHelper.Done; + } + + public virtual Task RestoreAsync(Guid appId, BackupReader reader) + { + return TaskHelper.Done; + } + + public virtual Task BackupAsync(Guid appId, BackupWriter writer) + { + return TaskHelper.Done; + } + + public virtual Task CleanupRestoreAsync(Guid appId) + { + return TaskHelper.Done; + } + + public virtual Task CompleteRestoreAsync(Guid appId, BackupReader reader) + { + return TaskHelper.Done; + } + + public virtual Task CompleteBackupAsync(Guid appId, BackupWriter writer) + { + return TaskHelper.Done; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupHandlerWithStore.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupHandlerWithStore.cs new file mode 100644 index 000000000..d6a2eba0d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupHandlerWithStore.cs @@ -0,0 +1,60 @@ +// ========================================================================== +// 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.EventSourcing; +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, nameof(store)); + + this.store = store; + } + + protected Task RemoveSnapshotAsync(Guid id) + { + return store.RemoveSnapshotAsync(id); + } + + protected async Task RebuildManyAsync(IEnumerable ids, Func action) + { + foreach (var id in ids) + { + await action(id); + } + } + + protected async Task RebuildAsync(Guid key, Func, TState, TState> func) where TState : IDomainState, new() + { + var state = new TState + { + Version = EtagVersion.Empty + }; + + var persistence = store.WithSnapshotsAndEventSourcing(typeof(TGrain), key, s => state = s, e => + { + state = func(e, state); + + state.Version++; + }); + + await persistence.ReadAsync(); + await persistence.WriteSnapshotAsync(state); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs new file mode 100644 index 000000000..cc49fe3e5 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs @@ -0,0 +1,149 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Entities.Backup.Archive; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed class BackupReader : DisposableObjectBase + { + private static readonly JsonSerializer Serializer = new JsonSerializer(); + private readonly GuidMapper guidMapper = new GuidMapper(); + private readonly ZipArchive archive; + private int readEvents; + private int readAttachments; + + public int ReadEvents + { + get { return readEvents; } + } + + public int ReadAttachments + { + get { return readAttachments; } + } + + public BackupReader(Stream stream) + { + archive = new ZipArchive(stream, ZipArchiveMode.Read, false); + } + + protected override void DisposeObject(bool disposing) + { + if (disposing) + { + archive.Dispose(); + } + } + + public Guid OldGuid(Guid newId) + { + return guidMapper.OldGuid(newId); + } + + public async Task ReadJsonAttachmentAsync(string name) + { + Guard.NotNullOrEmpty(name, nameof(name)); + + var attachmentEntry = archive.GetEntry(ArchiveHelper.GetAttachmentPath(name)); + + if (attachmentEntry == null) + { + throw new FileNotFoundException("Cannot find attachment.", name); + } + + JToken result; + + using (var stream = attachmentEntry.Open()) + { + using (var textReader = new StreamReader(stream)) + { + using (var jsonReader = new JsonTextReader(textReader)) + { + result = await JToken.ReadFromAsync(jsonReader); + + guidMapper.NewGuids(result); + } + } + } + + readAttachments++; + + return result; + } + + public async Task ReadBlobAsync(string name, Func handler) + { + Guard.NotNullOrEmpty(name, nameof(name)); + Guard.NotNull(handler, nameof(handler)); + + var attachmentEntry = archive.GetEntry(ArchiveHelper.GetAttachmentPath(name)); + + if (attachmentEntry == null) + { + throw new FileNotFoundException("Cannot find attachment.", name); + } + + using (var stream = attachmentEntry.Open()) + { + await handler(stream); + } + + readAttachments++; + } + + public async Task ReadEventsAsync(IStreamNameResolver streamNameResolver, Func handler) + { + Guard.NotNull(handler, nameof(handler)); + Guard.NotNull(streamNameResolver, nameof(streamNameResolver)); + + while (true) + { + var eventEntry = archive.GetEntry(ArchiveHelper.GetEventPath(readEvents)); + + if (eventEntry == null) + { + break; + } + + using (var stream = eventEntry.Open()) + { + using (var textReader = new StreamReader(stream)) + { + using (var jsonReader = new JsonTextReader(textReader)) + { + var storedEvent = Serializer.Deserialize(jsonReader); + + storedEvent.Data.Payload = guidMapper.NewGuids(storedEvent.Data.Payload); + storedEvent.Data.Metadata = guidMapper.NewGuids(storedEvent.Data.Metadata); + + var streamName = streamNameResolver.WithNewId(storedEvent.StreamName, guidMapper.NewGuidString); + + storedEvent = new StoredEvent(streamName, + storedEvent.EventPosition, + storedEvent.EventStreamNumber, + storedEvent.Data); + + await handler(storedEvent); + } + } + } + + readEvents++; + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupRestoreException.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupRestoreException.cs new file mode 100644 index 000000000..f7fec5453 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupRestoreException.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.Runtime.Serialization; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + [Serializable] + public class BackupRestoreException : Exception + { + public BackupRestoreException(string message) + : base(message) + { + } + + public BackupRestoreException(string message, Exception inner) + : base(message, inner) + { + } + + protected BackupRestoreException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs new file mode 100644 index 000000000..920ae4641 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.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.IO; +using System.IO.Compression; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Entities.Backup.Archive; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed class BackupWriter : DisposableObjectBase + { + private static readonly JsonSerializer Serializer = new JsonSerializer(); + private readonly ZipArchive archive; + private int writtenEvents; + private int writtenAttachments; + + public int WrittenEvents + { + get { return writtenEvents; } + } + + public int WrittenAttachments + { + get { return writtenAttachments; } + } + + public BackupWriter(Stream stream, bool keepOpen = false) + { + archive = new ZipArchive(stream, ZipArchiveMode.Create, keepOpen); + } + + protected override void DisposeObject(bool disposing) + { + if (disposing) + { + archive.Dispose(); + } + } + + public async Task WriteJsonAsync(string name, JToken value) + { + Guard.NotNullOrEmpty(name, nameof(name)); + + var attachmentEntry = archive.CreateEntry(ArchiveHelper.GetAttachmentPath(name)); + + using (var stream = attachmentEntry.Open()) + { + using (var textWriter = new StreamWriter(stream)) + { + using (var jsonWriter = new JsonTextWriter(textWriter)) + { + await value.WriteToAsync(jsonWriter); + } + } + } + + writtenAttachments++; + } + + public async Task WriteBlobAsync(string name, Func handler) + { + Guard.NotNullOrEmpty(name, nameof(name)); + Guard.NotNull(handler, nameof(handler)); + + var attachmentEntry = archive.CreateEntry(ArchiveHelper.GetAttachmentPath(name)); + + using (var stream = attachmentEntry.Open()) + { + await handler(stream); + } + + writtenAttachments++; + } + + public void WriteEvent(StoredEvent storedEvent) + { + Guard.NotNull(storedEvent, nameof(storedEvent)); + + var eventEntry = archive.CreateEntry(ArchiveHelper.GetEventPath(writtenEvents)); + + using (var stream = eventEntry.Open()) + { + using (var textWriter = new StreamWriter(stream)) + { + using (var jsonWriter = new JsonTextWriter(textWriter)) + { + Serializer.Serialize(jsonWriter, storedEvent); + } + } + } + + writtenEvents++; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/EventStreamWriter.cs b/src/Squidex.Domain.Apps.Entities/Backup/EventStreamWriter.cs deleted file mode 100644 index 7133985ac..000000000 --- a/src/Squidex.Domain.Apps.Entities/Backup/EventStreamWriter.cs +++ /dev/null @@ -1,79 +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.IO.Compression; -using System.Threading.Tasks; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; - -namespace Squidex.Domain.Apps.Entities.Backup -{ - public sealed class EventStreamWriter : DisposableObjectBase - { - private const int MaxItemsPerFolder = 1000; - private readonly ZipArchive archive; - private int writtenEvents; - private int writtenAttachments; - - public EventStreamWriter(Stream stream) - { - archive = new ZipArchive(stream, ZipArchiveMode.Update, true); - } - - public async Task WriteEventAsync(EventData eventData, Func attachment = null) - { - var eventObject = - new JObject( - new JProperty("type", eventData.Type), - new JProperty("payload", eventData.Payload), - new JProperty("metadata", eventData.Metadata)); - - var eventFolder = writtenEvents / MaxItemsPerFolder; - var eventPath = $"events/{eventFolder}/{writtenEvents}.json"; - var eventEntry = archive.GetEntry(eventPath) ?? archive.CreateEntry(eventPath); - - using (var stream = eventEntry.Open()) - { - using (var textWriter = new StreamWriter(stream)) - { - using (var jsonWriter = new JsonTextWriter(textWriter)) - { - await eventObject.WriteToAsync(jsonWriter); - } - } - } - - writtenEvents++; - - if (attachment != null) - { - var attachmentFolder = writtenAttachments / MaxItemsPerFolder; - var attachmentPath = $"attachments/{attachmentFolder}/{writtenEvents}.blob"; - var attachmentEntry = archive.GetEntry(attachmentPath) ?? archive.CreateEntry(attachmentPath); - - using (var stream = attachmentEntry.Open()) - { - await attachment(stream); - } - - writtenAttachments++; - } - } - - protected override void DisposeObject(bool disposing) - { - if (disposing) - { - archive.Dispose(); - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/GuidMapper.cs b/src/Squidex.Domain.Apps.Entities/Backup/GuidMapper.cs new file mode 100644 index 000000000..c0f7ac827 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/GuidMapper.cs @@ -0,0 +1,170 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed class GuidMapper + { + private static readonly int GuidLength = Guid.Empty.ToString().Length; + private readonly List<(JObject Source, string NewKey, string OldKey)> mappings = new List<(JObject Source, string NewKey, string OldKey)>(); + private readonly Dictionary oldToNewGuid = new Dictionary(); + private readonly Dictionary newToOldGuid = new Dictionary(); + + public Guid NewGuid(Guid oldGuid) + { + return oldToNewGuid.GetOrDefault(oldGuid); + } + + public Guid OldGuid(Guid newGuid) + { + return newToOldGuid.GetOrDefault(newGuid); + } + + public string NewGuidString(string key) + { + if (Guid.TryParse(key, out var guid)) + { + return GenerateNewGuid(guid).ToString(); + } + + return null; + } + + public JToken NewGuids(JToken jToken) + { + var result = NewGuidsCore(jToken); + + if (mappings.Count > 0) + { + foreach (var mapping in mappings) + { + if (mapping.Source.TryGetValue(mapping.OldKey, out var value)) + { + mapping.Source.Remove(mapping.OldKey); + mapping.Source[mapping.NewKey] = value; + } + } + + mappings.Clear(); + } + + return result; + } + + private JToken NewGuidsCore(JToken jToken) + { + switch (jToken.Type) + { + case JTokenType.String: + if (TryConvertString(jToken.ToString(), out var result)) + { + return result; + } + + break; + case JTokenType.Guid: + return GenerateNewGuid((Guid)jToken); + case JTokenType.Object: + NewGuidsCore((JObject)jToken); + break; + case JTokenType.Array: + NewGuidsCore((JArray)jToken); + break; + } + + return jToken; + } + + private void NewGuidsCore(JArray jArray) + { + for (var i = 0; i < jArray.Count; i++) + { + jArray[i] = NewGuidsCore(jArray[i]); + } + } + + private void NewGuidsCore(JObject jObject) + { + foreach (var jProperty in jObject.Properties()) + { + var newValue = NewGuidsCore(jProperty.Value); + + if (!ReferenceEquals(newValue, jProperty.Value)) + { + jProperty.Value = newValue; + } + + if (TryConvertString(jProperty.Name, out var newKey)) + { + mappings.Add((jObject, newKey, jProperty.Name)); + } + } + } + + private bool TryConvertString(string value, out string result) + { + return TryGenerateNewGuidString(value, out result) || TryGenerateNewNamedId(value, out result); + } + + private bool TryGenerateNewGuidString(string value, out string result) + { + result = null; + + if (value.Length == GuidLength) + { + if (Guid.TryParse(value, out var guid)) + { + var newGuid = GenerateNewGuid(guid); + + result = newGuid.ToString(); + + return true; + } + } + + return false; + } + + private bool TryGenerateNewNamedId(string value, out string result) + { + result = null; + + if (value.Length > GuidLength && value[GuidLength] == ',') + { + if (Guid.TryParse(value.Substring(0, GuidLength), out var guid)) + { + var newGuid = GenerateNewGuid(guid); + + result = newGuid + value.Substring(GuidLength); + + return true; + } + } + + return false; + } + + private Guid GenerateNewGuid(Guid oldGuid) + { + return oldToNewGuid.GetOrAdd(oldGuid, GuidGenerator); + } + + private Guid GuidGenerator(Guid oldGuid) + { + var newGuid = Guid.NewGuid(); + + newToOldGuid[newGuid] = oldGuid; + + return newGuid; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/Helpers/ArchiveHelper.cs b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/ArchiveHelper.cs new file mode 100644 index 000000000..ed0c5778c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/ArchiveHelper.cs @@ -0,0 +1,50 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Entities.Backup.Archive +{ + public static class ArchiveHelper + { + private const int MaxAttachmentFolders = 1000; + private const int MaxEventsPerFolder = 1000; + + public static string GetAttachmentPath(string name) + { + name = name.ToLowerInvariant(); + + var attachmentFolder = SimpleHash(name) % MaxAttachmentFolders; + var attachmentPath = $"attachments/{attachmentFolder}/{name}"; + + return attachmentPath; + } + + public static string GetEventPath(int index) + { + var eventFolder = index / MaxEventsPerFolder; + var eventPath = $"events/{eventFolder}/{index}.json"; + + return eventPath; + } + + private static int SimpleHash(string value) + { + var hash = 17; + + foreach (char c in value) + { + unchecked + { + hash = (hash * 23) + c.GetHashCode(); + } + } + + return Math.Abs(hash); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs new file mode 100644 index 000000000..f002efa81 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs @@ -0,0 +1,66 @@ +// ========================================================================== +// 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; + +namespace Squidex.Domain.Apps.Entities.Backup.Helpers +{ + public static class Downloader + { + public static async Task DownloadAsync(this IBackupArchiveLocation backupArchiveLocation, Uri url, Guid id) + { + 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, Guid id) + { + Stream stream = null; + + try + { + stream = await backupArchiveLocation.OpenStreamAsync(id); + + return new BackupReader(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/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Safe.cs b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Safe.cs new file mode 100644 index 000000000..d599881b6 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Safe.cs @@ -0,0 +1,62 @@ +// ========================================================================== +// 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, Guid id, ISemanticLog log) + { + try + { + await backupArchiveLocation.DeleteArchiveAsync(id); + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", "deleteArchive") + .WriteProperty("status", "failed") + .WriteProperty("operationId", id.ToString())); + } + } + + public static async Task DeleteAsync(IAssetStore assetStore, Guid id, ISemanticLog log) + { + try + { + await assetStore.DeleteAsync(id.ToString(), 0, null); + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", "deleteBackup") + .WriteProperty("status", "failed") + .WriteProperty("operationId", id.ToString())); + } + } + + public static async Task CleanupRestoreAsync(BackupHandler handler, Guid appId, Guid id, ISemanticLog log) + { + try + { + await handler.CleanupRestoreAsync(appId); + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", "cleanupRestore") + .WriteProperty("status", "failed") + .WriteProperty("operationId", id.ToString())); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs b/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs index 4142fd5e0..56fe0a19e 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs @@ -22,6 +22,6 @@ namespace Squidex.Domain.Apps.Entities.Backup int HandledAssets { get; } - bool IsFailed { get; } + JobStatus Status { get; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs b/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs new file mode 100644 index 000000000..6ee281401 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.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.Threading.Tasks; +using Orleans; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public interface IRestoreGrain : IGrainWithStringKey + { + Task RestoreAsync(Uri url, string newAppName = null); + + Task> GetJobAsync(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs b/src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs new file mode 100644 index 000000000..fdd5306d8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using NodaTime; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public interface IRestoreJob + { + Uri Url { get; } + + Instant Started { get; } + + Instant? Stopped { get; } + + List Log { get; } + + JobStatus Status { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/JobStatus.cs b/src/Squidex.Domain.Apps.Entities/Backup/JobStatus.cs new file mode 100644 index 000000000..26f6f541c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/JobStatus.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public enum JobStatus + { + Created, + Started, + Completed, + Failed + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs b/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs new file mode 100644 index 000000000..134367e4b --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs @@ -0,0 +1,335 @@ +// ========================================================================== +// 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 NodaTime; +using Orleans; +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.Assets; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed class RestoreGrain : GrainOfString, IRestoreGrain + { + private readonly IAssetStore assetStore; + private readonly IBackupArchiveLocation backupArchiveLocation; + private readonly IClock clock; + private readonly IEnumerable handlers; + private readonly IEventStore eventStore; + private readonly IEventDataFormatter eventDataFormatter; + private readonly IGrainFactory grainFactory; + private readonly ISemanticLog log; + private readonly IStreamNameResolver streamNameResolver; + private readonly IStore store; + private RefToken actor; + private RestoreState state = new RestoreState(); + private IPersistence persistence; + + private RestoreStateJob CurrentJob + { + get { return state.Job; } + } + + public RestoreGrain( + IAssetStore assetStore, + IBackupArchiveLocation backupArchiveLocation, + IClock clock, + IEventStore eventStore, + IEventDataFormatter eventDataFormatter, + IGrainFactory grainFactory, + IEnumerable handlers, + ISemanticLog log, + IStreamNameResolver streamNameResolver, + IStore store) + { + Guard.NotNull(assetStore, nameof(assetStore)); + Guard.NotNull(backupArchiveLocation, nameof(backupArchiveLocation)); + Guard.NotNull(clock, nameof(clock)); + Guard.NotNull(eventStore, nameof(eventStore)); + Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter)); + Guard.NotNull(grainFactory, nameof(grainFactory)); + Guard.NotNull(handlers, nameof(handlers)); + Guard.NotNull(store, nameof(store)); + Guard.NotNull(streamNameResolver, nameof(streamNameResolver)); + Guard.NotNull(log, nameof(log)); + + this.assetStore = assetStore; + this.backupArchiveLocation = backupArchiveLocation; + this.clock = clock; + this.eventStore = eventStore; + this.eventDataFormatter = eventDataFormatter; + this.grainFactory = grainFactory; + this.handlers = handlers; + this.store = store; + this.streamNameResolver = streamNameResolver; + this.log = log; + } + + public override async Task OnActivateAsync(string key) + { + actor = new RefToken(RefTokenType.Subject, key); + + persistence = store.WithSnapshots(GetType(), key, s => state = s); + + await ReadAsync(); + + RecoverAfterRestart(); + } + + private void RecoverAfterRestart() + { + RecoverAfterRestartAsync().Forget(); + } + + private async Task RecoverAfterRestartAsync() + { + if (CurrentJob?.Status == JobStatus.Started) + { + Log("Failed due application restart"); + + CurrentJob.Status = JobStatus.Failed; + + await CleanupAsync(); + await WriteAsync(); + } + } + + public Task RestoreAsync(Uri url, string newAppName) + { + Guard.NotNull(url, nameof(url)); + + if (newAppName != null) + { + Guard.ValidSlug(newAppName, nameof(newAppName)); + } + + if (CurrentJob?.Status == JobStatus.Started) + { + throw new DomainException("A restore operation is already running."); + } + + state.Job = new RestoreStateJob + { + Id = Guid.NewGuid(), + NewAppName = newAppName, + Started = clock.GetCurrentInstant(), + Status = JobStatus.Started, + Url = url + }; + + Process(); + + return TaskHelper.Done; + } + + private void Process() + { + ProcessAsync().Forget(); + } + + private async Task ProcessAsync() + { + using (Profiler.StartSession()) + { + try + { + Log("Started. The restore process has the following steps:"); + Log(" * Download backup"); + Log(" * Restore events and attachments."); + Log(" * Restore all objects like app, schemas and contents"); + Log(" * Complete the restore operation for all objects"); + + log.LogInformation(w => w + .WriteProperty("action", "restore") + .WriteProperty("status", "started") + .WriteProperty("operationId", CurrentJob.Id.ToString()) + .WriteProperty("url", CurrentJob.Url.ToString())); + + using (Profiler.Trace("Download")) + { + await DownloadAsync(); + } + + using (var reader = await backupArchiveLocation.OpenArchiveAsync(CurrentJob.Id)) + { + using (Profiler.Trace("ReadEvents")) + { + await ReadEventsAsync(reader); + } + + foreach (var handler in handlers) + { + using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.RestoreAsync))) + { + await handler.RestoreAsync(CurrentJob.AppId, reader); + } + + Log($"Restored {handler.Name}"); + } + + foreach (var handler in handlers) + { + using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.CompleteRestoreAsync))) + { + await handler.CompleteRestoreAsync(CurrentJob.AppId, reader); + } + + Log($"Completed {handler.Name}"); + } + } + + CurrentJob.Status = JobStatus.Completed; + + Log("Completed, Yeah!"); + + log.LogInformation(w => + { + w.WriteProperty("action", "restore"); + w.WriteProperty("status", "completed"); + w.WriteProperty("operationId", CurrentJob.Id.ToString()); + w.WriteProperty("url", CurrentJob.Url.ToString()); + + Profiler.Session?.Write(w); + }); + } + catch (Exception ex) + { + if (ex is BackupRestoreException backupException) + { + Log(backupException.Message); + } + else + { + Log("Failed with internal error"); + } + + await CleanupAsync(ex); + + CurrentJob.Status = JobStatus.Failed; + + log.LogError(ex, w => + { + w.WriteProperty("action", "retore"); + w.WriteProperty("status", "failed"); + w.WriteProperty("operationId", CurrentJob.Id.ToString()); + w.WriteProperty("url", CurrentJob.Url.ToString()); + + Profiler.Session?.Write(w); + }); + } + finally + { + CurrentJob.Stopped = clock.GetCurrentInstant(); + + await WriteAsync(); + } + } + } + + private async Task CleanupAsync(Exception exception = null) + { + await Safe.DeleteAsync(backupArchiveLocation, CurrentJob.Id, log); + + if (CurrentJob.AppId != Guid.Empty) + { + foreach (var handler in handlers) + { + await Safe.CleanupRestoreAsync(handler, CurrentJob.AppId, CurrentJob.Id, log); + } + } + } + + private async Task DownloadAsync() + { + Log("Downloading Backup"); + + await backupArchiveLocation.DownloadAsync(CurrentJob.Url, CurrentJob.Id); + + Log("Downloaded Backup"); + } + + private async Task ReadEventsAsync(BackupReader reader) + { + await reader.ReadEventsAsync(streamNameResolver, async (storedEvent) => + { + var @event = eventDataFormatter.Parse(storedEvent.Data); + + if (@event.Payload is SquidexEvent squidexEvent) + { + squidexEvent.Actor = actor; + } + + if (@event.Payload is AppCreated appCreated) + { + CurrentJob.AppId = appCreated.AppId.Id; + + if (!string.IsNullOrWhiteSpace(CurrentJob.NewAppName)) + { + appCreated.Name = CurrentJob.NewAppName; + } + } + + if (@event.Payload is AppEvent appEvent && !string.IsNullOrWhiteSpace(CurrentJob.NewAppName)) + { + appEvent.AppId = new NamedId(appEvent.AppId.Id, CurrentJob.NewAppName); + } + + foreach (var handler in handlers) + { + await handler.RestoreEventAsync(@event, CurrentJob.AppId, reader, actor); + } + + var eventData = eventDataFormatter.ToEventData(@event, @event.Headers.CommitId()); + var eventCommit = new List { eventData }; + + await eventStore.AppendAsync(Guid.NewGuid(), storedEvent.StreamName, eventCommit); + + Log($"Read {reader.ReadEvents} events and {reader.ReadAttachments} attachments.", true); + }); + + Log("Reading events completed."); + } + + private void Log(string message, bool replace = false) + { + if (replace && CurrentJob.Log.Count > 0) + { + CurrentJob.Log[CurrentJob.Log.Count - 1] = $"{clock.GetCurrentInstant()}: {message}"; + } + else + { + CurrentJob.Log.Add($"{clock.GetCurrentInstant()}: {message}"); + } + } + + private async Task ReadAsync() + { + await persistence.ReadAsync(); + } + + private async Task WriteAsync() + { + await persistence.WriteSnapshotAsync(state); + } + + public Task> GetJobAsync() + { + return Task.FromResult>(CurrentJob); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs b/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs index 58c5d37b4..6da5e9dfa 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs @@ -29,6 +29,6 @@ namespace Squidex.Domain.Apps.Entities.Backup.State public int HandledAssets { get; set; } [JsonProperty] - public bool IsFailed { get; set; } + public JobStatus Status { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState.cs b/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState.cs new file mode 100644 index 000000000..d86a658fe --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Newtonsoft.Json; + +namespace Squidex.Domain.Apps.Entities.Backup.State +{ + public class RestoreState + { + [JsonProperty] + public RestoreStateJob Job { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs b/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs new file mode 100644 index 000000000..f3fd17042 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs @@ -0,0 +1,44 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using NodaTime; + +namespace Squidex.Domain.Apps.Entities.Backup.State +{ + public sealed class RestoreStateJob : IRestoreJob + { + [JsonProperty] + public string AppName { get; set; } + + [JsonProperty] + public Guid Id { get; set; } + + [JsonProperty] + public Guid AppId { get; set; } + + [JsonProperty] + public Uri Url { get; set; } + + [JsonProperty] + public string NewAppName { get; set; } + + [JsonProperty] + public Instant Started { get; set; } + + [JsonProperty] + public Instant? Stopped { get; set; } + + [JsonProperty] + public List Log { get; set; } = new List(); + + [JsonProperty] + public JobStatus Status { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs b/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs index b710a46ac..7fb773874 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs @@ -18,7 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Backup { var tempFile = GetTempFile(backupId); - return Task.FromResult(new FileStream(tempFile, FileMode.Create, FileAccess.ReadWrite)); + return Task.FromResult(new FileStream(tempFile, FileMode.OpenOrCreate, FileAccess.ReadWrite)); } public Task DeleteArchiveAsync(Guid backupId) @@ -38,7 +38,7 @@ namespace Squidex.Domain.Apps.Entities.Backup private static string GetTempFile(Guid backupId) { - return Path.Combine(Path.GetTempPath(), backupId.ToString()); + return Path.Combine(Path.GetTempPath(), backupId.ToString() + ".zip"); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs b/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs new file mode 100644 index 000000000..38b905248 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.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.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Entities.Contents.State; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class BackupContents : BackupHandlerWithStore + { + private readonly HashSet contentIds = new HashSet(); + private readonly IContentRepository contentRepository; + + public override string Name { get; } = "Contents"; + + public BackupContents(IStore store, IContentRepository contentRepository) + : base(store) + { + Guard.NotNull(contentRepository, nameof(contentRepository)); + + this.contentRepository = contentRepository; + } + + public override Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + { + switch (@event.Payload) + { + case ContentCreated contentCreated: + contentIds.Add(contentCreated.ContentId); + break; + } + + return TaskHelper.Done; + } + + public override Task RestoreAsync(Guid appId, BackupReader reader) + { + return RebuildManyAsync(contentIds, id => RebuildAsync(id, (e, s) => s.Apply(e))); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs b/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs index 36da4d974..819161319 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs @@ -28,5 +28,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Status[] status, Guid id); Task QueryScheduledWithoutDataAsync(Instant now, Func callback); + + Task RemoveAsync(Guid appId); } } diff --git a/src/Squidex.Domain.Apps.Entities/History/Repositories/IHistoryEventRepository.cs b/src/Squidex.Domain.Apps.Entities/History/Repositories/IHistoryEventRepository.cs index 2810f7e18..02a8d6e5a 100644 --- a/src/Squidex.Domain.Apps.Entities/History/Repositories/IHistoryEventRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/History/Repositories/IHistoryEventRepository.cs @@ -14,5 +14,7 @@ namespace Squidex.Domain.Apps.Entities.History.Repositories public interface IHistoryEventRepository { Task> QueryByChannelAsync(Guid appId, string channelPrefix, int count); + + Task RemoveAsync(Guid appId); } } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/BackupRules.cs b/src/Squidex.Domain.Apps.Entities/Rules/BackupRules.cs new file mode 100644 index 000000000..4700dc163 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/BackupRules.cs @@ -0,0 +1,65 @@ +// ========================================================================== +// 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.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Rules.Indexes; +using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Domain.Apps.Entities.Rules.State; +using Squidex.Domain.Apps.Events.Rules; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public sealed class BackupRules : BackupHandlerWithStore + { + private readonly HashSet ruleIds = new HashSet(); + private readonly IGrainFactory grainFactory; + private readonly IRuleEventRepository ruleEventRepository; + + public override string Name { get; } = "Rules"; + + public BackupRules(IStore store, IGrainFactory grainFactory, IRuleEventRepository ruleEventRepository) + : base(store) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); + + this.grainFactory = grainFactory; + + this.ruleEventRepository = ruleEventRepository; + } + + public override Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + { + switch (@event.Payload) + { + case RuleCreated ruleCreated: + ruleIds.Add(ruleCreated.RuleId); + break; + case RuleDeleted ruleDeleted: + ruleIds.Remove(ruleDeleted.RuleId); + break; + } + + return TaskHelper.Done; + } + + public async override Task RestoreAsync(Guid appId, BackupReader reader) + { + await RebuildManyAsync(ruleIds, id => RebuildAsync(id, (e, s) => s.Apply(e))); + + await grainFactory.GetGrain(appId).RebuildAsync(ruleIds); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Indexes/IRulesByAppIndex.cs b/src/Squidex.Domain.Apps.Entities/Rules/Indexes/IRulesByAppIndex.cs index dd169a045..a58689e4c 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Indexes/IRulesByAppIndex.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Indexes/IRulesByAppIndex.cs @@ -10,7 +10,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Orleans; -namespace Squidex.Domain.Apps.Entities.Rules +namespace Squidex.Domain.Apps.Entities.Rules.Indexes { public interface IRulesByAppIndex : IGrainWithGuidKey { @@ -20,6 +20,8 @@ namespace Squidex.Domain.Apps.Entities.Rules Task RebuildAsync(HashSet rules); + Task ClearAsync(); + Task> GetRuleIdsAsync(); } } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesByAppIndexGrain.cs b/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesByAppIndexGrain.cs index 6f9a49390..911833a74 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesByAppIndexGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesByAppIndexGrain.cs @@ -44,6 +44,13 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes return persistence.ReadAsync(); } + public Task ClearAsync() + { + state = new State(); + + return persistence.DeleteAsync(); + } + public Task RebuildAsync(HashSet rules) { state = new State { Rules = rules }; diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs b/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs index 31c09131b..37b001f72 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs @@ -25,6 +25,8 @@ namespace Squidex.Domain.Apps.Entities.Rules.Repositories Task QueryPendingAsync(Instant now, Func callback, CancellationToken ct = default(CancellationToken)); + Task RemoveAsync(Guid appId); + Task CountByAppAsync(Guid appId); Task> QueryByAppAsync(Guid appId, int skip = 0, int take = 20); diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/BackupSchemas.cs b/src/Squidex.Domain.Apps.Entities/Schemas/BackupSchemas.cs new file mode 100644 index 000000000..600d8e0e1 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/BackupSchemas.cs @@ -0,0 +1,65 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Orleans; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Schemas.Indexes; +using Squidex.Domain.Apps.Entities.Schemas.State; +using Squidex.Domain.Apps.Events.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Schemas +{ + public sealed class BackupSchemas : BackupHandlerWithStore + { + private readonly HashSet> schemaIds = new HashSet>(); + private readonly Dictionary schemasByName = new Dictionary(); + private readonly FieldRegistry fieldRegistry; + private readonly IGrainFactory grainFactory; + + public override string Name { get; } = "Schemas"; + + public BackupSchemas(IStore store, FieldRegistry fieldRegistry, IGrainFactory grainFactory) + : base(store) + { + Guard.NotNull(fieldRegistry, nameof(fieldRegistry)); + Guard.NotNull(grainFactory, nameof(grainFactory)); + + this.fieldRegistry = fieldRegistry; + + this.grainFactory = grainFactory; + } + + public override Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + { + switch (@event.Payload) + { + case SchemaCreated schemaCreated: + schemaIds.Add(schemaCreated.SchemaId); + schemasByName[schemaCreated.SchemaId.Name] = schemaCreated.SchemaId.Id; + break; + } + + return TaskHelper.Done; + } + + public async override Task RestoreAsync(Guid appId, BackupReader reader) + { + await RebuildManyAsync(schemaIds.Select(x => x.Id), id => RebuildAsync(id, (e, s) => s.Apply(e, fieldRegistry))); + + await grainFactory.GetGrain(appId).RebuildAsync(schemasByName); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasByAppIndex.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasByAppIndex.cs index 38104d363..0cffd11a9 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasByAppIndex.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasByAppIndex.cs @@ -10,7 +10,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Orleans; -namespace Squidex.Domain.Apps.Entities.Schemas +namespace Squidex.Domain.Apps.Entities.Schemas.Indexes { public interface ISchemasByAppIndex : IGrainWithGuidKey { @@ -20,6 +20,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas Task RebuildAsync(Dictionary schemas); + Task ClearAsync(); + Task GetSchemaIdAsync(string name); Task> GetSchemaIdsAsync(); diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasByAppIndexGrain.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasByAppIndexGrain.cs index 9164936c9..69eff9348 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasByAppIndexGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasByAppIndexGrain.cs @@ -44,6 +44,13 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes return persistence.ReadAsync(); } + public Task ClearAsync() + { + state = new State(); + + return persistence.DeleteAsync(); + } + public Task RebuildAsync(Dictionary schemas) { state = new State { Schemas = schemas }; diff --git a/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs b/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs index ff869cf80..d88dd023a 100644 --- a/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs +++ b/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs @@ -17,6 +17,11 @@ namespace Squidex.Domain.Apps.Entities.Tags { private readonly IGrainFactory grainFactory; + public string Name + { + get { return "Tags"; } + } + public GrainTagService(IGrainFactory grainFactory) { Guard.NotNull(grainFactory, nameof(grainFactory)); @@ -24,31 +29,46 @@ namespace Squidex.Domain.Apps.Entities.Tags this.grainFactory = grainFactory; } - public Task> NormalizeTagsAsync(Guid appId, string category, HashSet names, HashSet ids) + public Task> NormalizeTagsAsync(Guid appId, string group, HashSet names, HashSet ids) + { + return GetGrain(appId, group).NormalizeTagsAsync(names, ids); + } + + public Task> GetTagIdsAsync(Guid appId, string group, HashSet names) + { + return GetGrain(appId, group).GetTagIdsAsync(names); + } + + public Task> DenormalizeTagsAsync(Guid appId, string group, HashSet ids) + { + return GetGrain(appId, group).DenormalizeTagsAsync(ids); + } + + public Task> GetTagsAsync(Guid appId, string group) { - return GetGrain(appId, category).NormalizeTagsAsync(names, ids); + return GetGrain(appId, group).GetTagsAsync(); } - public Task> GetTagIdsAsync(Guid appId, string category, HashSet names) + public Task GetExportableTagsAsync(Guid appId, string group) { - return GetGrain(appId, category).GetTagIdsAsync(names); + return GetGrain(appId, group).GetExportableTagsAsync(); } - public Task> DenormalizeTagsAsync(Guid appId, string category, HashSet ids) + public Task RebuildTagsAsync(Guid appId, string group, TagSet tags) { - return GetGrain(appId, category).DenormalizeTagsAsync(ids); + return GetGrain(appId, group).RebuildAsync(tags); } - public Task> GetTagsAsync(Guid appId, string category) + public Task ClearAsync(Guid appId, string group) { - return GetGrain(appId, category).GetTagsAsync(); + return GetGrain(appId, group).ClearAsync(); } - private ITagGrain GetGrain(Guid appId, string category) + private ITagGrain GetGrain(Guid appId, string group) { - Guard.NotNullOrEmpty(category, nameof(category)); + Guard.NotNullOrEmpty(group, nameof(group)); - return grainFactory.GetGrain($"{appId}_{category}"); + return grainFactory.GetGrain($"{appId}_{group}"); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs b/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs index a37bf83cb..952702b10 100644 --- a/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs @@ -20,5 +20,11 @@ namespace Squidex.Domain.Apps.Entities.Tags Task> DenormalizeTagsAsync(HashSet ids); Task> GetTagsAsync(); + + Task GetExportableTagsAsync(); + + Task ClearAsync(); + + Task RebuildAsync(TagSet tags); } } diff --git a/src/Squidex.Domain.Apps.Entities/Tags/ITagService.cs b/src/Squidex.Domain.Apps.Entities/Tags/ITagService.cs index 52dae1dc4..01fb3c9e8 100644 --- a/src/Squidex.Domain.Apps.Entities/Tags/ITagService.cs +++ b/src/Squidex.Domain.Apps.Entities/Tags/ITagService.cs @@ -13,12 +13,18 @@ namespace Squidex.Domain.Apps.Entities.Tags { public interface ITagService { - Task> NormalizeTagsAsync(Guid appId, string category, HashSet names, HashSet ids); + Task> NormalizeTagsAsync(Guid appId, string group, HashSet names, HashSet ids); - Task> GetTagIdsAsync(Guid appId, string category, HashSet names); + Task> GetTagIdsAsync(Guid appId, string group, HashSet names); - Task> DenormalizeTagsAsync(Guid appId, string category, HashSet ids); + Task> DenormalizeTagsAsync(Guid appId, string group, HashSet ids); - Task> GetTagsAsync(Guid appId, string category); + Task> GetTagsAsync(Guid appId, string group); + + Task GetExportableTagsAsync(Guid appId, string group); + + Task RebuildTagsAsync(Guid appId, string group, TagSet tags); + + Task ClearAsync(Guid appId, string group); } } diff --git a/src/Squidex.Domain.Apps.Entities/Tags/Tag.cs b/src/Squidex.Domain.Apps.Entities/Tags/Tag.cs new file mode 100644 index 000000000..60fab8187 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Tags/Tag.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Tags +{ + public sealed class Tag + { + public string Name { get; set; } + + public int Count { get; set; } = 1; + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs b/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs index 3517b9476..1e9b76654 100644 --- a/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs @@ -24,14 +24,7 @@ namespace Squidex.Domain.Apps.Entities.Tags [CollectionName("Index_Tags")] public sealed class State { - public Dictionary Tags { get; set; } = new Dictionary(); - } - - public sealed class TagInfo - { - public string Name { get; set; } - - public int Count { get; set; } = 1; + public TagSet Tags { get; set; } = new TagSet(); } public TagGrain(IStore store) @@ -51,6 +44,20 @@ namespace Squidex.Domain.Apps.Entities.Tags return persistence.ReadAsync(); } + public Task ClearAsync() + { + state = new State(); + + return persistence.DeleteAsync(); + } + + public Task RebuildAsync(TagSet tags) + { + state.Tags = tags; + + return persistence.WriteSnapshotAsync(state); + } + public async Task> NormalizeTagsAsync(HashSet names, HashSet ids) { var result = new HashSet(); @@ -79,7 +86,7 @@ namespace Squidex.Domain.Apps.Entities.Tags { tagId = Guid.NewGuid().ToString(); - state.Tags.Add(tagId, new TagInfo { Name = tagName }); + state.Tags.Add(tagId, new Tag { Name = tagName }); } result.Add(tagId); @@ -147,5 +154,10 @@ namespace Squidex.Domain.Apps.Entities.Tags { return Task.FromResult(state.Tags.Values.ToDictionary(x => x.Name, x => x.Count)); } + + public Task GetExportableTagsAsync() + { + return Task.FromResult(state.Tags); + } } } diff --git a/src/Squidex.Domain.Apps.Entities/Tags/TagSet.cs b/src/Squidex.Domain.Apps.Entities/Tags/TagSet.cs new file mode 100644 index 000000000..48815870e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Tags/TagSet.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Entities.Tags +{ + public sealed class TagSet : Dictionary + { + } +} diff --git a/src/Squidex.Infrastructure.Azure/Assets/AzureBlobAssetStore.cs b/src/Squidex.Infrastructure.Azure/Assets/AzureBlobAssetStore.cs index c5f841510..628ebdf20 100644 --- a/src/Squidex.Infrastructure.Azure/Assets/AzureBlobAssetStore.cs +++ b/src/Squidex.Infrastructure.Azure/Assets/AzureBlobAssetStore.cs @@ -57,82 +57,93 @@ namespace Squidex.Infrastructure.Assets return new Uri(blobContainer.StorageUri.PrimaryUri, $"/{containerName}/{blobName}").ToString(); } - public async Task CopyAsync(string name, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)) + public async Task CopyAsync(string sourceFileName, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)) { - var blobName = GetObjectName(id, version, suffix); - var blobRef = blobContainer.GetBlobReference(blobName); + var targetName = GetObjectName(id, version, suffix); + var targetBlob = blobContainer.GetBlobReference(targetName); - var tempBlob = blobContainer.GetBlockBlobReference(name); + var sourceBlob = blobContainer.GetBlockBlobReference(sourceFileName); try { - await blobRef.StartCopyAsync(tempBlob.Uri, null, null, null, null, ct); + await targetBlob.StartCopyAsync(sourceBlob.Uri, null, AccessCondition.GenerateIfNotExistsCondition(), null, null, ct); - while (blobRef.CopyState.Status == CopyStatus.Pending) + while (targetBlob.CopyState.Status == CopyStatus.Pending) { ct.ThrowIfCancellationRequested(); await Task.Delay(50); - await blobRef.FetchAttributesAsync(null, null, null, ct); + await targetBlob.FetchAttributesAsync(null, null, null, ct); } - if (blobRef.CopyState.Status != CopyStatus.Success) + if (targetBlob.CopyState.Status != CopyStatus.Success) { - throw new StorageException($"Copy of temporary file failed: {blobRef.CopyState.Status}"); + throw new StorageException($"Copy of temporary file failed: {targetBlob.CopyState.Status}"); } } + catch (StorageException ex) when (ex.RequestInformation.HttpStatusCode == 409) + { + throw new AssetAlreadyExistsException(targetName); + } catch (StorageException ex) when (ex.RequestInformation.HttpStatusCode == 404) { - throw new AssetNotFoundException($"Asset {name} not found.", ex); + throw new AssetNotFoundException(sourceFileName, ex); } } public async Task DownloadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) { - var blobName = GetObjectName(id, version, suffix); - var blobRef = blobContainer.GetBlockBlobReference(blobName); + var blob = blobContainer.GetBlockBlobReference(GetObjectName(id, version, suffix)); try { - await blobRef.DownloadToStreamAsync(stream, null, null, null, ct); + await blob.DownloadToStreamAsync(stream, null, null, null, ct); } catch (StorageException ex) when (ex.RequestInformation.HttpStatusCode == 404) { - throw new AssetNotFoundException($"Asset {id}, {version} not found.", ex); + throw new AssetNotFoundException($"Id={id}, Version={version}", ex); } } - public async Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) + public Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) { - var blobName = GetObjectName(id, version, suffix); - var blobRef = blobContainer.GetBlockBlobReference(blobName); - - blobRef.Metadata[AssetVersion] = version.ToString(); - blobRef.Metadata[AssetId] = id; + return UploadCoreAsync(GetObjectName(id, version, suffix), stream, ct); + } - await blobRef.UploadFromStreamAsync(stream, null, null, null, ct); - await blobRef.SetMetadataAsync(); + public Task UploadAsync(string fileName, Stream stream, CancellationToken ct = default(CancellationToken)) + { + return UploadCoreAsync(fileName, stream, ct); } - public Task UploadAsync(string name, Stream stream, CancellationToken ct = default(CancellationToken)) + public Task DeleteAsync(string id, long version, string suffix) { - var tempBlob = blobContainer.GetBlockBlobReference(name); + return DeleteCoreAsync(GetObjectName(id, version, suffix)); + } - return tempBlob.UploadFromStreamAsync(stream, null, null, null, ct); + public Task DeleteAsync(string fileName) + { + return DeleteCoreAsync(fileName); } - public Task DeleteAsync(string name) + private Task DeleteCoreAsync(string blobName) { - var tempBlob = blobContainer.GetBlockBlobReference(name); + var blob = blobContainer.GetBlockBlobReference(blobName); - return tempBlob.DeleteIfExistsAsync(); + return blob.DeleteIfExistsAsync(); } - public Task DeleteAsync(string id, long version, string suffix) + private async Task UploadCoreAsync(string blobName, Stream stream, CancellationToken ct) { - var tempBlob = blobContainer.GetBlockBlobReference(GetObjectName(id, version, suffix)); + try + { + var tempBlob = blobContainer.GetBlockBlobReference(blobName); - return tempBlob.DeleteIfExistsAsync(); + await tempBlob.UploadFromStreamAsync(stream, AccessCondition.GenerateIfNotExistsCondition(), null, null, ct); + } + catch (StorageException ex) when (ex.RequestInformation.HttpStatusCode == 409) + { + throw new AssetAlreadyExistsException(blobName); + } } private string GetObjectName(string id, long version, string suffix) diff --git a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Formatter.cs b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Formatter.cs index aefd883b2..c28abf358 100644 --- a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Formatter.cs +++ b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Formatter.cs @@ -24,6 +24,7 @@ namespace Squidex.Infrastructure.EventSourcing var eventData = new EventData { Type = @event.EventType, Payload = body, Metadata = meta }; return new StoredEvent( + @event.EventStreamId, resolvedEvent.OriginalEventNumber.ToString(), resolvedEvent.Event.EventNumber, eventData); diff --git a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs index d21394747..50722a41d 100644 --- a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs +++ b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs @@ -118,6 +118,11 @@ namespace Squidex.Infrastructure.EventSourcing } } + public Task DeleteStreamAsync(string streamName) + { + return connection.DeleteStreamAsync(streamName, ExpectedVersion.Any); + } + public Task AppendAsync(Guid commitId, string streamName, ICollection events) { return AppendEventsInternalAsync(streamName, EtagVersion.Any, events); @@ -163,6 +168,11 @@ namespace Squidex.Infrastructure.EventSourcing } } + public Task DeleteManyAsync(string property, object value) + { + throw new NotSupportedException(); + } + private string GetStreamName(string streamName) { return $"{prefix}-{streamName}"; diff --git a/src/Squidex.Infrastructure.GoogleCloud/Assets/GoogleCloudAssetStore.cs b/src/Squidex.Infrastructure.GoogleCloud/Assets/GoogleCloudAssetStore.cs index 5af2be27a..9a8a31e7c 100644 --- a/src/Squidex.Infrastructure.GoogleCloud/Assets/GoogleCloudAssetStore.cs +++ b/src/Squidex.Infrastructure.GoogleCloud/Assets/GoogleCloudAssetStore.cs @@ -18,6 +18,8 @@ namespace Squidex.Infrastructure.Assets { public sealed class GoogleCloudAssetStore : IAssetStore, IInitializable { + private static readonly UploadObjectOptions IfNotExists = new UploadObjectOptions { IfGenerationMatch = 0 }; + private static readonly CopyObjectOptions IfNotExistsCopy = new CopyObjectOptions { IfGenerationMatch = 0 }; private readonly string bucketName; private StorageClient storageClient; @@ -49,29 +51,21 @@ namespace Squidex.Infrastructure.Assets return $"https://storage.cloud.google.com/{bucketName}/{objectName}"; } - public Task UploadAsync(string name, Stream stream, CancellationToken ct = default(CancellationToken)) - { - return storageClient.UploadObjectAsync(bucketName, name, "application/octet-stream", stream, cancellationToken: ct); - } - - public async Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) - { - var objectName = GetObjectName(id, version, suffix); - - await storageClient.UploadObjectAsync(bucketName, objectName, "application/octet-stream", stream, cancellationToken: ct); - } - - public async Task CopyAsync(string name, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)) + public async Task CopyAsync(string sourceFileName, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)) { var objectName = GetObjectName(id, version, suffix); try { - await storageClient.CopyObjectAsync(bucketName, name, bucketName, objectName, cancellationToken: ct); + await storageClient.CopyObjectAsync(bucketName, sourceFileName, bucketName, objectName, IfNotExistsCopy, ct); } catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.NotFound) { - throw new AssetNotFoundException($"Asset {name} not found.", ex); + throw new AssetNotFoundException(sourceFileName, ex); + } + catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.PreconditionFailed) + { + throw new AssetAlreadyExistsException(objectName); } } @@ -85,37 +79,51 @@ namespace Squidex.Infrastructure.Assets } catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.NotFound) { - throw new AssetNotFoundException($"Asset {id}, {version} not found.", ex); + throw new AssetNotFoundException($"Id={id}, Version={version}", ex); } } - public async Task DeleteAsync(string name) + public Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) + { + return UploadCoreAsync(GetObjectName(id, version, suffix), stream, ct); + } + + public Task UploadAsync(string fileName, Stream stream, CancellationToken ct = default(CancellationToken)) + { + return UploadCoreAsync(fileName, stream, ct); + } + + public Task DeleteAsync(string id, long version, string suffix) + { + return DeleteCoreAsync(GetObjectName(id, version, suffix)); + } + + public Task DeleteAsync(string fileName) + { + return DeleteCoreAsync(fileName); + } + + private async Task UploadCoreAsync(string objectName, Stream stream, CancellationToken ct) { try { - await storageClient.DeleteObjectAsync(bucketName, name); + await storageClient.UploadObjectAsync(bucketName, objectName, "application/octet-stream", stream, IfNotExists, ct); } - catch (GoogleApiException ex) + catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.PreconditionFailed) { - if (ex.HttpStatusCode != HttpStatusCode.NotFound) - { - throw; - } + throw new AssetAlreadyExistsException(objectName); } } - public async Task DeleteAsync(string id, long version, string suffix) + private async Task DeleteCoreAsync(string objectName) { try { - await storageClient.DeleteObjectAsync(bucketName, GetObjectName(id, version, suffix)); + await storageClient.DeleteObjectAsync(bucketName, objectName); } - catch (GoogleApiException ex) + catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.NotFound) { - if (ex.HttpStatusCode != HttpStatusCode.NotFound) - { - throw; - } + return; } } diff --git a/src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs b/src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs index 253641cd7..faadf950a 100644 --- a/src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs +++ b/src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs @@ -9,6 +9,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using MongoDB.Bson; using MongoDB.Driver; using MongoDB.Driver.GridFS; @@ -43,20 +44,20 @@ namespace Squidex.Infrastructure.Assets return "UNSUPPORTED"; } - public async Task CopyAsync(string name, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)) + public async Task CopyAsync(string sourceFileName, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)) { try { var target = GetFileName(id, version, suffix); - using (var readStream = await bucket.OpenDownloadStreamAsync(name, cancellationToken: ct)) + using (var readStream = await bucket.OpenDownloadStreamAsync(sourceFileName, cancellationToken: ct)) { - await bucket.UploadFromStreamAsync(target, target, readStream, cancellationToken: ct); + await UploadFileCoreAsync(target, readStream, ct); } } catch (GridFSFileNotFoundException ex) { - throw new AssetNotFoundException($"Asset {name} not found.", ex); + throw new AssetNotFoundException(sourceFileName, ex); } } @@ -73,23 +74,18 @@ namespace Squidex.Infrastructure.Assets } catch (GridFSFileNotFoundException ex) { - throw new AssetNotFoundException($"Asset {id}, {version} not found.", ex); + throw new AssetNotFoundException($"Id={id}, Version={version}", ex); } } - public Task UploadAsync(string name, Stream stream, CancellationToken ct = default(CancellationToken)) - { - return UploadFileCoreAsync(name, stream, ct); - } - public Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) { return UploadFileCoreAsync(GetFileName(id, version, suffix), stream, ct); } - public Task DeleteAsync(string name) + public Task UploadAsync(string fileName, Stream stream, CancellationToken ct = default(CancellationToken)) { - return DeleteCoreAsync(name); + return UploadFileCoreAsync(fileName, stream, ct); } public Task DeleteAsync(string id, long version, string suffix) @@ -97,6 +93,11 @@ namespace Squidex.Infrastructure.Assets return DeleteCoreAsync(GetFileName(id, version, suffix)); } + public Task DeleteAsync(string fileName) + { + return DeleteCoreAsync(fileName); + } + private async Task DeleteCoreAsync(string id) { try @@ -109,9 +110,20 @@ namespace Squidex.Infrastructure.Assets } } - private Task UploadFileCoreAsync(string id, Stream stream, CancellationToken ct = default(CancellationToken)) + private async Task UploadFileCoreAsync(string id, Stream stream, CancellationToken ct = default(CancellationToken)) { - return bucket.UploadFromStreamAsync(id, id, stream, cancellationToken: ct); + try + { + await bucket.UploadFromStreamAsync(id, id, stream, cancellationToken: ct); + } + catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + throw new AssetAlreadyExistsException(id); + } + catch (MongoBulkWriteException ex) when (ex.WriteErrors.Any(x => x.Category == ServerErrorCategory.DuplicateKey)) + { + throw new AssetAlreadyExistsException(id); + } } private static string GetFileName(string id, long version, string suffix) diff --git a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs index 9f7bc88f5..a9e31eb43 100644 --- a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs +++ b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs @@ -60,7 +60,7 @@ namespace Squidex.Infrastructure.EventSourcing var eventData = e.ToEventData(); var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); - result.Add(new StoredEvent(eventToken, eventStreamOffset, eventData)); + result.Add(new StoredEvent(streamName, eventToken, eventStreamOffset, eventData)); } } } @@ -111,7 +111,7 @@ namespace Squidex.Infrastructure.EventSourcing var eventData = e.ToEventData(); var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); - await callback(new StoredEvent(eventToken, eventStreamOffset, eventData)); + await callback(new StoredEvent(commit.EventStream, eventToken, eventStreamOffset, eventData)); commitOffset++; } @@ -124,8 +124,8 @@ namespace Squidex.Infrastructure.EventSourcing { var filters = new List>(); - AddPositionFilter(streamPosition, filters); - AddPropertyFitler(property, value, filters); + FilterByPosition(streamPosition, filters); + FilterByProperty(property, value, filters); return Filter.And(filters); } @@ -134,18 +134,18 @@ namespace Squidex.Infrastructure.EventSourcing { var filters = new List>(); - AddPositionFilter(streamPosition, filters); - AddStreamFilter(streamFilter, filters); + FilterByPosition(streamPosition, filters); + FilterByStream(streamFilter, filters); return Filter.And(filters); } - private static void AddPropertyFitler(string property, object value, List> filters) + private static void FilterByProperty(string property, object value, List> filters) { filters.Add(Filter.Eq(CreateIndexPath(property), value)); } - private static void AddStreamFilter(string streamFilter, List> filters) + private static void FilterByStream(string streamFilter, List> filters) { if (!string.IsNullOrWhiteSpace(streamFilter) && !string.Equals(streamFilter, ".*", StringComparison.OrdinalIgnoreCase)) { @@ -160,7 +160,7 @@ namespace Squidex.Infrastructure.EventSourcing } } - private static void AddPositionFilter(StreamPosition streamPosition, List> filters) + private static void FilterByPosition(StreamPosition streamPosition, List> filters) { if (streamPosition.IsEndOfCommit) { diff --git a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs index bbeb5c469..af4b0172a 100644 --- a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs +++ b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs @@ -20,6 +20,16 @@ namespace Squidex.Infrastructure.EventSourcing private const int MaxWriteAttempts = 20; private static readonly BsonTimestamp EmptyTimestamp = new BsonTimestamp(0); + public Task DeleteStreamAsync(string streamName) + { + return Collection.DeleteManyAsync(x => x.EventStream == streamName); + } + + public Task DeleteManyAsync(string property, object value) + { + return Collection.DeleteManyAsync(Filter.Eq(CreateIndexPath(property), value)); + } + public Task AppendAsync(Guid commitId, string streamName, ICollection events) { return AppendAsync(commitId, streamName, EtagVersion.Any, events); diff --git a/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs b/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs index 8fc6c3204..67e593b53 100644 --- a/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs +++ b/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs @@ -68,5 +68,13 @@ namespace Squidex.Infrastructure.States await Collection.Find(new BsonDocument()).ForEachAsync(x => callback(x.Doc, x.Version)); } } + + public async Task RemoveAsync(TKey key) + { + using (Profiler.TraceMethod>()) + { + await Collection.DeleteOneAsync(x => x.Id.Equals(key)); + } + } } } diff --git a/src/Squidex.Infrastructure/Assets/AssetAlreadyExistsException.cs b/src/Squidex.Infrastructure/Assets/AssetAlreadyExistsException.cs new file mode 100644 index 000000000..954f26c4c --- /dev/null +++ b/src/Squidex.Infrastructure/Assets/AssetAlreadyExistsException.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Runtime.Serialization; + +namespace Squidex.Infrastructure.Assets +{ + [Serializable] + public class AssetAlreadyExistsException : Exception + { + public AssetAlreadyExistsException(string fileName) + : base(FormatMessage(fileName)) + { + } + + public AssetAlreadyExistsException(string fileName, Exception inner) + : base(FormatMessage(fileName), inner) + { + } + + protected AssetAlreadyExistsException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + + private static string FormatMessage(string fileName) + { + Guard.NotNullOrEmpty(fileName, nameof(fileName)); + + return $"An asset with name '{fileName}' already not exists."; + } + } +} diff --git a/src/Squidex.Infrastructure/Assets/AssetNotFoundException.cs b/src/Squidex.Infrastructure/Assets/AssetNotFoundException.cs index 104371efb..1691a8bbf 100644 --- a/src/Squidex.Infrastructure/Assets/AssetNotFoundException.cs +++ b/src/Squidex.Infrastructure/Assets/AssetNotFoundException.cs @@ -13,23 +13,26 @@ namespace Squidex.Infrastructure.Assets [Serializable] public class AssetNotFoundException : Exception { - public AssetNotFoundException() + public AssetNotFoundException(string fileName) + : base(FormatMessage(fileName)) { } - public AssetNotFoundException(string message) - : base(message) + public AssetNotFoundException(string fileName, Exception inner) + : base(FormatMessage(fileName), inner) { } - public AssetNotFoundException(string message, Exception inner) - : base(message, inner) + protected AssetNotFoundException(SerializationInfo info, StreamingContext context) + : base(info, context) { } - protected AssetNotFoundException(SerializationInfo info, StreamingContext context) - : base(info, context) + private static string FormatMessage(string fileName) { + Guard.NotNullOrEmpty(fileName, nameof(fileName)); + + return $"An asset with name '{fileName}' does not exist."; } } } diff --git a/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs b/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs index 6ff9d8598..8bcda3ac6 100644 --- a/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs +++ b/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs @@ -59,26 +59,6 @@ namespace Squidex.Infrastructure.Assets return file.FullName; } - public async Task UploadAsync(string name, Stream stream, CancellationToken ct = default(CancellationToken)) - { - var file = GetFile(name); - - using (var fileStream = file.OpenWrite()) - { - await stream.CopyToAsync(fileStream, BufferSize, ct); - } - } - - public async Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) - { - var file = GetFile(id, version, suffix); - - using (var fileStream = file.OpenWrite()) - { - await stream.CopyToAsync(fileStream, BufferSize, ct); - } - } - public async Task DownloadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) { var file = GetFile(id, version, suffix); @@ -92,44 +72,74 @@ namespace Squidex.Infrastructure.Assets } catch (FileNotFoundException ex) { - throw new AssetNotFoundException($"Asset {id}, {version} not found.", ex); + throw new AssetNotFoundException($"Id={id}, Version={version}", ex); } } - public Task CopyAsync(string name, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)) + public Task CopyAsync(string sourceFileName, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)) { + var targetFile = GetFile(id, version, suffix); + try { - var file = GetFile(name); + var file = GetFile(sourceFileName); - file.CopyTo(GetPath(id, version, suffix)); + file.CopyTo(targetFile.FullName); return TaskHelper.Done; } + catch (IOException) when (targetFile.Exists) + { + throw new AssetAlreadyExistsException(targetFile.Name); + } catch (FileNotFoundException ex) { - throw new AssetNotFoundException($"Asset {name} not found.", ex); + throw new AssetNotFoundException(sourceFileName, ex); } } - public Task DeleteAsync(string id, long version, string suffix) + public Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) { - var file = GetFile(id, version, suffix); + return UploadCoreAsync(GetFile(id, version, suffix), stream, ct); + } - file.Delete(); + public Task UploadAsync(string fileName, Stream stream, CancellationToken ct = default(CancellationToken)) + { + return UploadCoreAsync(GetFile(fileName), stream, ct); + } - return TaskHelper.Done; + public Task DeleteAsync(string id, long version, string suffix) + { + return DeleteFileCoreAsync(GetFile(id, version, suffix)); } - public Task DeleteAsync(string name) + public Task DeleteAsync(string fileName) { - var file = GetFile(name); + return DeleteFileCoreAsync(GetFile(fileName)); + } + private static Task DeleteFileCoreAsync(FileInfo file) + { file.Delete(); return TaskHelper.Done; } + private async Task UploadCoreAsync(FileInfo file, Stream stream, CancellationToken ct) + { + try + { + using (var fileStream = file.Open(FileMode.CreateNew, FileAccess.Write)) + { + await stream.CopyToAsync(fileStream, BufferSize, ct); + } + } + catch (IOException) when (file.Exists) + { + throw new AssetAlreadyExistsException(file.Name); + } + } + private FileInfo GetFile(string id, long version, string suffix) { Guard.NotNullOrEmpty(id, nameof(id)); @@ -137,11 +147,11 @@ namespace Squidex.Infrastructure.Assets return GetFile(GetPath(id, version, suffix)); } - private FileInfo GetFile(string name) + private FileInfo GetFile(string fileName) { - Guard.NotNullOrEmpty(name, nameof(name)); + Guard.NotNullOrEmpty(fileName, nameof(fileName)); - return new FileInfo(GetPath(name)); + return new FileInfo(GetPath(fileName)); } private string GetPath(string name) diff --git a/src/Squidex.Infrastructure/Assets/IAssetStore.cs b/src/Squidex.Infrastructure/Assets/IAssetStore.cs index 7a4d2cdbc..8f954d731 100644 --- a/src/Squidex.Infrastructure/Assets/IAssetStore.cs +++ b/src/Squidex.Infrastructure/Assets/IAssetStore.cs @@ -15,15 +15,15 @@ namespace Squidex.Infrastructure.Assets { string GenerateSourceUrl(string id, long version, string suffix); - Task CopyAsync(string name, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)); + Task CopyAsync(string sourceFileName, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)); Task DownloadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)); - Task UploadAsync(string name, Stream stream, CancellationToken ct = default(CancellationToken)); + Task UploadAsync(string fileName, Stream stream, CancellationToken ct = default(CancellationToken)); Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)); - Task DeleteAsync(string name); + Task DeleteAsync(string fileName); Task DeleteAsync(string id, long version, string suffix); } diff --git a/src/Squidex.Infrastructure/CollectionExtensions.cs b/src/Squidex.Infrastructure/CollectionExtensions.cs index 8e021e970..61513594d 100644 --- a/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -167,6 +167,18 @@ namespace Squidex.Infrastructure return result; } + public static TValue GetOrAdd(this IDictionary dictionary, TKey key, TValue fallback) + { + if (!dictionary.TryGetValue(key, out var result)) + { + result = fallback; + + dictionary.Add(key, result); + } + + return result; + } + public static TValue GetOrAdd(this IDictionary dictionary, TKey key, Func creator) { if (!dictionary.TryGetValue(key, out var result)) diff --git a/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs b/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs index c33d86e5a..186b7310e 100644 --- a/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs +++ b/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs @@ -26,6 +26,10 @@ namespace Squidex.Infrastructure.EventSourcing Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection events); + Task DeleteStreamAsync(string streamName); + + Task DeleteManyAsync(string property, object value); + IEventSubscription CreateSubscription(IEventSubscriber subscriber, string streamFilter, string position = null); } } diff --git a/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs b/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs index 3c93e21a4..97ee0f55c 100644 --- a/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs +++ b/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs @@ -9,14 +9,17 @@ namespace Squidex.Infrastructure.EventSourcing { public sealed class StoredEvent { + public string StreamName { get; } + public string EventPosition { get; } public long EventStreamNumber { get; } public EventData Data { get; } - public StoredEvent(string eventPosition, long eventStreamNumber, EventData data) + public StoredEvent(string streamName, string eventPosition, long eventStreamNumber, EventData data) { + Guard.NotNullOrEmpty(streamName, nameof(streamName)); Guard.NotNullOrEmpty(eventPosition, nameof(eventPosition)); Guard.NotNull(data, nameof(data)); @@ -24,6 +27,8 @@ namespace Squidex.Infrastructure.EventSourcing EventPosition = eventPosition; EventStreamNumber = eventStreamNumber; + + StreamName = streamName; } } } diff --git a/src/Squidex.Infrastructure/Log/Profiler.cs b/src/Squidex.Infrastructure/Log/Profiler.cs index 85c19bb2b..e420f2dea 100644 --- a/src/Squidex.Infrastructure/Log/Profiler.cs +++ b/src/Squidex.Infrastructure/Log/Profiler.cs @@ -34,6 +34,11 @@ namespace Squidex.Infrastructure.Log return Cleaner; } + public static IDisposable TraceMethod(Type type, [CallerMemberName] string memberName = null) + { + return Trace($"{type.Name}/{memberName}"); + } + public static IDisposable TraceMethod([CallerMemberName] string memberName = null) { return Trace($"{typeof(T).Name}/{memberName}"); diff --git a/src/Squidex.Infrastructure/RefTokenType.cs b/src/Squidex.Infrastructure/RefTokenType.cs new file mode 100644 index 000000000..c8ee8944e --- /dev/null +++ b/src/Squidex.Infrastructure/RefTokenType.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure +{ + public static class RefTokenType + { + public const string Subject = "subject"; + + public const string Client = "client"; + } +} diff --git a/src/Squidex.Infrastructure/States/DefaultStreamNameResolver.cs b/src/Squidex.Infrastructure/States/DefaultStreamNameResolver.cs index b2c00d6b7..582011a20 100644 --- a/src/Squidex.Infrastructure/States/DefaultStreamNameResolver.cs +++ b/src/Squidex.Infrastructure/States/DefaultStreamNameResolver.cs @@ -15,6 +15,9 @@ namespace Squidex.Infrastructure.States public string GetStreamName(Type aggregateType, string id) { + Guard.NotNullOrEmpty(id, nameof(id)); + Guard.NotNull(aggregateType, nameof(aggregateType)); + var typeName = char.ToLower(aggregateType.Name[0]) + aggregateType.Name.Substring(1); foreach (var suffix in Suffixes) @@ -29,5 +32,25 @@ namespace Squidex.Infrastructure.States return $"{typeName}-{id}"; } + + public string WithNewId(string streamName, Func idGenerator) + { + Guard.NotNullOrEmpty(streamName, nameof(streamName)); + Guard.NotNull(idGenerator, nameof(idGenerator)); + + var positionOfDash = streamName.IndexOf('-'); + + if (positionOfDash >= 0) + { + var newId = idGenerator(streamName.Substring(positionOfDash + 1)); + + if (!string.IsNullOrWhiteSpace(newId)) + { + streamName = $"{streamName.Substring(0, positionOfDash)}-{newId}"; + } + } + + return streamName; + } } } diff --git a/src/Squidex.Infrastructure/States/IPersistence{TState}.cs b/src/Squidex.Infrastructure/States/IPersistence{TState}.cs index 2b467b302..c69e95926 100644 --- a/src/Squidex.Infrastructure/States/IPersistence{TState}.cs +++ b/src/Squidex.Infrastructure/States/IPersistence{TState}.cs @@ -15,6 +15,8 @@ namespace Squidex.Infrastructure.States { long Version { get; } + Task DeleteAsync(); + Task WriteEventsAsync(IEnumerable> @events); Task WriteSnapshotAsync(TState state); diff --git a/src/Squidex.Infrastructure/States/ISnapshotStore.cs b/src/Squidex.Infrastructure/States/ISnapshotStore.cs index 60b2ab5be..9431226f1 100644 --- a/src/Squidex.Infrastructure/States/ISnapshotStore.cs +++ b/src/Squidex.Infrastructure/States/ISnapshotStore.cs @@ -18,6 +18,8 @@ namespace Squidex.Infrastructure.States Task ClearAsync(); + Task RemoveAsync(TKey key); + Task ReadAllAsync(Func callback); } } diff --git a/src/Squidex.Infrastructure/States/IStore.cs b/src/Squidex.Infrastructure/States/IStore.cs index e0c65ba83..57b33552e 100644 --- a/src/Squidex.Infrastructure/States/IStore.cs +++ b/src/Squidex.Infrastructure/States/IStore.cs @@ -20,7 +20,5 @@ namespace Squidex.Infrastructure.States IPersistence WithSnapshotsAndEventSourcing(Type owner, TKey key, Func applySnapshot, Func, Task> applyEvent); ISnapshotStore GetSnapshotStore(); - - Task ClearSnapshotsAsync(); } } diff --git a/src/Squidex.Infrastructure/States/IStreamNameResolver.cs b/src/Squidex.Infrastructure/States/IStreamNameResolver.cs index a8d13034c..02b15f2fb 100644 --- a/src/Squidex.Infrastructure/States/IStreamNameResolver.cs +++ b/src/Squidex.Infrastructure/States/IStreamNameResolver.cs @@ -12,5 +12,7 @@ namespace Squidex.Infrastructure.States public interface IStreamNameResolver { string GetStreamName(Type aggregateType, string id); + + string WithNewId(string streamName, Func idGenerator); } } diff --git a/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs b/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs index ea8b50d1e..5c6a22c3f 100644 --- a/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs +++ b/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs @@ -175,6 +175,19 @@ namespace Squidex.Infrastructure.States UpdateVersion(); } + public async Task DeleteAsync() + { + if (UseEventSourcing()) + { + await eventStore.DeleteStreamAsync(GetStreamName()); + } + + if (UseSnapshots()) + { + await snapshotStore.RemoveAsync(ownerKey); + } + } + private EventData[] GetEventData(Envelope[] events, Guid commitId) { return @events.Select(x => eventDataFormatter.ToEventData(x, commitId, true)).ToArray(); diff --git a/src/Squidex.Infrastructure/States/Store.cs b/src/Squidex.Infrastructure/States/Store.cs index 0a3e02ff7..3bbacc36d 100644 --- a/src/Squidex.Infrastructure/States/Store.cs +++ b/src/Squidex.Infrastructure/States/Store.cs @@ -63,6 +63,11 @@ namespace Squidex.Infrastructure.States return GetSnapshotStore().ClearAsync(); } + public Task RemoveSnapshotAsync(TKey key) + { + return GetSnapshotStore().RemoveAsync(key); + } + public ISnapshotStore GetSnapshotStore() { return (ISnapshotStore)services.GetService(typeof(ISnapshotStore)); diff --git a/src/Squidex.Infrastructure/States/StoreExtensions.cs b/src/Squidex.Infrastructure/States/StoreExtensions.cs index 1164807ad..17b9bf6f8 100644 --- a/src/Squidex.Infrastructure/States/StoreExtensions.cs +++ b/src/Squidex.Infrastructure/States/StoreExtensions.cs @@ -58,5 +58,22 @@ namespace Squidex.Infrastructure.States { return store.WithSnapshotsAndEventSourcing(typeof(TOwner), key, applySnapshot.ToAsync(), applyEvent.ToAsync()); } + + public static Task ClearSnapshotsAsync(this IStore store) + { + return store.GetSnapshotStore().ClearAsync(); + } + + public static Task RemoveSnapshotAsync(this IStore store, TKey key) + { + return store.GetSnapshotStore().RemoveAsync(key); + } + + public static async Task GetSnapshotAsync(this IStore store, TKey key) + { + var result = await store.GetSnapshotStore().ReadAsync(key); + + return result.Value; + } } } diff --git a/src/Squidex.Infrastructure/Tasks/TaskExtensions.cs b/src/Squidex.Infrastructure/Tasks/TaskExtensions.cs index 8d5f8a548..416808ba0 100644 --- a/src/Squidex.Infrastructure/Tasks/TaskExtensions.cs +++ b/src/Squidex.Infrastructure/Tasks/TaskExtensions.cs @@ -6,14 +6,30 @@ // ========================================================================== using System; +using System.Threading; using System.Threading.Tasks; namespace Squidex.Infrastructure.Tasks { public static class TaskExtensions { + private static readonly Action IgnoreTaskContinuation = t => { var ignored = t.Exception; }; + public static void Forget(this Task task) { + if (task.IsCompleted) + { + var ignored = task.Exception; + } + else + { + task.ContinueWith( + IgnoreTaskContinuation, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } } public static Func ToDefault(this Action action) diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs index 45d6fbed8..475f4b059 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs @@ -40,9 +40,9 @@ namespace Squidex.Areas.Api.Controllers.Backups.Models public int HandledAssets { get; set; } /// - /// Indicates if the job has failed. + /// The status of the operation. /// - public bool IsFailed { get; set; } + public JobStatus Status { get; set; } public static BackupJobDto FromBackup(IBackupJob backup) { diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs new file mode 100644 index 000000000..d28d77ab0 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs @@ -0,0 +1,51 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using NodaTime; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Backups.Models +{ + public sealed class RestoreJobDto + { + /// + /// The uri to load from. + /// + [Required] + public Uri Url { get; set; } + + /// + /// The status log. + /// + [Required] + public List Log { get; set; } + + /// + /// The time when the job has been started. + /// + public Instant Started { get; set; } + + /// + /// The time when the job has been stopped. + /// + public Instant? Stopped { get; set; } + + /// + /// The status of the operation. + /// + public JobStatus Status { get; set; } + + public static RestoreJobDto FromJob(IRestoreJob job) + { + return SimpleMapper.Map(job, new RestoreJobDto()); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs new file mode 100644 index 000000000..f1b4528cc --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Areas.Api.Controllers.Backups.Models +{ + public sealed class RestoreRequest + { + /// + /// The name of the app. + /// + [Required] + [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] + public string Name { get; set; } + + /// + /// The url to the restore file. + /// + [Required] + public Uri Url { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs new file mode 100644 index 000000000..2162860a5 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs @@ -0,0 +1,71 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using NSwag.Annotations; +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.Pipeline; + +namespace Squidex.Areas.Api.Controllers.Backups +{ + /// + /// Restores backups. + /// + [ApiAuthorize] + [ApiExceptionFilter] + [ApiModelValidation(true)] + [MustBeAdministrator] + [SwaggerIgnore] + public class RestoreController : ApiController + { + private readonly IGrainFactory grainFactory; + + public RestoreController(ICommandBus commandBus, IGrainFactory grainFactory) + : base(commandBus) + { + this.grainFactory = grainFactory; + } + + [HttpGet] + [Route("apps/restore/")] + [ApiCosts(0)] + public async Task GetJob() + { + var restoreGrain = grainFactory.GetGrain(User.OpenIdSubject()); + + var job = await restoreGrain.GetJobAsync(); + + if (job.Value == null) + { + return NotFound(); + } + + var jobs = await restoreGrain.GetJobAsync(); + + var response = RestoreJobDto.FromJob(job.Value); + + return Ok(response); + } + + [HttpPost] + [Route("apps/restore/")] + [ApiCosts(0)] + public async Task PostRestore([FromBody] RestoreRequest request) + { + var restoreGrain = grainFactory.GetGrain(User.OpenIdSubject()); + + await restoreGrain.RestoreAsync(request.Url, request.Name); + + return NoContent(); + } + } +} diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index c87313238..12e65d848 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -52,8 +52,7 @@ namespace Squidex.Config.Domain c.GetRequiredService>(), c.GetRequiredService(), exposeSourceUrl)) - .As() - .As(); + .As().As(); services.AddSingletonAs() .As(); @@ -94,6 +93,35 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As>(); + services.AddSingletonAs() + .As(); + + services.AddCommandPipeline(); + services.AddBackupHandlers(); + + services.AddSingleton>(DomainObjectGrainFormatter.Format); + + services.AddSingleton(c => + { + var uiOptions = c.GetRequiredService>(); + + var result = new InitialPatterns(); + + foreach (var pattern in uiOptions.Value.RegexSuggestions) + { + if (!string.IsNullOrWhiteSpace(pattern.Key) && + !string.IsNullOrWhiteSpace(pattern.Value)) + { + result[Guid.NewGuid()] = new AppPattern(pattern.Key, pattern.Value); + } + } + + return result; + }); + } + + private static void AddCommandPipeline(this IServiceCollection services) + { services.AddSingletonAs() .As(); @@ -118,6 +146,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs>() .As(); @@ -130,9 +161,6 @@ namespace Squidex.Config.Domain services.AddSingletonAs>() .As(); - services.AddSingletonAs() - .As(); - services.AddSingletonAs() .As(); @@ -150,29 +178,24 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + } - services.AddSingletonAs() - .As(); + private static void AddBackupHandlers(this IServiceCollection services) + { + services.AddTransientAs() + .As(); - services.AddSingleton>(DomainObjectGrainFormatter.Format); + services.AddTransientAs() + .As(); - services.AddSingleton(c => - { - var uiOptions = c.GetRequiredService>(); + services.AddTransientAs() + .As(); - var result = new InitialPatterns(); + services.AddTransientAs() + .As(); - foreach (var pattern in uiOptions.Value.RegexSuggestions) - { - if (!string.IsNullOrWhiteSpace(pattern.Key) && - !string.IsNullOrWhiteSpace(pattern.Value)) - { - result[Guid.NewGuid()] = new AppPattern(pattern.Key, pattern.Value); - } - } - - return result; - }); + services.AddTransientAs() + .As(); } public static void AddMyMigrationServices(this IServiceCollection services) @@ -180,6 +203,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .AsSelf(); + services.AddTransientAs() + .AsSelf(); + services.AddTransientAs() .As(); @@ -209,9 +235,6 @@ namespace Squidex.Config.Domain services.AddTransientAs() .As(); - - services.AddTransientAs() - .AsSelf(); } } } diff --git a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs index f8e16d354..33690ade1 100644 --- a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs +++ b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs @@ -56,14 +56,14 @@ namespace Squidex.Pipeline.CommandMiddlewares { var subjectId = httpContextAccessor.HttpContext.User.OpenIdSubject(); - return subjectId == null ? null : new RefToken("subject", subjectId); + return subjectId == null ? null : new RefToken(RefTokenType.Subject, subjectId); } private RefToken FindActorFromClient() { var clientId = httpContextAccessor.HttpContext.User.OpenIdClientId(); - return clientId == null ? null : new RefToken("client", clientId); + return clientId == null ? null : new RefToken(RefTokenType.Client, clientId); } } } diff --git a/src/Squidex/app/features/administration/administration-area.component.html b/src/Squidex/app/features/administration/administration-area.component.html index e75dab4a4..6319cf409 100644 --- a/src/Squidex/app/features/administration/administration-area.component.html +++ b/src/Squidex/app/features/administration/administration-area.component.html @@ -12,6 +12,11 @@ + diff --git a/src/Squidex/app/features/administration/declarations.ts b/src/Squidex/app/features/administration/declarations.ts index 451d9dc50..09279fe2c 100644 --- a/src/Squidex/app/features/administration/declarations.ts +++ b/src/Squidex/app/features/administration/declarations.ts @@ -11,6 +11,7 @@ export * from './guards/user-must-exist.guard'; export * from './guards/unset-user.guard'; export * from './pages/event-consumers/event-consumers-page.component'; +export * from './pages/restore/restore-page.component'; export * from './pages/users/user-page.component'; export * from './pages/users/users-page.component'; diff --git a/src/Squidex/app/features/administration/module.ts b/src/Squidex/app/features/administration/module.ts index c2d23b6b3..a97719a34 100644 --- a/src/Squidex/app/features/administration/module.ts +++ b/src/Squidex/app/features/administration/module.ts @@ -18,6 +18,7 @@ import { EventConsumersPageComponent, EventConsumersService, EventConsumersState, + RestorePageComponent, UnsetUserGuard, UserMustExistGuard, UserPageComponent, @@ -38,6 +39,10 @@ const routes: Routes = [ path: 'event-consumers', component: EventConsumersPageComponent }, + { + path: 'restore', + component: RestorePageComponent + }, { path: 'users', component: UsersPageComponent, @@ -69,6 +74,7 @@ const routes: Routes = [ declarations: [ AdministrationAreaComponent, EventConsumersPageComponent, + RestorePageComponent, UserPageComponent, UsersPageComponent ], diff --git a/src/Squidex/app/features/administration/pages/restore/restore-page.component.html b/src/Squidex/app/features/administration/pages/restore/restore-page.component.html new file mode 100644 index 000000000..9ddcbc968 --- /dev/null +++ b/src/Squidex/app/features/administration/pages/restore/restore-page.component.html @@ -0,0 +1,68 @@ + + + + + Restore Backup + + + + +
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+

Last Restore Operation

+
+ +
+ {{job.url}} +
+
+
+
+
+ {{row}} +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
\ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/restore/restore-page.component.scss b/src/Squidex/app/features/administration/pages/restore/restore-page.component.scss new file mode 100644 index 000000000..ccd571a75 --- /dev/null +++ b/src/Squidex/app/features/administration/pages/restore/restore-page.component.scss @@ -0,0 +1,68 @@ +@import '_vars'; +@import '_mixins'; + +$circle-size: 2rem; + +h3 { + margin: 0; +} + +.section { + margin-bottom: .8rem; +} + +.container { + padding-top: 2rem; +} + +.card { + &-header { + h3 { + line-height: $circle-size; + } + } + + &-footer { + font-size: .9rem; + } + + &-body { + font-family: monospace; + background: $color-border; + max-height: 400px; + min-height: 300px; + overflow-y: scroll; + } +} + +.restore { + &-status { + & { + @include circle($circle-size); + line-height: $circle-size + .1rem; + text-align: center; + font-size: .6 * $circle-size; + font-weight: normal; + background: $color-border; + color: $color-dark-foreground; + vertical-align: middle; + } + + &-pending { + color: inherit; + } + + &-failed { + background: $color-theme-error; + } + + &-success { + background: $color-theme-green; + } + } + + &-url { + @include truncate; + line-height: 30px; + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/restore/restore-page.component.ts b/src/Squidex/app/features/administration/pages/restore/restore-page.component.ts new file mode 100644 index 000000000..fa8977ccf --- /dev/null +++ b/src/Squidex/app/features/administration/pages/restore/restore-page.component.ts @@ -0,0 +1,68 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { Subscription, timer } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + AuthService, + BackupsService, + DialogService, + RestoreDto, + RestoreForm +} from '@app/shared'; + +@Component({ + selector: 'sqx-restore-page', + styleUrls: ['./restore-page.component.scss'], + templateUrl: './restore-page.component.html' +}) +export class RestorePageComponent implements OnDestroy, OnInit { + private timerSubscription: Subscription; + + public restoreJob: RestoreDto | null; + public restoreForm = new RestoreForm(this.formBuilder); + + constructor( + public readonly authState: AuthService, + private readonly backupsService: BackupsService, + private readonly dialogs: DialogService, + private readonly formBuilder: FormBuilder + ) { + } + + public ngOnDestroy() { + this.timerSubscription.unsubscribe(); + } + + public ngOnInit() { + this.timerSubscription = + timer(0, 2000).pipe(switchMap(() => this.backupsService.getRestore())) + .subscribe(dto => { + if (dto !== null) { + this.restoreJob = dto; + } + }); + } + + public restore() { + const value = this.restoreForm.submit(); + + if (value) { + this.restoreForm.submitCompleted({}); + + this.backupsService.postRestore(value) + .subscribe(() => { + this.dialogs.notifyInfo('Restore started, it can take several minutes to complete.'); + }, error => { + this.dialogs.notifyError(error); + }); + } + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/services/users.service.ts b/src/Squidex/app/features/administration/services/users.service.ts index 4b23dbff4..8ffa5cabb 100644 --- a/src/Squidex/app/features/administration/services/users.service.ts +++ b/src/Squidex/app/features/administration/services/users.service.ts @@ -24,10 +24,6 @@ export class UsersDto extends Model { ) { super(); } - - public with(value: Partial): UsersDto { - return this.clone(value); - } } export class UserDto extends Model { diff --git a/src/Squidex/app/features/rules/pages/events/rule-events-page.component.scss b/src/Squidex/app/features/rules/pages/events/rule-events-page.component.scss index db9f3550d..a05c75506 100644 --- a/src/Squidex/app/features/rules/pages/events/rule-events-page.component.scss +++ b/src/Squidex/app/features/rules/pages/events/rule-events-page.component.scss @@ -18,6 +18,7 @@ h3 { &-dump { margin-top: 1rem; + font-family: monospace; font-size: .8rem; font-weight: normal; height: 20rem; diff --git a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html index d413f2ba4..9c2b7aeee 100644 --- a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html +++ b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html @@ -33,15 +33,15 @@
-
+
-
+
-
+
-
+
diff --git a/src/Squidex/app/features/settings/pages/backups/backups-page.component.scss b/src/Squidex/app/features/settings/pages/backups/backups-page.component.scss index 80a10e112..7d14e7dd0 100644 --- a/src/Squidex/app/features/settings/pages/backups/backups-page.component.scss +++ b/src/Squidex/app/features/settings/pages/backups/backups-page.component.scss @@ -1,14 +1,14 @@ @import '_vars'; @import '_mixins'; -$cicle-size: 2.8rem; +$circle-size: 2.8rem; .backup-status { & { - @include circle($cicle-size); - line-height: $cicle-size + .1rem; + @include circle($circle-size); + line-height: $circle-size + .1rem; text-align: center; - font-size: .4 * $cicle-size; + font-size: .4 * $circle-size; font-weight: normal; background: $color-border; color: $color-dark-foreground; diff --git a/src/Squidex/app/features/settings/pages/languages/language.component.html b/src/Squidex/app/features/settings/pages/languages/language.component.html index fb6a2f7a8..7567144f8 100644 --- a/src/Squidex/app/features/settings/pages/languages/language.component.html +++ b/src/Squidex/app/features/settings/pages/languages/language.component.html @@ -40,7 +40,7 @@
-
+
{{language.englishName}}
diff --git a/src/Squidex/app/shared/components/history-list.component.scss b/src/Squidex/app/shared/components/history-list.component.scss index 0eabbf8e9..17c4744e7 100644 --- a/src/Squidex/app/shared/components/history-list.component.scss +++ b/src/Squidex/app/shared/components/history-list.component.scss @@ -15,6 +15,10 @@ margin-top: .25rem; } +.user-ref { + padding-right: .25rem; +} + .event { & { color: $color-history; diff --git a/src/Squidex/app/shared/internal.ts b/src/Squidex/app/shared/internal.ts index 02a02f1fa..51fb48eb8 100644 --- a/src/Squidex/app/shared/internal.ts +++ b/src/Squidex/app/shared/internal.ts @@ -44,6 +44,7 @@ export * from './state/apps.forms'; export * from './state/apps.state'; export * from './state/assets.forms'; export * from './state/assets.state'; +export * from './state/backups.forms'; export * from './state/backups.state'; export * from './state/clients.forms'; export * from './state/clients.state'; diff --git a/src/Squidex/app/shared/services/app-clients.service.ts b/src/Squidex/app/shared/services/app-clients.service.ts index 2d4186ecd..81aede153 100644 --- a/src/Squidex/app/shared/services/app-clients.service.ts +++ b/src/Squidex/app/shared/services/app-clients.service.ts @@ -27,10 +27,6 @@ export class AppClientsDto extends Model { ) { super(); } - - public with(value: Partial): AppClientsDto { - return this.clone(value); - } } export class AppClientDto extends Model { diff --git a/src/Squidex/app/shared/services/app-contributors.service.ts b/src/Squidex/app/shared/services/app-contributors.service.ts index 9b90e4ad9..bad2c9770 100644 --- a/src/Squidex/app/shared/services/app-contributors.service.ts +++ b/src/Squidex/app/shared/services/app-contributors.service.ts @@ -28,10 +28,6 @@ export class AppContributorsDto extends Model { ) { super(); } - - public with(value: Partial): AppContributorsDto { - return this.clone(value); - } } export class AppContributorDto extends Model { @@ -41,10 +37,6 @@ export class AppContributorDto extends Model { ) { super(); } - - public with(value: Partial): AppContributorDto { - return this.clone(value); - } } export class ContributorAssignedDto { diff --git a/src/Squidex/app/shared/services/app-languages.service.ts b/src/Squidex/app/shared/services/app-languages.service.ts index 936c30f04..7f68eaa6a 100644 --- a/src/Squidex/app/shared/services/app-languages.service.ts +++ b/src/Squidex/app/shared/services/app-languages.service.ts @@ -27,10 +27,6 @@ export class AppLanguagesDto extends Model { ) { super(); } - - public with(value: Partial): AppLanguagesDto { - return this.clone(value); - } } export class AppLanguageDto extends Model { @@ -43,10 +39,6 @@ export class AppLanguageDto extends Model { ) { super(); } - - public with(value: Partial): AppLanguageDto { - return this.clone(value); - } } export class AddAppLanguageDto { diff --git a/src/Squidex/app/shared/services/app-patterns.service.ts b/src/Squidex/app/shared/services/app-patterns.service.ts index b32979415..31f688a22 100644 --- a/src/Squidex/app/shared/services/app-patterns.service.ts +++ b/src/Squidex/app/shared/services/app-patterns.service.ts @@ -26,10 +26,6 @@ export class AppPatternsDto extends Model { ) { super(); } - - public with(value: Partial): AppPatternsDto { - return this.clone(value); - } } export class AppPatternDto extends Model { @@ -41,10 +37,6 @@ export class AppPatternDto extends Model { ) { super(); } - - public with(value: Partial): AppPatternDto { - return this.clone(value); - } } export class EditAppPatternDto { diff --git a/src/Squidex/app/shared/services/apps.service.ts b/src/Squidex/app/shared/services/apps.service.ts index 59d647ac4..792c8575d 100644 --- a/src/Squidex/app/shared/services/apps.service.ts +++ b/src/Squidex/app/shared/services/apps.service.ts @@ -31,10 +31,6 @@ export class AppDto extends Model { ) { super(); } - - public with(value: Partial): AppDto { - return this.clone(value); - } } export class CreateAppDto { diff --git a/src/Squidex/app/shared/services/assets.service.ts b/src/Squidex/app/shared/services/assets.service.ts index 0f781729b..e2436b4f7 100644 --- a/src/Squidex/app/shared/services/assets.service.ts +++ b/src/Squidex/app/shared/services/assets.service.ts @@ -29,10 +29,6 @@ export class AssetsDto extends Model { ) { super(); } - - public with(value: Partial): AssetsDto { - return this.clone(value); - } } export class AssetDto extends Model { diff --git a/src/Squidex/app/shared/services/backups.service.spec.ts b/src/Squidex/app/shared/services/backups.service.spec.ts index 77e24c9aa..c88b0f161 100644 --- a/src/Squidex/app/shared/services/backups.service.spec.ts +++ b/src/Squidex/app/shared/services/backups.service.spec.ts @@ -13,7 +13,9 @@ import { ApiUrlConfig, BackupDto, BackupsService, - DateTime + DateTime, + RestoreDto, + StartRestoreDto } from './../'; describe('BackupsService', () => { @@ -55,7 +57,7 @@ describe('BackupsService', () => { stopped: '2017-02-04', handledEvents: 13, handledAssets: 17, - isFailed: false + status: 'Failed' }, { id: '2', @@ -63,17 +65,95 @@ describe('BackupsService', () => { stopped: null, handledEvents: 23, handledAssets: 27, - isFailed: true + status: 'Completed' } ]); expect(backups!).toEqual( [ - new BackupDto('1', DateTime.parseISO_UTC('2017-02-03'), DateTime.parseISO_UTC('2017-02-04'), 13, 17, false), - new BackupDto('2', DateTime.parseISO_UTC('2018-02-03'), null, 23, 27, true) + new BackupDto('1', DateTime.parseISO_UTC('2017-02-03'), DateTime.parseISO_UTC('2017-02-04'), 13, 17, 'Failed'), + new BackupDto('2', DateTime.parseISO_UTC('2018-02-03'), null, 23, 27, 'Completed') ]); })); + it('should make get request to get restore', + inject([BackupsService, HttpTestingController], (backupsService: BackupsService, httpMock: HttpTestingController) => { + + let restore: RestoreDto; + + backupsService.getRestore().subscribe(result => { + restore = result!; + }); + + const req = httpMock.expectOne('http://service/p/api/apps/restore'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({ + url: 'http://url', + started: '2017-02-03', + stopped: '2017-02-04', + status: 'Failed', + log: [ + 'log1', + 'log2' + ] + }); + + expect(restore!).toEqual( + new RestoreDto('http://url', + DateTime.parseISO_UTC('2017-02-03'), + DateTime.parseISO_UTC('2017-02-04'), + 'Failed', + [ + 'log1', + 'log2' + ])); + })); + + it('should return null when get restore return 404', + inject([BackupsService, HttpTestingController], (backupsService: BackupsService, httpMock: HttpTestingController) => { + + let restore: RestoreDto | null; + + backupsService.getRestore().subscribe(result => { + restore = result; + }); + + const req = httpMock.expectOne('http://service/p/api/apps/restore'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}, { status: 404, statusText: '404' }); + + expect(restore!).toBeNull(); + })); + + it('should throw error when get restore return non 404', + inject([BackupsService, HttpTestingController], (backupsService: BackupsService, httpMock: HttpTestingController) => { + + let restore: RestoreDto | null; + let error: any; + + backupsService.getRestore().subscribe(result => { + restore = result; + }, err => { + error = err; + }); + + const req = httpMock.expectOne('http://service/p/api/apps/restore'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}, { status: 500, statusText: '500' }); + + expect(restore!).toBeUndefined(); + expect(error)!.toBeDefined(); + })); + it('should make post request to start backup', inject([BackupsService, HttpTestingController], (backupsService: BackupsService, httpMock: HttpTestingController) => { @@ -87,6 +167,19 @@ describe('BackupsService', () => { req.flush({}); })); + it('should make post request to start restore', + inject([BackupsService, HttpTestingController], (backupsService: BackupsService, httpMock: HttpTestingController) => { + + backupsService.postRestore(new StartRestoreDto('http://url')).subscribe(); + + const req = httpMock.expectOne('http://service/p/api/apps/restore'); + + expect(req.request.method).toEqual('POST'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); + it('should make delete request to remove language', inject([BackupsService, HttpTestingController], (backupsService: BackupsService, httpMock: HttpTestingController) => { diff --git a/src/Squidex/app/shared/services/backups.service.ts b/src/Squidex/app/shared/services/backups.service.ts index d29099d8f..68b8298a3 100644 --- a/src/Squidex/app/shared/services/backups.service.ts +++ b/src/Squidex/app/shared/services/backups.service.ts @@ -5,17 +5,18 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { map, tap } from 'rxjs/operators'; +import { Observable, of, throwError } from 'rxjs'; +import { catchError, map, tap } from 'rxjs/operators'; import { AnalyticsService, ApiUrlConfig, DateTime, Model, - pretifyError + pretifyError, + Types } from '@app/framework'; export class BackupDto extends Model { @@ -25,13 +26,28 @@ export class BackupDto extends Model { public readonly stopped: DateTime | null, public readonly handledEvents: number, public readonly handledAssets: number, - public readonly isFailed: boolean + public readonly status: string ) { super(); } +} + +export class RestoreDto { + constructor( + public readonly url: string, + public readonly started: DateTime, + public readonly stopped: DateTime | null, + public readonly status: string, + public readonly log: string[] + ) { + } +} - public with(value: Partial): BackupDto { - return this.clone(value); +export class StartRestoreDto { + constructor( + public readonly url: string, + public readonly newAppName?: string + ) { } } @@ -58,12 +74,36 @@ export class BackupsService { item.stopped ? DateTime.parseISO_UTC(item.stopped) : null, item.handledEvents, item.handledAssets, - item.isFailed); + item.status); }); }), pretifyError('Failed to load backups.')); } + public getRestore(): Observable { + const url = this.apiUrl.buildUrl(`api/apps/restore`); + + return this.http.get(url).pipe( + map(response => { + const body: any = response; + + return new RestoreDto( + body.url, + DateTime.parseISO_UTC(body.started), + body.stopped ? DateTime.parseISO_UTC(body.stopped) : null, + body.status, + body.log); + }), + catchError(error => { + if (Types.is(error, HttpErrorResponse) && error.status === 404) { + return of(null); + } else { + return throwError(error); + } + }), + pretifyError('Failed to load backups.')); + } + public postBackup(appName: string): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups`); @@ -74,6 +114,16 @@ export class BackupsService { pretifyError('Failed to start backup.')); } + public postRestore(dto: StartRestoreDto): Observable { + const url = this.apiUrl.buildUrl(`api/apps/restore`); + + return this.http.post(url, dto).pipe( + tap(() => { + this.analytics.trackEvent('Restore', 'Started'); + }), + pretifyError('Failed to start restore.')); + } + public deleteBackup(appName: string, id: string): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups/${id}`); diff --git a/src/Squidex/app/shared/services/contents.service.spec.ts b/src/Squidex/app/shared/services/contents.service.spec.ts index 8d92f9763..6e9d7fb9f 100644 --- a/src/Squidex/app/shared/services/contents.service.spec.ts +++ b/src/Squidex/app/shared/services/contents.service.spec.ts @@ -285,6 +285,19 @@ describe('ContentsService', () => { req.flush({}); })); + it('should make put request to discard changes', + inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { + + contentsService.discardChanges('my-app', 'my-schema', 'content1', version).subscribe(); + + const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1/discard'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBe(version.value); + + req.flush({}); + })); + it('should make put request to change content status', inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { diff --git a/src/Squidex/app/shared/services/contents.service.ts b/src/Squidex/app/shared/services/contents.service.ts index d1a2eed27..9052d8852 100644 --- a/src/Squidex/app/shared/services/contents.service.ts +++ b/src/Squidex/app/shared/services/contents.service.ts @@ -43,10 +43,6 @@ export class ScheduleDto extends Model { ) { super(); } - - public with(value: Partial): ScheduleDto { - return this.clone(value); - } } export class ContentDto extends Model { @@ -233,7 +229,7 @@ export class ContentsService { public discardChanges(appName: string, schemaName: string, id: string, version: Version): Observable> { const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/discard`); - return HTTP.putVersioned(this.http, url, version).pipe( + return HTTP.putVersioned(this.http, url, {}, version).pipe( tap(() => { this.analytics.trackEvent('Content', 'Discarded', appName); }), diff --git a/src/Squidex/app/shared/services/plans.service.ts b/src/Squidex/app/shared/services/plans.service.ts index 491a6062c..9ab81561f 100644 --- a/src/Squidex/app/shared/services/plans.service.ts +++ b/src/Squidex/app/shared/services/plans.service.ts @@ -30,10 +30,6 @@ export class PlansDto extends Model { ) { super(); } - - public with(value: Partial): PlansDto { - return this.clone(value); - } } export class PlanDto extends Model { @@ -49,10 +45,6 @@ export class PlanDto extends Model { ) { super(); } - - public with(value: Partial): PlanDto { - return this.clone(value); - } } export class PlanChangedDto { diff --git a/src/Squidex/app/shared/services/rules.service.spec.ts b/src/Squidex/app/shared/services/rules.service.spec.ts index 3905ffa67..3d6927f41 100644 --- a/src/Squidex/app/shared/services/rules.service.spec.ts +++ b/src/Squidex/app/shared/services/rules.service.spec.ts @@ -201,6 +201,8 @@ describe('RulesService', () => { expect(req.request.method).toEqual('DELETE'); expect(req.request.headers.get('If-Match')).toEqual(version.value); + + req.flush({}); })); it('should make get request to get app rule events', @@ -265,5 +267,8 @@ describe('RulesService', () => { const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/events/123'); expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); })); }); \ No newline at end of file diff --git a/src/Squidex/app/shared/services/rules.service.ts b/src/Squidex/app/shared/services/rules.service.ts index e5df0d780..fd4b79fb0 100644 --- a/src/Squidex/app/shared/services/rules.service.ts +++ b/src/Squidex/app/shared/services/rules.service.ts @@ -83,10 +83,6 @@ export class RuleEventsDto extends Model { ) { super(); } - - public with(value: Partial): RuleEventsDto { - return this.clone(value); - } } export class RuleEventDto extends Model { @@ -103,10 +99,6 @@ export class RuleEventDto extends Model { ) { super(); } - - public with(value: Partial): RuleEventDto { - return this.clone(value); - } } export class CreateRuleDto { diff --git a/src/Squidex/app/shared/state/backups.forms.ts b/src/Squidex/app/shared/state/backups.forms.ts new file mode 100644 index 000000000..475050a6c --- /dev/null +++ b/src/Squidex/app/shared/state/backups.forms.ts @@ -0,0 +1,32 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { map, startWith } from 'rxjs/operators'; + +import { Form, ValidatorsEx } from '@app/framework'; + +export class RestoreForm extends Form { + public hasNoUrl = + this.form.controls['url'].valueChanges.pipe(startWith(null), map(x => !x)); + + constructor(formBuilder: FormBuilder) { + super(formBuilder.group({ + name: [null, + [ + Validators.maxLength(40), + ValidatorsEx.pattern('[a-z0-9]+(\-[a-z0-9]+)*', 'Name can contain lower case letters (a-z), numbers and dashes (not at the end).') + ] + ], + url: [null, + [ + Validators.required + ] + ] + })); + } +} \ No newline at end of file diff --git a/src/Squidex/app/shared/state/backups.state.spec.ts b/src/Squidex/app/shared/state/backups.state.spec.ts index d46cea933..ad1b63dfb 100644 --- a/src/Squidex/app/shared/state/backups.state.spec.ts +++ b/src/Squidex/app/shared/state/backups.state.spec.ts @@ -22,8 +22,8 @@ describe('BackupsState', () => { const app = 'my-app'; const oldBackups = [ - new BackupDto('id1', DateTime.now(), null, 1, 1, false), - new BackupDto('id2', DateTime.now(), null, 2, 2, false) + new BackupDto('id1', DateTime.now(), null, 1, 1, 'Started'), + new BackupDto('id2', DateTime.now(), null, 2, 2, 'Started') ]; let dialogs: IMock; diff --git a/src/Squidex/app/theme/icomoon/demo.html b/src/Squidex/app/theme/icomoon/demo.html index c385962d3..3b417bb03 100644 --- a/src/Squidex/app/theme/icomoon/demo.html +++ b/src/Squidex/app/theme/icomoon/demo.html @@ -9,10 +9,26 @@
-

Font Name: icomoon (Glyphs: 94)

+

Font Name: icomoon (Glyphs: 95)

Grid Size: 24

+
+
+ + + + icon-backup +
+
+ + +
+
+ liga: + +
+
diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.eot b/src/Squidex/app/theme/icomoon/fonts/icomoon.eot index 7d47c86cf..7d637f899 100644 Binary files a/src/Squidex/app/theme/icomoon/fonts/icomoon.eot and b/src/Squidex/app/theme/icomoon/fonts/icomoon.eot differ diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.svg b/src/Squidex/app/theme/icomoon/fonts/icomoon.svg index 5f5cea4e1..ff07500d5 100644 --- a/src/Squidex/app/theme/icomoon/fonts/icomoon.svg +++ b/src/Squidex/app/theme/icomoon/fonts/icomoon.svg @@ -98,6 +98,7 @@ + diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf b/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf index a1f6fc322..c486ad6d3 100644 Binary files a/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf and b/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf differ diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.woff b/src/Squidex/app/theme/icomoon/fonts/icomoon.woff index 9d034ac44..9e6cd0914 100644 Binary files a/src/Squidex/app/theme/icomoon/fonts/icomoon.woff and b/src/Squidex/app/theme/icomoon/fonts/icomoon.woff differ diff --git a/src/Squidex/app/theme/icomoon/selection.json b/src/Squidex/app/theme/icomoon/selection.json index 18bc8f403..67eb4be2f 100644 --- a/src/Squidex/app/theme/icomoon/selection.json +++ b/src/Squidex/app/theme/icomoon/selection.json @@ -1,6 +1,35 @@ { "IcoMoonType": "selection", "icons": [ + { + "icon": { + "paths": [ + "M512 128c212 0 384 172 384 384s-172 384-384 384c-88 0-170-30-234-80l60-60c50 34 110 54 174 54 166 0 298-132 298-298s-132-298-298-298-298 132-298 298h128l-172 170-170-170h128c0-212 172-384 384-384zM598 512c0 46-40 86-86 86s-86-40-86-86 40-86 86-86 86 40 86 86z" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "tags": [ + "settings_backup_restore" + ], + "grid": 24 + }, + "attrs": [ + {} + ], + "properties": { + "order": 1, + "id": 2, + "prevSize": 24, + "code": 59739, + "name": "backup" + }, + "setIdx": 1, + "setId": 3, + "iconIdx": 0 + }, { "icon": { "paths": [ @@ -26,9 +55,9 @@ "code": 59738, "name": "support" }, - "setIdx": 0, + "setIdx": 1, "setId": 3, - "iconIdx": 0 + "iconIdx": 2 }, { "icon": { @@ -55,7 +84,7 @@ "code": 59705, "name": "control-RichText" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 90 }, @@ -84,7 +113,7 @@ "code": 59710, "name": "download" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 91 }, @@ -113,7 +142,7 @@ "code": 59737, "name": "action-Medium" }, - "setIdx": 1, + "setIdx": 2, "setId": 2, "iconIdx": 0 }, @@ -142,7 +171,7 @@ "prevSize": 32, "code": 59729 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 44 }, @@ -172,7 +201,7 @@ "prevSize": 32, "code": 59726 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 45 }, @@ -204,7 +233,7 @@ "prevSize": 32, "code": 59727 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 46 }, @@ -233,7 +262,7 @@ "prevSize": 32, "code": 59724 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 47 }, @@ -271,7 +300,7 @@ "prevSize": 32, "code": 59722 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 48 }, @@ -300,7 +329,7 @@ "prevSize": 32, "code": 59652 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 49 }, @@ -329,7 +358,7 @@ "prevSize": 32, "code": 59653 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 50 }, @@ -358,7 +387,7 @@ "prevSize": 32, "code": 59654 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 51 }, @@ -387,7 +416,7 @@ "prevSize": 32, "code": 59655 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 52 }, @@ -416,7 +445,7 @@ "prevSize": 32, "code": 59656 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 53 }, @@ -445,7 +474,7 @@ "prevSize": 32, "code": 59657 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 54 }, @@ -474,7 +503,7 @@ "prevSize": 32, "code": 59658 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 55 }, @@ -503,7 +532,7 @@ "prevSize": 32, "code": 59659 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 56 }, @@ -532,7 +561,7 @@ "prevSize": 32, "code": 59660 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 57 }, @@ -561,7 +590,7 @@ "prevSize": 32, "code": 59661 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 58 }, @@ -590,7 +619,7 @@ "prevSize": 32, "code": 59662 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 59 }, @@ -619,7 +648,7 @@ "prevSize": 32, "code": 59663 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 60 }, @@ -648,7 +677,7 @@ "prevSize": 32, "code": 59664 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 61 }, @@ -683,7 +712,7 @@ "prevSize": 32, "code": 59665 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 62 }, @@ -712,7 +741,7 @@ "prevSize": 32, "code": 59666 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 63 }, @@ -741,7 +770,7 @@ "prevSize": 32, "code": 59667 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 64 }, @@ -770,7 +799,7 @@ "prevSize": 32, "code": 59668 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 65 }, @@ -799,7 +828,7 @@ "prevSize": 32, "code": 59669 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 66 }, @@ -828,7 +857,7 @@ "prevSize": 32, "code": 59670 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 67 }, @@ -857,7 +886,7 @@ "prevSize": 32, "code": 59671 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 68 }, @@ -886,7 +915,7 @@ "prevSize": 32, "code": 59672 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 69 }, @@ -916,7 +945,7 @@ "prevSize": 32, "code": 59713 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 70 }, @@ -945,7 +974,7 @@ "prevSize": 32, "code": 59673 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 71 }, @@ -974,7 +1003,7 @@ "prevSize": 32, "code": 59675 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 72 }, @@ -1003,7 +1032,7 @@ "prevSize": 32, "code": 59676 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 73 }, @@ -1032,7 +1061,7 @@ "prevSize": 32, "code": 59677 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 74 }, @@ -1061,7 +1090,7 @@ "prevSize": 32, "code": 59678 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 75 }, @@ -1090,7 +1119,7 @@ "prevSize": 32, "code": 59679 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 76 }, @@ -1119,7 +1148,7 @@ "prevSize": 32, "code": 59680 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 77 }, @@ -1157,7 +1186,7 @@ "prevSize": 32, "code": 59681 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 78 }, @@ -1186,7 +1215,7 @@ "prevSize": 32, "code": 59682 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 79 }, @@ -1215,7 +1244,7 @@ "prevSize": 32, "code": 59683 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 80 }, @@ -1244,7 +1273,7 @@ "prevSize": 32, "code": 59684 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 81 }, @@ -1273,7 +1302,7 @@ "prevSize": 32, "code": 59685 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 82 }, @@ -1302,7 +1331,7 @@ "prevSize": 32, "code": 59674 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 83 }, @@ -1331,7 +1360,7 @@ "prevSize": 32, "code": 59686 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 84 }, @@ -1360,7 +1389,7 @@ "prevSize": 32, "code": 59687 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 85 }, @@ -1389,7 +1418,7 @@ "prevSize": 32, "code": 59688 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 86 }, @@ -1428,7 +1457,7 @@ "prevSize": 28, "code": 59736 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 0 }, @@ -1470,7 +1499,7 @@ "prevSize": 28, "code": 59735 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 1 }, @@ -1508,7 +1537,7 @@ "prevSize": 28, "code": 59734 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 3 }, @@ -1538,7 +1567,7 @@ "code": 59733, "name": "exclamation" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 4 }, @@ -1568,7 +1597,7 @@ "prevSize": 28, "code": 59730 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 5 }, @@ -1598,7 +1627,7 @@ "code": 59725, "name": "action-Slack" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 6 }, @@ -1627,7 +1656,7 @@ "prevSize": 28, "code": 59723 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 7 }, @@ -1659,7 +1688,7 @@ "prevSize": 28, "code": 59721 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 8 }, @@ -1691,7 +1720,7 @@ "prevSize": 28, "code": 59711 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 9 }, @@ -1721,7 +1750,7 @@ "code": 59648, "name": "angle-down" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 10 }, @@ -1751,7 +1780,7 @@ "code": 59649, "name": "angle-left" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 11 }, @@ -1781,7 +1810,7 @@ "code": 59697, "name": "angle-right" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 12 }, @@ -1811,7 +1840,7 @@ "code": 59651, "name": "angle-up" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 13 }, @@ -1852,7 +1881,7 @@ "prevSize": 28, "code": 59717 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 14 }, @@ -1887,7 +1916,7 @@ "prevSize": 28, "code": 59720 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 15 }, @@ -1917,7 +1946,7 @@ "code": 59709, "name": "bug" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 16 }, @@ -1947,7 +1976,7 @@ "code": 59692, "name": "caret-down" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 17 }, @@ -1977,7 +2006,7 @@ "code": 59690, "name": "caret-left" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 18 }, @@ -2007,7 +2036,7 @@ "code": 59689, "name": "caret-right" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 19 }, @@ -2037,7 +2066,7 @@ "code": 59691, "name": "caret-up" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 20 }, @@ -2075,7 +2104,7 @@ "prevSize": 28, "code": 59718 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 21 }, @@ -2104,7 +2133,7 @@ "prevSize": 28, "code": 59702 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 22 }, @@ -2133,7 +2162,7 @@ "prevSize": 28, "code": 59703 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 23 }, @@ -2162,7 +2191,7 @@ "prevSize": 28, "code": 59704 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 24 }, @@ -2189,7 +2218,7 @@ "prevSize": 28, "code": 61450 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 25 }, @@ -2218,7 +2247,7 @@ "prevSize": 28, "code": 61641 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 26 }, @@ -2247,7 +2276,7 @@ "prevSize": 28, "code": 59698 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 27 }, @@ -2297,7 +2326,7 @@ "prevSize": 28, "code": 59719 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 28 }, @@ -2329,7 +2358,7 @@ "code": 59732, "name": "hour-glass" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 29 }, @@ -2362,7 +2391,7 @@ "code": 59731, "name": "spinner" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 30 }, @@ -2393,7 +2422,7 @@ "code": 59728, "name": "clock" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 31 }, @@ -2430,7 +2459,7 @@ "prevSize": 32, "code": 59650 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 32 }, @@ -2463,7 +2492,7 @@ "prevSize": 32, "code": 59850 }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 33 }, @@ -2496,7 +2525,7 @@ "code": 59715, "name": "elapsed" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 34 }, @@ -2526,7 +2555,7 @@ "code": 59707, "name": "google" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 35 }, @@ -2558,7 +2587,7 @@ "code": 59700, "name": "lock" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 36 }, @@ -2589,7 +2618,7 @@ "code": 59712, "name": "microsoft, action-AzureQueue" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 37 }, @@ -2619,7 +2648,7 @@ "code": 59695, "name": "pause" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 38 }, @@ -2649,7 +2678,7 @@ "code": 59696, "name": "play" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 39 }, @@ -2688,7 +2717,7 @@ "code": 59694, "name": "reset" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 40 }, @@ -2723,7 +2752,7 @@ "code": 59693, "name": "settings2" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 41 }, @@ -2757,7 +2786,7 @@ "code": 59716, "name": "timeout" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 42 }, @@ -2787,7 +2816,7 @@ "code": 59699, "name": "unlocked" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 43 }, @@ -2820,7 +2849,7 @@ "code": 59701, "name": "browser" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 87 }, @@ -2852,7 +2881,7 @@ "code": 59714, "name": "checkmark" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 88 }, @@ -2882,7 +2911,7 @@ "code": 59706, "name": "control-Stars" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 89 }, @@ -2911,7 +2940,7 @@ "code": 59708, "name": "info" }, - "setIdx": 2, + "setIdx": 3, "setId": 1, "iconIdx": 92 } diff --git a/src/Squidex/app/theme/icomoon/style.css b/src/Squidex/app/theme/icomoon/style.css index 78c745c05..8ef903e79 100644 --- a/src/Squidex/app/theme/icomoon/style.css +++ b/src/Squidex/app/theme/icomoon/style.css @@ -1,10 +1,10 @@ @font-face { font-family: 'icomoon'; - src: url('fonts/icomoon.eot?lx1t7v'); - src: url('fonts/icomoon.eot?lx1t7v#iefix') format('embedded-opentype'), - url('fonts/icomoon.ttf?lx1t7v') format('truetype'), - url('fonts/icomoon.woff?lx1t7v') format('woff'), - url('fonts/icomoon.svg?lx1t7v#icomoon') format('svg'); + src: url('fonts/icomoon.eot?za0y3d'); + src: url('fonts/icomoon.eot?za0y3d#iefix') format('embedded-opentype'), + url('fonts/icomoon.ttf?za0y3d') format('truetype'), + url('fonts/icomoon.woff?za0y3d') format('woff'), + url('fonts/icomoon.svg?za0y3d#icomoon') format('svg'); font-weight: normal; font-style: normal; } @@ -24,6 +24,9 @@ -moz-osx-font-smoothing: grayscale; } +.icon-backup:before { + content: "\e95b"; +} .icon-support:before { content: "\e95a"; } diff --git a/src/Squidex/package.json b/src/Squidex/package.json index 5ab170f23..3bc7cc1c6 100644 --- a/src/Squidex/package.json +++ b/src/Squidex/package.json @@ -43,6 +43,7 @@ "rxjs": "6.2.0", "slugify": "1.3.0", "sortablejs": "1.7.0", + "tslib": "^1.9.3", "zone.js": "0.8.26" }, "devDependencies": { diff --git a/src/Squidex/tsconfig.json b/src/Squidex/tsconfig.json index af90295f4..f93597800 100644 --- a/src/Squidex/tsconfig.json +++ b/src/Squidex/tsconfig.json @@ -3,8 +3,10 @@ "baseUrl": ".", "emitDecoratorMetadata": true, "experimentalDecorators": true, + "importHelpers": true, "lib": ["es6", "esnext", "dom"], "moduleResolution": "node", + "noEmitHelpers": true, "noImplicitAny": true, "noUnusedLocals": true, "noUnusedParameters": false, diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs index 0bec1cd1d..b7ff530fd 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs @@ -106,7 +106,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules [Fact] public void Should_format_email_and_display_name_from_user() { - var @event = new EnrichedContentEvent { User = user, Actor = new RefToken("subject", "123") }; + var @event = new EnrichedContentEvent { User = user, Actor = new RefToken(RefTokenType.Subject, "123") }; var result = sut.Format("From $USER_NAME ($USER_EMAIL)", @event); @@ -116,7 +116,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules [Fact] public void Should_return_undefined_if_user_is_not_found() { - var @event = new EnrichedContentEvent { Actor = new RefToken("subject", "123") }; + var @event = new EnrichedContentEvent { Actor = new RefToken(RefTokenType.Subject, "123") }; var result = sut.Format("From $USER_NAME ($USER_EMAIL)", @event); @@ -126,7 +126,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules [Fact] public void Should_format_email_and_display_name_from_client() { - var @event = new EnrichedContentEvent { Actor = new RefToken("client", "android") }; + var @event = new EnrichedContentEvent { Actor = new RefToken(RefTokenType.Client, "android") }; var result = sut.Format("From $USER_NAME ($USER_EMAIL)", @event); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs index ae373ecd9..932e96fb9 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs @@ -25,7 +25,6 @@ namespace Squidex.Domain.Apps.Entities.Apps { public class AppGrainTests : HandlerTestBase { - private readonly IAppProvider appProvider = A.Fake(); private readonly IAppPlansProvider appPlansProvider = A.Fake(); private readonly IAppPlanBillingManager appPlansBillingManager = A.Fake(); private readonly IUser user = A.Fake(); @@ -47,9 +46,6 @@ namespace Squidex.Domain.Apps.Entities.Apps public AppGrainTests() { - A.CallTo(() => appProvider.GetAppAsync(AppName)) - .Returns((IAppEntity)null); - A.CallTo(() => user.Id) .Returns(contributorId); @@ -62,7 +58,7 @@ namespace Squidex.Domain.Apps.Entities.Apps { patternId2, new AppPattern("Numbers", "[0-9]*") } }; - sut = new AppGrain(initialPatterns, Store, A.Dummy(), appProvider, appPlansProvider, appPlansBillingManager, userResolver); + sut = new AppGrain(initialPatterns, Store, A.Dummy(), appPlansProvider, appPlansBillingManager, userResolver); sut.OnActivateAsync(Id).Wait(); } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs index 588ab8762..36ec66cb9 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs @@ -5,8 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Threading.Tasks; using FakeItEasy; +using Orleans; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Services; @@ -19,18 +19,12 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { public class GuardAppTests { - private readonly IAppProvider apps = A.Fake(); private readonly IUserResolver users = A.Fake(); + private readonly IGrainFactory grainFactory = A.Fake(); private readonly IAppPlansProvider appPlans = A.Fake(); public GuardAppTests() { - A.CallTo(() => apps.GetAppAsync(A.Ignored)) - .Returns(Task.FromResult(null)); - - A.CallTo(() => apps.GetAppAsync("existing")) - .Returns(A.Dummy()); - A.CallTo(() => users.FindByIdOrEmailAsync(A.Ignored)) .Returns(A.Dummy()); @@ -42,29 +36,20 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards } [Fact] - public Task CanCreate_should_throw_exception_if_name_already_in_use() - { - var command = new CreateApp { Name = "existing" }; - - return ValidationAssert.ThrowsAsync(() => GuardApp.CanCreate(command, apps), - new ValidationError("An app with the same name already exists.", "Name")); - } - - [Fact] - public Task CanCreate_should_throw_exception_if_name_not_valid() + public void CanCreate_should_throw_exception_if_name_not_valid() { var command = new CreateApp { Name = "INVALID NAME" }; - return ValidationAssert.ThrowsAsync(() => GuardApp.CanCreate(command, apps), + ValidationAssert.Throws(() => GuardApp.CanCreate(command), new ValidationError("Name must be a valid slug.", "Name")); } [Fact] - public Task CanCreate_should_not_throw_exception_if_app_name_is_free() + public void CanCreate_should_not_throw_exception_if_app_name_is_valid() { var command = new CreateApp { Name = "new-app" }; - return GuardApp.CanCreate(command, apps); + GuardApp.CanCreate(command); } [Fact] diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByNameIndexCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByNameIndexCommandMiddlewareTests.cs index 8d67c852f..899245c67 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByNameIndexCommandMiddlewareTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByNameIndexCommandMiddlewareTests.cs @@ -10,13 +10,14 @@ using System.Threading.Tasks; using FakeItEasy; using Orleans; using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Orleans; using Xunit; namespace Squidex.Domain.Apps.Entities.Apps.Indexes { - public sealed class AppsByNameIndexCommandMiddlewareTests + public class AppsByNameIndexCommandMiddlewareTests { private readonly IGrainFactory grainFactory = A.Fake(); private readonly ICommandBus commandBus = A.Fake(); @@ -35,14 +36,45 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes [Fact] public async Task Should_add_app_to_index_on_create() { + A.CallTo(() => index.ReserveAppAsync(appId, "my-app")) + .Returns(true); + var context = new CommandContext(new CreateApp { AppId = appId, Name = "my-app" }, commandBus) .Complete(); await sut.HandleAsync(context); + A.CallTo(() => index.ReserveAppAsync(appId, "my-app")) + .MustHaveHappened(); + A.CallTo(() => index.AddAppAsync(appId, "my-app")) .MustHaveHappened(); + + A.CallTo(() => index.RemoveReservationAsync(appId, "my-app")) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_not_remove_reservation_when_not_reserved() + { + A.CallTo(() => index.ReserveAppAsync(appId, "my-app")) + .Returns(false); + + var context = + new CommandContext(new CreateApp { AppId = appId, Name = "my-app" }, commandBus) + .Complete(); + + await Assert.ThrowsAsync(() => sut.HandleAsync(context)); + + A.CallTo(() => index.ReserveAppAsync(appId, "my-app")) + .MustHaveHappened(); + + A.CallTo(() => index.AddAppAsync(appId, "my-app")) + .MustNotHaveHappened(); + + A.CallTo(() => index.RemoveReservationAsync(appId, "my-app")) + .MustNotHaveHappened(); } [Fact] diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByNameIndexGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByNameIndexGrainTests.cs index 5db614a21..368b9c417 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByNameIndexGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByNameIndexGrainTests.cs @@ -15,7 +15,7 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Apps.Indexes { - public sealed class AppsByNameIndexGrainTests + public class AppsByNameIndexGrainTests { private readonly IStore store = A.Fake>(); private readonly IPersistence persistence = A.Fake>(); @@ -47,6 +47,64 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes .MustHaveHappened(); } + [Fact] + public async Task Should_not_be_able_to_reserve_index_if_name_taken() + { + await sut.AddAppAsync(appId2, appName1); + + Assert.False(await sut.ReserveAppAsync(appId1, appName1)); + } + + [Fact] + public async Task Should_not_be_able_to_reserve_if_name_reserved() + { + await sut.ReserveAppAsync(appId2, appName1); + + Assert.False(await sut.ReserveAppAsync(appId1, appName1)); + } + + [Fact] + public async Task Should_not_be_able_to_reserve_if_id_taken() + { + await sut.AddAppAsync(appId1, appName1); + + Assert.False(await sut.ReserveAppAsync(appId1, appName2)); + } + + [Fact] + public async Task Should_not_be_able_to_reserve_if_id_reserved() + { + await sut.ReserveAppAsync(appId1, appName1); + + Assert.False(await sut.ReserveAppAsync(appId1, appName2)); + } + + [Fact] + public async Task Should_be_able_to_reserve_if_id_and_name_not_reserved() + { + await sut.ReserveAppAsync(appId1, appName1); + + Assert.True(await sut.ReserveAppAsync(appId2, appName2)); + } + + [Fact] + public async Task Should_be_able_to_reserve_after_app_removed() + { + await sut.AddAppAsync(appId1, appName1); + await sut.RemoveAppAsync(appId1); + + Assert.True(await sut.ReserveAppAsync(appId1, appName1)); + } + + [Fact] + public async Task Should_be_able_to_reserve_after_reservation_removed() + { + await sut.ReserveAppAsync(appId1, appName1); + await sut.RemoveReservationAsync(appId1, appName1); + + Assert.True(await sut.ReserveAppAsync(appId1, appName1)); + } + [Fact] public async Task Should_remove_app_id_from_index() { diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexCommandMiddlewareTests.cs index afa3851b2..1f581b135 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexCommandMiddlewareTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexCommandMiddlewareTests.cs @@ -18,7 +18,7 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Apps.Indexes { - public sealed class AppsByUserIndexCommandMiddlewareTests + public class AppsByUserIndexCommandMiddlewareTests { private readonly IGrainFactory grainFactory = A.Fake(); private readonly ICommandBus commandBus = A.Fake(); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexGrainTests.cs index f640a8831..ee8d91359 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexGrainTests.cs @@ -15,7 +15,7 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Apps.Indexes { - public sealed class AppsByUserIndexGrainTests + public class AppsByUserIndexGrainTests { private readonly IStore store = A.Fake>(); private readonly IPersistence persistence = A.Fake>(); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs new file mode 100644 index 000000000..4db3961c5 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs @@ -0,0 +1,108 @@ +// ========================================================================== +// 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.Linq; +using System.Threading.Tasks; +using FakeItEasy; +using FluentAssertions; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public class BackupReaderWriterTests + { + private readonly IStreamNameResolver streamNameResolver = A.Fake(); + + public BackupReaderWriterTests() + { + A.CallTo(() => streamNameResolver.WithNewId(A.Ignored, A>.Ignored)) + .ReturnsLazily(new Func, string>((stream, idGenerator) => stream + "^2")); + } + + [Fact] + public async Task Should_write_and_read_events() + { + var stream = new MemoryStream(); + + var sourceEvents = new List(); + + using (var writer = new BackupWriter(stream, true)) + { + for (var i = 0; i < 1000; i++) + { + var eventData = new EventData { Type = i.ToString(), Metadata = i, Payload = i }; + var eventStored = new StoredEvent("S", "1", 2, eventData); + + if (i % 17 == 0) + { + await writer.WriteBlobAsync(eventData.Type, innerStream => + { + innerStream.WriteByte((byte)i); + + return TaskHelper.Done; + }); + } + else if (i % 37 == 0) + { + await writer.WriteJsonAsync(eventData.Type, $"JSON_{i}"); + } + + writer.WriteEvent(eventStored); + + sourceEvents.Add(eventStored); + } + } + + stream.Position = 0; + + var readEvents = new List(); + + using (var reader = new BackupReader(stream)) + { + await reader.ReadEventsAsync(streamNameResolver, async @event => + { + var i = int.Parse(@event.Data.Type); + + if (i % 17 == 0) + { + await reader.ReadBlobAsync(@event.Data.Type, innerStream => + { + var b = innerStream.ReadByte(); + + Assert.Equal((byte)i, b); + + return TaskHelper.Done; + }); + } + else if (i % 37 == 0) + { + var j = await reader.ReadJsonAttachmentAsync(@event.Data.Type); + + Assert.Equal($"JSON_{i}", j.ToString()); + } + + readEvents.Add(@event); + }); + } + + var sourceEventsWithNewStreamName = + sourceEvents.Select(x => + new StoredEvent(streamNameResolver.WithNewId(x.StreamName, null), + x.EventPosition, + x.EventStreamNumber, + x.Data)).ToList(); + + readEvents.Should().BeEquivalentTo(sourceEventsWithNewStreamName); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Backup/GuidMapperTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Backup/GuidMapperTests.cs new file mode 100644 index 000000000..fdab2731a --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Backup/GuidMapperTests.cs @@ -0,0 +1,161 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public class GuidMapperTests + { + private readonly Guid id1 = Guid.NewGuid(); + private readonly Guid id2 = Guid.NewGuid(); + private readonly GuidMapper map = new GuidMapper(); + + [Fact] + public void Should_map_guid_string_if_valid() + { + var result = map.NewGuidString(id1.ToString()); + + Assert.Equal(map.NewGuid(id1).ToString(), result); + } + + [Fact] + public void Should_return_null_if_mapping_invalid_guid_string() + { + var result = map.NewGuidString("invalid"); + + Assert.Null(result); + } + + [Fact] + public void Should_return_null_if_mapping_null_guid_string() + { + var result = map.NewGuidString(null); + + Assert.Null(result); + } + + [Fact] + public void Should_map_guid() + { + var result = map.NewGuids(id1); + + Assert.Equal(map.NewGuid(id1), result.Value()); + } + + [Fact] + public void Should_return_old_guid() + { + var newGuid = map.NewGuids(id1).Value(); + + Assert.Equal(id1, map.OldGuid(newGuid)); + } + + [Fact] + public void Should_map_guid_string() + { + var result = map.NewGuids(id1.ToString()); + + Assert.Equal(map.NewGuid(id1).ToString(), result.Value()); + } + + [Fact] + public void Should_map_named_id() + { + var result = map.NewGuids($"{id1},name"); + + Assert.Equal($"{map.NewGuid(id1)},name", result.Value()); + } + + [Fact] + public void Should_map_array_with_guid() + { + var obj = + new JObject( + new JProperty("k", + new JArray(id1, id1, id2))); + + map.NewGuids(obj); + + Assert.Equal(map.NewGuid(id1), obj["k"][0].Value()); + Assert.Equal(map.NewGuid(id1), obj["k"][1].Value()); + Assert.Equal(map.NewGuid(id2), obj["k"][2].Value()); + } + + [Fact] + public void Should_map_objects_with_guid_keys() + { + var obj = + new JObject( + new JProperty("k", + new JObject( + new JProperty(id1.ToString(), id1), + new JProperty(id2.ToString(), id2)))); + + map.NewGuids(obj); + + Assert.Equal(map.NewGuid(id1), obj["k"].Value(map.NewGuid(id1).ToString())); + Assert.Equal(map.NewGuid(id2), obj["k"].Value(map.NewGuid(id2).ToString())); + } + + [Fact] + public void Should_map_objects_with_guid() + { + var obj = + new JObject( + new JProperty("k", + new JObject( + new JProperty("v1", id1), + new JProperty("v2", id1), + new JProperty("v3", id2)))); + + map.NewGuids(obj); + + Assert.Equal(map.NewGuid(id1), obj["k"].Value("v1")); + Assert.Equal(map.NewGuid(id1), obj["k"].Value("v2")); + Assert.Equal(map.NewGuid(id2), obj["k"].Value("v3")); + } + + [Fact] + public void Should_map_objects_with_guid_string() + { + var obj = + new JObject( + new JProperty("k", + new JObject( + new JProperty("v1", id1.ToString()), + new JProperty("v2", id1.ToString()), + new JProperty("v3", id2.ToString())))); + + map.NewGuids(obj); + + Assert.Equal(map.NewGuid(id1).ToString(), obj["k"].Value("v1")); + Assert.Equal(map.NewGuid(id1).ToString(), obj["k"].Value("v2")); + Assert.Equal(map.NewGuid(id2).ToString(), obj["k"].Value("v3")); + } + + [Fact] + public void Should_map_objects_with_named_id() + { + var obj = + new JObject( + new JProperty("k", + new JObject( + new JProperty("v1", $"{id1},v1"), + new JProperty("v2", $"{id1},v2"), + new JProperty("v3", $"{id2},v3")))); + + map.NewGuids(obj); + + Assert.Equal($"{map.NewGuid(id1).ToString()},v1", obj["k"].Value("v1")); + Assert.Equal($"{map.NewGuid(id1).ToString()},v2", obj["k"].Value("v2")); + Assert.Equal($"{map.NewGuid(id2).ToString()},v3", obj["k"].Value("v3")); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index 44190e333..f25465a81 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -141,9 +141,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL Id = id, Version = 1, Created = now, - CreatedBy = new RefToken("subject", "user1"), + CreatedBy = new RefToken(RefTokenType.Subject, "user1"), LastModified = now, - LastModifiedBy = new RefToken("subject", "user2"), + LastModifiedBy = new RefToken(RefTokenType.Subject, "user2"), Data = data }; @@ -159,9 +159,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL Id = id, Version = 1, Created = now, - CreatedBy = new RefToken("subject", "user1"), + CreatedBy = new RefToken(RefTokenType.Subject, "user1"), LastModified = now, - LastModifiedBy = new RefToken("subject", "user2"), + LastModifiedBy = new RefToken(RefTokenType.Subject, "user2"), FileName = "MyFile.png", FileSize = 1024, FileVersion = 123, diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/SingletonCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/SingletonCommandMiddlewareTests.cs index 27587abd7..466c880c3 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/SingletonCommandMiddlewareTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/SingletonCommandMiddlewareTests.cs @@ -14,7 +14,7 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Contents { - public sealed class SingletonCommandMiddlewareTests + public class SingletonCommandMiddlewareTests { private readonly ICommandBus commandBus = A.Fake(); private readonly SingletonCommandMiddleware sut = new SingletonCommandMiddleware(); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesByAppIndexCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesByAppIndexCommandMiddlewareTests.cs index cb4fd2a23..c90cc54a7 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesByAppIndexCommandMiddlewareTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesByAppIndexCommandMiddlewareTests.cs @@ -17,7 +17,7 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Rules.Indexes { - public sealed class RulesByAppIndexCommandMiddlewareTests + public class RulesByAppIndexCommandMiddlewareTests { private readonly IGrainFactory grainFactory = A.Fake(); private readonly ICommandBus commandBus = A.Fake(); @@ -38,7 +38,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes public async Task Should_add_rule_to_index_on_create() { var context = - new CommandContext(new CreateRule { RuleId = appId, AppId = NamedId.Of(appId, "my-app") }, commandBus) + new CommandContext(new CreateRule { RuleId = appId, AppId = BuildAppId() }, commandBus) .Complete(); await sut.HandleAsync(context); @@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes .Returns(J.AsTask(ruleState)); A.CallTo(() => ruleState.AppId) - .Returns(NamedId.Of(appId, "my-app")); + .Returns(BuildAppId()); var context = new CommandContext(new DeleteRule { RuleId = appId }, commandBus) @@ -71,5 +71,10 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes A.CallTo(() => index.RemoveRuleAsync(appId)) .MustHaveHappened(); } + + private NamedId BuildAppId() + { + return NamedId.Of(appId, "my-app"); + } } } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesByAppIndexGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesByAppIndexGrainTests.cs index be15f041f..98140a3e2 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesByAppIndexGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesByAppIndexGrainTests.cs @@ -14,7 +14,7 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Rules.Indexes { - public sealed class RulesByAppIndexGrainTests + public class RulesByAppIndexGrainTests { private readonly IStore store = A.Fake>(); private readonly IPersistence persistence = A.Fake>(); @@ -45,6 +45,21 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes .MustHaveHappenedTwiceExactly(); } + [Fact] + public async Task Should_delete_and_reset_state_when_cleaning() + { + await sut.AddRuleAsync(ruleId1); + await sut.AddRuleAsync(ruleId2); + await sut.ClearAsync(); + + var ids = await sut.GetRuleIdsAsync(); + + Assert.Empty(ids); + + A.CallTo(() => persistence.DeleteAsync()) + .MustHaveHappened(); + } + [Fact] public async Task Should_remove_rule_id_from_index() { diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasByAppIndexCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasByAppIndexCommandMiddlewareTests.cs index 15091edd8..d42b7e63d 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasByAppIndexCommandMiddlewareTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasByAppIndexCommandMiddlewareTests.cs @@ -17,7 +17,7 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Schemas.Indexes { - public sealed class SchemasByAppIndexCommandMiddlewareTests + public class SchemasByAppIndexCommandMiddlewareTests { private readonly IGrainFactory grainFactory = A.Fake(); private readonly ICommandBus commandBus = A.Fake(); @@ -38,7 +38,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes public async Task Should_add_schema_to_index_on_create() { var context = - new CommandContext(new CreateSchema { SchemaId = appId, Name = "my-schema", AppId = NamedId.Of(appId, "my-app") }, commandBus) + new CommandContext(new CreateSchema { SchemaId = appId, Name = "my-schema", AppId = BuildAppId() }, commandBus) .Complete(); await sut.HandleAsync(context); @@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes .Returns(J.AsTask(schemaState)); A.CallTo(() => schemaState.AppId) - .Returns(NamedId.Of(appId, "my-app")); + .Returns(BuildAppId()); var context = new CommandContext(new DeleteSchema { SchemaId = appId }, commandBus) @@ -71,5 +71,10 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes A.CallTo(() => index.RemoveSchemaAsync(appId)) .MustHaveHappened(); } + + private NamedId BuildAppId() + { + return NamedId.Of(appId, "my-app"); + } } } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasByAppIndexGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasByAppIndexGrainTests.cs index 5da814102..b52491a3a 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasByAppIndexGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasByAppIndexGrainTests.cs @@ -14,7 +14,7 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Schemas.Indexes { - public sealed class SchemasByAppIndexGrainTests + public class SchemasByAppIndexGrainTests { private readonly IStore store = A.Fake>(); private readonly IPersistence persistence = A.Fake>(); @@ -46,6 +46,20 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes .MustHaveHappened(); } + [Fact] + public async Task Should_delete_and_reset_state_when_cleaning() + { + await sut.AddSchemaAsync(schemaId1, schemaName1); + await sut.ClearAsync(); + + var id = await sut.GetSchemaIdAsync(schemaName1); + + Assert.Equal(id, Guid.Empty); + + A.CallTo(() => persistence.DeleteAsync()) + .MustHaveHappened(); + } + [Fact] public async Task Should_remove_schema_id_from_index() { diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs index f1aa0fd33..ec40dc92b 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs @@ -30,7 +30,42 @@ namespace Squidex.Domain.Apps.Entities.Tags } [Fact] - public async Task Should_call_grain_when_retrieving_tas() + public void Should_provide_name() + { + Assert.Equal("Tags", sut.Name); + } + + [Fact] + public async Task Should_call_grain_when_clearing() + { + await sut.ClearAsync(appId, TagGroups.Assets); + + A.CallTo(() => grain.ClearAsync()) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_call_grain_when_rebuilding() + { + var tags = new TagSet(); + + await sut.RebuildTagsAsync(appId, TagGroups.Assets, tags); + + A.CallTo(() => grain.RebuildAsync(tags)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_call_grain_when_retrieving_raw_tags() + { + await sut.GetExportableTagsAsync(appId, TagGroups.Assets); + + A.CallTo(() => grain.GetExportableTagsAsync()) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_call_grain_when_retrieving_tags() { await sut.GetTagsAsync(appId, TagGroups.Assets); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs index ce385f017..90b9fa0c8 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs @@ -30,6 +30,45 @@ namespace Squidex.Domain.Apps.Entities.Tags sut.OnActivateAsync(string.Empty).Wait(); } + [Fact] + public async Task Should_delete_and_reset_state_when_cleaning() + { + await sut.NormalizeTagsAsync(HashSet.Of("tag1", "tag2"), null); + await sut.NormalizeTagsAsync(HashSet.Of("tag2", "tag3"), null); + await sut.ClearAsync(); + + var allTags = await sut.GetTagsAsync(); + + Assert.Empty(allTags); + + A.CallTo(() => persistence.DeleteAsync()) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_rebuild_tags() + { + var tags = new TagSet + { + ["1"] = new Tag { Name = "tag1", Count = 1 }, + ["2"] = new Tag { Name = "tag2", Count = 2 }, + ["3"] = new Tag { Name = "tag3", Count = 6 } + }; + + await sut.RebuildAsync(tags); + + var allTags = await sut.GetTagsAsync(); + + Assert.Equal(new Dictionary + { + ["tag1"] = 1, + ["tag2"] = 2, + ["tag3"] = 6 + }, allTags); + + Assert.Same(tags, await sut.GetExportableTagsAsync()); + } + [Fact] public async Task Should_add_tags_to_grain() { diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs b/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs index 60c257bb8..d10ec2201 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs @@ -27,7 +27,7 @@ namespace Squidex.Domain.Apps.Entities.TestHelpers private readonly IPersistence persistence1 = A.Fake>(); private readonly IPersistence persistence2 = A.Fake(); - protected RefToken User { get; } = new RefToken("subject", Guid.NewGuid().ToString()); + protected RefToken User { get; } = new RefToken(RefTokenType.Subject, Guid.NewGuid().ToString()); protected Guid AppId { get; } = Guid.NewGuid(); diff --git a/tests/Squidex.Infrastructure.Tests/Assets/AssetStoreTests.cs b/tests/Squidex.Infrastructure.Tests/Assets/AssetStoreTests.cs index cf5d56896..4d9f8927b 100644 --- a/tests/Squidex.Infrastructure.Tests/Assets/AssetStoreTests.cs +++ b/tests/Squidex.Infrastructure.Tests/Assets/AssetStoreTests.cs @@ -14,11 +14,16 @@ namespace Squidex.Infrastructure.Assets { public abstract class AssetStoreTests : IDisposable where T : IAssetStore { + private readonly MemoryStream assetData = new MemoryStream(new byte[] { 0x1, 0x2, 0x3, 0x4 }); + private readonly string assetId = Guid.NewGuid().ToString(); + private readonly string tempId = Guid.NewGuid().ToString(); private readonly Lazy sut; protected AssetStoreTests() { sut = new Lazy(CreateStore); + + ((IInitializable)Sut).Initialize(); } protected T Sut @@ -26,6 +31,11 @@ namespace Squidex.Infrastructure.Assets get { return sut.Value; } } + protected string AssetId + { + get { return assetId; } + } + public abstract T CreateStore(); public abstract void Dispose(); @@ -33,27 +43,18 @@ namespace Squidex.Infrastructure.Assets [Fact] public virtual Task Should_throw_exception_if_asset_to_download_is_not_found() { - ((IInitializable)Sut).Initialize(); - - return Assert.ThrowsAsync(() => Sut.DownloadAsync(Id(), 1, "suffix", new MemoryStream())); + return Assert.ThrowsAsync(() => Sut.DownloadAsync(assetId, 1, "suffix", new MemoryStream())); } [Fact] public Task Should_throw_exception_if_asset_to_copy_is_not_found() { - ((IInitializable)Sut).Initialize(); - - return Assert.ThrowsAsync(() => Sut.CopyAsync(Id(), Id(), 1, null)); + return Assert.ThrowsAsync(() => Sut.CopyAsync(tempId, assetId, 1, null)); } [Fact] public async Task Should_read_and_write_file() { - ((IInitializable)Sut).Initialize(); - - var assetId = Id(); - var assetData = new MemoryStream(new byte[] { 0x1, 0x2, 0x3, 0x4 }); - await Sut.UploadAsync(assetId, 1, "suffix", assetData); var readData = new MemoryStream(); @@ -64,15 +65,16 @@ namespace Squidex.Infrastructure.Assets } [Fact] - public virtual async Task Should_commit_temporary_file() + public async Task Should_throw_exception_when_file_to_write_already_exists() { - ((IInitializable)Sut).Initialize(); - - var tempId = Id(); + await Sut.UploadAsync(assetId, 1, "suffix", assetData); - var assetId = Id(); - var assetData = new MemoryStream(new byte[] { 0x1, 0x2, 0x3, 0x4 }); + await Assert.ThrowsAsync(() => Sut.UploadAsync(assetId, 1, "suffix", assetData)); + } + [Fact] + public virtual async Task Should_read_and_write_temporary_file() + { await Sut.UploadAsync(tempId, assetData); await Sut.CopyAsync(tempId, assetId, 1, "suffix"); @@ -84,14 +86,26 @@ namespace Squidex.Infrastructure.Assets } [Fact] - public async Task Should_ignore_when_deleting_twice_by_name() + public async Task Should_throw_exception_when_temporary_file_to_write_already_exists() { - ((IInitializable)Sut).Initialize(); + await Sut.UploadAsync(tempId, assetData); + await Sut.CopyAsync(tempId, assetId, 1, "suffix"); - var tempId = Id(); + await Assert.ThrowsAsync(() => Sut.UploadAsync(tempId, assetData)); + } + + [Fact] + public async Task Should_throw_exception_when_target_file_to_copy_to_already_exists() + { + await Sut.UploadAsync(tempId, assetData); + await Sut.CopyAsync(tempId, assetId, 1, "suffix"); - var assetData = new MemoryStream(new byte[] { 0x1, 0x2, 0x3, 0x4 }); + await Assert.ThrowsAsync(() => Sut.CopyAsync(tempId, assetId, 1, "suffix")); + } + [Fact] + public async Task Should_ignore_when_deleting_twice_by_name() + { await Sut.UploadAsync(tempId, assetData); await Sut.DeleteAsync(tempId); await Sut.DeleteAsync(tempId); @@ -100,20 +114,9 @@ namespace Squidex.Infrastructure.Assets [Fact] public async Task Should_ignore_when_deleting_twice_by_id() { - ((IInitializable)Sut).Initialize(); - - var tempId = Id(); - - var assetData = new MemoryStream(new byte[] { 0x1, 0x2, 0x3, 0x4 }); - await Sut.UploadAsync(tempId, 0, null, assetData); await Sut.DeleteAsync(tempId, 0, null); await Sut.DeleteAsync(tempId, 0, null); } - - protected static string Id() - { - return Guid.NewGuid().ToString(); - } } } diff --git a/tests/Squidex.Infrastructure.Tests/Assets/AzureBlobAssetStoreTests.cs b/tests/Squidex.Infrastructure.Tests/Assets/AzureBlobAssetStoreTests.cs index 9f1f47f96..0ce5b5820 100644 --- a/tests/Squidex.Infrastructure.Tests/Assets/AzureBlobAssetStoreTests.cs +++ b/tests/Squidex.Infrastructure.Tests/Assets/AzureBlobAssetStoreTests.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using Xunit; #pragma warning disable xUnit1000 // Test classes must be public @@ -26,11 +25,9 @@ namespace Squidex.Infrastructure.Assets [Fact] public void Should_calculate_source_url() { - Sut.Initialize(); + var url = Sut.GenerateSourceUrl(AssetId, 1, null); - var id = Guid.NewGuid().ToString(); - - Assert.Equal($"http://127.0.0.1:10000/squidex-test-container/{id}_1", Sut.GenerateSourceUrl(id, 1, null)); + Assert.Equal($"http://127.0.0.1:10000/squidex-test-container/{AssetId}_1", url); } } } diff --git a/tests/Squidex.Infrastructure.Tests/Assets/FolderAssetStoreTests.cs b/tests/Squidex.Infrastructure.Tests/Assets/FolderAssetStoreTests.cs index 8885d1225..a1d221382 100644 --- a/tests/Squidex.Infrastructure.Tests/Assets/FolderAssetStoreTests.cs +++ b/tests/Squidex.Infrastructure.Tests/Assets/FolderAssetStoreTests.cs @@ -39,19 +39,15 @@ namespace Squidex.Infrastructure.Assets [Fact] public void Should_create_directory_when_connecting() { - Sut.Initialize(); - Assert.True(Directory.Exists(testFolder)); } [Fact] public void Should_calculate_source_url() { - Sut.Initialize(); - - var id = Guid.NewGuid().ToString(); + var url = Sut.GenerateSourceUrl(AssetId, 1, null); - Assert.Equal(Path.Combine(testFolder, $"{id}_1"), Sut.GenerateSourceUrl(id, 1, null)); + Assert.Equal(Path.Combine(testFolder, $"{AssetId}_1"), url); } private static string CreateInvalidPath() diff --git a/tests/Squidex.Infrastructure.Tests/Assets/GoogleCloudAssetStoreTests.cs b/tests/Squidex.Infrastructure.Tests/Assets/GoogleCloudAssetStoreTests.cs index 858fb813c..d31420bcf 100644 --- a/tests/Squidex.Infrastructure.Tests/Assets/GoogleCloudAssetStoreTests.cs +++ b/tests/Squidex.Infrastructure.Tests/Assets/GoogleCloudAssetStoreTests.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using Xunit; #pragma warning disable xUnit1000 // Test classes must be public @@ -26,11 +25,9 @@ namespace Squidex.Infrastructure.Assets [Fact] public void Should_calculate_source_url() { - Sut.Initialize(); + var url = Sut.GenerateSourceUrl(AssetId, 1, null); - var id = Guid.NewGuid().ToString(); - - Assert.Equal($"https://storage.cloud.google.com/squidex-test/{id}_1", Sut.GenerateSourceUrl(id, 1, null)); + Assert.Equal($"https://storage.cloud.google.com/squidex-test/{AssetId}_1", url); } } } diff --git a/tests/Squidex.Infrastructure.Tests/Assets/MongoGridFsAssetStoreTests.cs b/tests/Squidex.Infrastructure.Tests/Assets/MongoGridFsAssetStoreTests.cs index eb1fce5f4..eb101e3ef 100644 --- a/tests/Squidex.Infrastructure.Tests/Assets/MongoGridFsAssetStoreTests.cs +++ b/tests/Squidex.Infrastructure.Tests/Assets/MongoGridFsAssetStoreTests.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using MongoDB.Driver; using MongoDB.Driver.GridFS; using Xunit; @@ -43,9 +42,7 @@ namespace Squidex.Infrastructure.Assets [Fact] public void Should_not_calculate_source_url() { - Sut.Initialize(); - - Assert.Equal("UNSUPPORTED", Sut.GenerateSourceUrl(Guid.NewGuid().ToString(), 1, null)); + Assert.Equal("UNSUPPORTED", Sut.GenerateSourceUrl(AssetId, 1, null)); } } } \ No newline at end of file diff --git a/tests/Squidex.Infrastructure.Tests/CollectionExtensionsTests.cs b/tests/Squidex.Infrastructure.Tests/CollectionExtensionsTests.cs index f1746a03f..d540ff429 100644 --- a/tests/Squidex.Infrastructure.Tests/CollectionExtensionsTests.cs +++ b/tests/Squidex.Infrastructure.Tests/CollectionExtensionsTests.cs @@ -71,6 +71,13 @@ namespace Squidex.Infrastructure [Fact] public void GetOrAdd_should_return_default_and_add_it_if_key_not_exists() + { + Assert.Equal(24, valueDictionary.GetOrAdd(12, 24)); + Assert.Equal(24, valueDictionary[12]); + } + + [Fact] + public void GetOrAdd_should_return_default_and_add_it_with_fallback_if_key_not_exists() { Assert.Equal(24, valueDictionary.GetOrAdd(12, x => 24)); Assert.Equal(24, valueDictionary[12]); diff --git a/tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/EventConsumerGrainTests.cs b/tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/EventConsumerGrainTests.cs index d6556c694..da62e3e23 100644 --- a/tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/EventConsumerGrainTests.cs +++ b/tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/EventConsumerGrainTests.cs @@ -173,7 +173,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains [Fact] public async Task Should_invoke_and_update_position_when_event_received() { - var @event = new StoredEvent(Guid.NewGuid().ToString(), 123, eventData); + var @event = new StoredEvent("Stream", Guid.NewGuid().ToString(), 123, eventData); await sut.OnActivateAsync(consumerName); await sut.ActivateAsync(); @@ -195,7 +195,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains A.CallTo(() => formatter.Parse(eventData, true)) .Throws(new TypeNameNotFoundException()); - var @event = new StoredEvent(Guid.NewGuid().ToString(), 123, eventData); + var @event = new StoredEvent("Stream", Guid.NewGuid().ToString(), 123, eventData); await sut.OnActivateAsync(consumerName); await sut.ActivateAsync(); @@ -214,7 +214,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains [Fact] public async Task Should_not_invoke_and_update_position_when_event_is_from_another_subscription() { - var @event = new StoredEvent(Guid.NewGuid().ToString(), 123, eventData); + var @event = new StoredEvent("Stream", Guid.NewGuid().ToString(), 123, eventData); await sut.OnActivateAsync(consumerName); await sut.ActivateAsync(); @@ -302,7 +302,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains A.CallTo(() => eventConsumer.On(envelope)) .Throws(ex); - var @event = new StoredEvent(Guid.NewGuid().ToString(), 123, eventData); + var @event = new StoredEvent("Stream", Guid.NewGuid().ToString(), 123, eventData); await sut.OnActivateAsync(consumerName); await sut.ActivateAsync(); @@ -329,7 +329,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains A.CallTo(() => formatter.Parse(eventData, true)) .Throws(ex); - var @event = new StoredEvent(Guid.NewGuid().ToString(), 123, eventData); + var @event = new StoredEvent("Stream", Guid.NewGuid().ToString(), 123, eventData); await sut.OnActivateAsync(consumerName); await sut.ActivateAsync(); @@ -356,7 +356,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains A.CallTo(() => eventConsumer.On(envelope)) .Throws(exception); - var @event = new StoredEvent(Guid.NewGuid().ToString(), 123, eventData); + var @event = new StoredEvent("Stream", Guid.NewGuid().ToString(), 123, eventData); await sut.OnActivateAsync(consumerName); await sut.ActivateAsync(); diff --git a/tests/Squidex.Infrastructure.Tests/EventSourcing/RetrySubscriptionTests.cs b/tests/Squidex.Infrastructure.Tests/EventSourcing/RetrySubscriptionTests.cs index 74a7eca02..85aee50d2 100644 --- a/tests/Squidex.Infrastructure.Tests/EventSourcing/RetrySubscriptionTests.cs +++ b/tests/Squidex.Infrastructure.Tests/EventSourcing/RetrySubscriptionTests.cs @@ -90,7 +90,7 @@ namespace Squidex.Infrastructure.EventSourcing [Fact] public async Task Should_forward_event_from_inner_subscription() { - var ev = new StoredEvent("1", 2, new EventData()); + var ev = new StoredEvent("Stream", "1", 2, new EventData()); await OnEventAsync(eventSubscription, ev); await sut.StopAsync(); @@ -102,7 +102,7 @@ namespace Squidex.Infrastructure.EventSourcing [Fact] public async Task Should_not_forward_event_when_message_is_from_another_subscription() { - var ev = new StoredEvent("1", 2, new EventData()); + var ev = new StoredEvent("Stream", "1", 2, new EventData()); await OnEventAsync(A.Fake(), ev); await sut.StopAsync(); diff --git a/tests/Squidex.Infrastructure.Tests/States/DefaultStreamNameResolverTests.cs b/tests/Squidex.Infrastructure.Tests/States/DefaultStreamNameResolverTests.cs index 26f36ef21..d643ee9d1 100644 --- a/tests/Squidex.Infrastructure.Tests/States/DefaultStreamNameResolverTests.cs +++ b/tests/Squidex.Infrastructure.Tests/States/DefaultStreamNameResolverTests.cs @@ -43,5 +43,35 @@ namespace Squidex.Infrastructure.States Assert.Equal($"myUser-{id}", name); } + + [Fact] + public void Should_calculate_new_stream_if_valid() + { + var oldStream = "myUser-123"; + + var newStream = sut.WithNewId(oldStream, x => "456"); + + Assert.Equal("myUser-456", newStream); + } + + [Fact] + public void Should_return_old_stream_if_format_not_valid() + { + var oldStream = "myUser|123"; + + var newStream = sut.WithNewId(oldStream, x => "456"); + + Assert.Equal(oldStream, newStream); + } + + [Fact] + public void Should_return_old_stream_if_new_id_not_valid() + { + var oldStream = "myUser-123"; + + var newStream = sut.WithNewId(oldStream, x => null); + + Assert.Equal(oldStream, newStream); + } } } diff --git a/tests/Squidex.Infrastructure.Tests/States/PersistenceEventSourcingTests.cs b/tests/Squidex.Infrastructure.Tests/States/PersistenceEventSourcingTests.cs index a910af1bf..5957ed378 100644 --- a/tests/Squidex.Infrastructure.Tests/States/PersistenceEventSourcingTests.cs +++ b/tests/Squidex.Infrastructure.Tests/States/PersistenceEventSourcingTests.cs @@ -56,7 +56,7 @@ namespace Squidex.Infrastructure.States [Fact] public async Task Should_ignore_old_events() { - var storedEvent = new StoredEvent("1", 0, new EventData()); + var storedEvent = new StoredEvent("1", "1", 0, new EventData()); A.CallTo(() => eventStore.QueryAsync(key, 0)) .Returns(new List { storedEvent }); @@ -205,6 +205,34 @@ namespace Squidex.Infrastructure.States await Assert.ThrowsAsync(() => persistence.WriteEventsAsync(new[] { new MyEvent(), new MyEvent() }.Select(Envelope.Create))); } + [Fact] + public async Task Should_delete_events_but_not_snapshot_when_deleted_snapshot_only() + { + var persistence = sut.WithEventSourcing(key, x => { }); + + await persistence.DeleteAsync(); + + A.CallTo(() => eventStore.DeleteStreamAsync(key)) + .MustHaveHappened(); + + A.CallTo(() => snapshotStore.RemoveAsync(key)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_delete_events_and_snapshot_when_deleted() + { + var persistence = sut.WithSnapshotsAndEventSourcing(key, x => { }, x => { }); + + await persistence.DeleteAsync(); + + A.CallTo(() => eventStore.DeleteStreamAsync(key)) + .MustHaveHappened(); + + A.CallTo(() => snapshotStore.RemoveAsync(key)) + .MustHaveHappened(); + } + private void SetupEventStore(int count, int eventOffset = 0, int readPosition = 0) { SetupEventStore(Enumerable.Repeat(0, count).Select(x => new MyEvent()).ToArray(), eventOffset, readPosition); @@ -224,7 +252,7 @@ namespace Squidex.Infrastructure.States foreach (var @event in events) { var eventData = new EventData(); - var eventStored = new StoredEvent(i.ToString(), i, eventData); + var eventStored = new StoredEvent(i.ToString(), i.ToString(), i, eventData); eventsStored.Add(eventStored); diff --git a/tests/Squidex.Infrastructure.Tests/States/PersistenceSnapshotTests.cs b/tests/Squidex.Infrastructure.Tests/States/PersistenceSnapshotTests.cs index b2e90f231..06701c721 100644 --- a/tests/Squidex.Infrastructure.Tests/States/PersistenceSnapshotTests.cs +++ b/tests/Squidex.Infrastructure.Tests/States/PersistenceSnapshotTests.cs @@ -33,15 +33,6 @@ namespace Squidex.Infrastructure.States sut = new Store(eventStore, eventDataFormatter, services, streamNameResolver); } - [Fact] - public async Task Should_call_snapshot_store_on_clear() - { - await sut.ClearSnapshotsAsync(); - - A.CallTo(() => snapshotStore.ClearAsync()) - .MustHaveHappened(); - } - [Fact] public async Task Should_read_from_store() { @@ -146,5 +137,51 @@ namespace Squidex.Infrastructure.States await Assert.ThrowsAsync(() => persistence.WriteSnapshotAsync(100)); } + + [Fact] + public async Task Should_delete_snapshot_but_not_events_when_deleted() + { + var persistence = sut.WithSnapshots(key, x => { }); + + await persistence.DeleteAsync(); + + A.CallTo(() => eventStore.DeleteStreamAsync(A.Ignored)) + .MustNotHaveHappened(); + + A.CallTo(() => snapshotStore.RemoveAsync(key)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_call_snapshot_store_on_clear() + { + await sut.ClearSnapshotsAsync(); + + A.CallTo(() => snapshotStore.ClearAsync()) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_delete_snapshot_but_not_events_when_deleted_from_store() + { + await sut.RemoveSnapshotAsync(key); + + A.CallTo(() => eventStore.DeleteStreamAsync(A.Ignored)) + .MustNotHaveHappened(); + + A.CallTo(() => snapshotStore.RemoveAsync(key)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_get_snapshot() + { + A.CallTo(() => snapshotStore.ReadAsync(key)) + .Returns((123, -1)); + + var result = await sut.GetSnapshotAsync(key); + + Assert.Equal(123, result); + } } } \ No newline at end of file diff --git a/tests/Squidex.Tests/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddlewareTests.cs b/tests/Squidex.Tests/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddlewareTests.cs index ce0ce1d9a..28977d34b 100644 --- a/tests/Squidex.Tests/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddlewareTests.cs +++ b/tests/Squidex.Tests/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddlewareTests.cs @@ -66,7 +66,7 @@ namespace Squidex.Pipeline.CommandMiddlewares await sut.HandleAsync(context); - Assert.Equal(new RefToken("subject", "me"), command.Actor); + Assert.Equal(new RefToken(RefTokenType.Subject, "me"), command.Actor); } [Fact] @@ -79,7 +79,7 @@ namespace Squidex.Pipeline.CommandMiddlewares await sut.HandleAsync(context); - Assert.Equal(new RefToken("client", "my-client"), command.Actor); + Assert.Equal(new RefToken(RefTokenType.Client, "my-client"), command.Actor); } [Fact] diff --git a/tools/Migrate_01/Migrations/AddPatterns.cs b/tools/Migrate_01/Migrations/AddPatterns.cs index 693435f22..4426982c6 100644 --- a/tools/Migrate_01/Migrations/AddPatterns.cs +++ b/tools/Migrate_01/Migrations/AddPatterns.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Orleans; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Apps.Indexes; using Squidex.Infrastructure.Migrations; using Squidex.Infrastructure.Orleans; diff --git a/tools/Migrate_01/Migrations/PopulateGrainIndexes.cs b/tools/Migrate_01/Migrations/PopulateGrainIndexes.cs index 40828a5fa..24ed5e092 100644 --- a/tools/Migrate_01/Migrations/PopulateGrainIndexes.cs +++ b/tools/Migrate_01/Migrations/PopulateGrainIndexes.cs @@ -9,11 +9,11 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; using Orleans; -using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Apps.Indexes; using Squidex.Domain.Apps.Entities.Apps.State; -using Squidex.Domain.Apps.Entities.Rules; +using Squidex.Domain.Apps.Entities.Rules.Indexes; using Squidex.Domain.Apps.Entities.Rules.State; -using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Indexes; using Squidex.Domain.Apps.Entities.Schemas.State; using Squidex.Infrastructure; using Squidex.Infrastructure.Migrations; diff --git a/tools/Migrate_01/Rebuilder.cs b/tools/Migrate_01/Rebuilder.cs index a5f760d35..be45577c0 100644 --- a/tools/Migrate_01/Rebuilder.cs +++ b/tools/Migrate_01/Rebuilder.cs @@ -50,28 +50,28 @@ namespace Migrate_01 public async Task RebuildAppsAsync() { - await store.ClearSnapshotsAsync(); + await store.GetSnapshotStore().ClearAsync(); await RebuildManyAsync("^app\\-", id => RebuildAsync(id, (e, s) => s.Apply(e))); } public async Task RebuildSchemasAsync() { - await store.ClearSnapshotsAsync(); + await store.GetSnapshotStore().ClearAsync(); await RebuildManyAsync("^schema\\-", id => RebuildAsync(id, (e, s) => s.Apply(e, fieldRegistry))); } public async Task RebuildRulesAsync() { - await store.ClearSnapshotsAsync(); + await store.GetSnapshotStore().ClearAsync(); await RebuildManyAsync("^rule\\-", id => RebuildAsync(id, (e, s) => s.Apply(e))); } public async Task RebuildAssetsAsync() { - await store.ClearSnapshotsAsync(); + await store.GetSnapshotStore().ClearAsync(); await RebuildManyAsync("^asset\\-", id => RebuildAsync(id, (e, s) => s.Apply(e))); } @@ -80,7 +80,7 @@ namespace Migrate_01 { using (localCache.StartContext()) { - await store.ClearSnapshotsAsync(); + await store.GetSnapshotStore().ClearAsync(); await RebuildManyAsync("^content\\-", async id => {