diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs index 4b96939f0..79ee47b28 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs @@ -29,6 +29,10 @@ namespace Squidex.Domain.Apps.Core string AssetContent(NamedId appId, string idOrSlug); + string AssetContentBase(); + + string AssetContentBase(string appName); + string BackupsUI(NamedId appId); string ClientsUI(NamedId appId); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs index 6542477ea..9abf6a6a5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs @@ -9,43 +9,205 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Domain.Apps.Entities.Contents.State; +using Squidex.Domain.Apps.Events.Apps; using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Json.Objects; namespace Squidex.Domain.Apps.Entities.Contents { public sealed class BackupContents : IBackupHandler { + private delegate void ObjectSetter(IReadOnlyDictionary obj, string key, IJsonValue value); + + private const string UrlsFile = "Urls.json"; + + private static readonly ObjectSetter JsonSetter = (obj, key, value) => + { + ((JsonObject)obj).Add(key, value); + }; + + private static readonly ObjectSetter FieldSetter = (obj, key, value) => + { + ((ContentFieldData)obj)[key] = value; + }; + private readonly Dictionary> contentIdsBySchemaId = new Dictionary>(); private readonly Rebuilder rebuilder; + private readonly IUrlGenerator urlGenerator; + private Urls? assetsUrlNew; + private Urls? assetsUrlOld; public string Name { get; } = "Contents"; - public BackupContents(Rebuilder rebuilder) + public sealed class Urls + { + public string Assets { get; set; } + + public string AssetsApp { get; set; } + } + + public BackupContents(Rebuilder rebuilder, IUrlGenerator urlGenerator) { Guard.NotNull(rebuilder, nameof(rebuilder)); + Guard.NotNull(urlGenerator, nameof(urlGenerator)); this.rebuilder = rebuilder; + + this.urlGenerator = urlGenerator; + } + + public async Task BackupEventAsync(Envelope @event, BackupContext context) + { + if (@event.Payload is AppCreated appCreated) + { + var urls = GetUrls(appCreated.Name); + + await context.Writer.WriteJsonAsync(UrlsFile, urls); + } } - public Task RestoreEventAsync(Envelope @event, RestoreContext context) + public async Task RestoreEventAsync(Envelope @event, RestoreContext context) { switch (@event.Payload) { - case ContentCreated contentCreated: - contentIdsBySchemaId.GetOrAddNew(contentCreated.SchemaId.Id).Add(contentCreated.ContentId); + case AppCreated appCreated: + assetsUrlNew = GetUrls(appCreated.Name); + assetsUrlOld = await ReadUrlsAsync(context.Reader); break; case SchemaDeleted schemaDeleted: contentIdsBySchemaId.Remove(schemaDeleted.SchemaId.Id); + break; + case ContentCreated contentCreated: + contentIdsBySchemaId.GetOrAddNew(contentCreated.SchemaId.Id).Add(contentCreated.ContentId); + + if (assetsUrlNew != null && assetsUrlOld != null) + { + ReplaceAssetUrl(contentCreated.Data); + } + + break; + case ContentUpdated contentUpdated: + if (assetsUrlNew != null && assetsUrlOld != null) + { + ReplaceAssetUrl(contentUpdated.Data); + } + break; } - return Task.FromResult(true); + return true; + } + + private void ReplaceAssetUrl(NamedContentData data) + { + foreach (var field in data.Values) + { + if (field != null) + { + ReplaceAssetUrl(field, FieldSetter); + } + } + } + + private void ReplaceAssetUrl(IReadOnlyDictionary source, ObjectSetter setter) + { + List<(string, string)>? replacements = null; + + foreach (var (key, value) in source) + { + switch (value) + { + case JsonString s: + { + var newValue = s.Value; + + newValue = newValue.Replace(assetsUrlOld!.AssetsApp, assetsUrlNew!.AssetsApp); + + if (!ReferenceEquals(newValue, s.Value)) + { + replacements ??= new List<(string, string)>(); + replacements.Add((key, newValue)); + break; + } + + newValue = newValue.Replace(assetsUrlOld!.Assets, assetsUrlNew!.Assets); + + if (!ReferenceEquals(newValue, s.Value)) + { + replacements ??= new List<(string, string)>(); + replacements.Add((key, newValue)); + break; + } + } + + break; + + case JsonArray arr: + ReplaceAssetUrl(arr); + break; + + case JsonObject obj: + ReplaceAssetUrl(obj, JsonSetter); + break; + } + } + + if (replacements != null) + { + foreach (var (key, newValue) in replacements) + { + setter(source, key, JsonValue.Create(newValue)); + } + } + } + + private void ReplaceAssetUrl(JsonArray source) + { + for (var i = 0; i < source.Count; i++) + { + var value = source[i]; + + switch (value) + { + case JsonString s: + { + var newValue = s.Value; + + newValue = newValue.Replace(assetsUrlOld!.AssetsApp, assetsUrlNew!.AssetsApp); + + if (!ReferenceEquals(newValue, s.Value)) + { + source[i] = JsonValue.Create(newValue); + break; + } + + newValue = newValue.Replace(assetsUrlOld!.Assets, assetsUrlNew!.Assets); + + if (!ReferenceEquals(newValue, s.Value)) + { + source[i] = JsonValue.Create(newValue); + break; + } + } + + break; + + case JsonArray arr: + break; + + case JsonObject obj: + ReplaceAssetUrl(obj, FieldSetter); + break; + } + } } public async Task RestoreAsync(RestoreContext context) @@ -61,5 +223,26 @@ namespace Squidex.Domain.Apps.Entities.Contents }); } } + + private async Task ReadUrlsAsync(IBackupReader reader) + { + try + { + return await reader.ReadJsonAttachmentAsync(UrlsFile); + } + catch + { + return null; + } + } + + private Urls GetUrls(string appName) + { + return new Urls + { + Assets = urlGenerator.AssetContentBase(), + AssetsApp = urlGenerator.AssetContentBase(appName) + }; + } } } diff --git a/backend/src/Squidex.Web/Services/UrlGenerator.cs b/backend/src/Squidex.Web/Services/UrlGenerator.cs index 135eee58b..5fee5d765 100644 --- a/backend/src/Squidex.Web/Services/UrlGenerator.cs +++ b/backend/src/Squidex.Web/Services/UrlGenerator.cs @@ -73,6 +73,16 @@ namespace Squidex.Web.Services return urlsOptions.BuildUrl($"app/{appId.Name}/assets?query={query}", false); } + public string AssetContentBase() + { + return urlsOptions.BuildUrl("api/assets/"); + } + + public string AssetContentBase(string appName) + { + return urlsOptions.BuildUrl($"api/assets/{appName}/"); + } + public string BackupsUI(NamedId appId) { return urlsOptions.BuildUrl($"app/{appId.Name}/settings/backups", false); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs index 531ee2b4c..3561346f3 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs @@ -10,25 +10,30 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using FakeItEasy; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Domain.Apps.Entities.Contents.State; +using Squidex.Domain.Apps.Events.Apps; using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Json.Objects; using Xunit; namespace Squidex.Domain.Apps.Entities.Contents { public class BackupContentsTests { + private readonly IUrlGenerator urlGenerator = A.Fake(); private readonly Rebuilder rebuilder = A.Fake(); private readonly BackupContents sut; public BackupContentsTests() { - sut = new BackupContents(rebuilder); + sut = new BackupContents(rebuilder, urlGenerator); } [Fact] @@ -37,9 +42,122 @@ namespace Squidex.Domain.Apps.Entities.Contents Assert.Equal("Contents", sut.Name); } + [Fact] + public async Task Should_write_asset_urls() + { + var me = new RefToken(RefTokenType.Subject, "123"); + + var appId = Guid.NewGuid(); + var appName = "my-app"; + + var assetsUrl = "https://old.squidex.com/api/assets/"; + var assetsUrlApp = "https://old.squidex.com/api/assets/my-app"; + + A.CallTo(() => urlGenerator.AssetContentBase()) + .Returns(assetsUrl); + + A.CallTo(() => urlGenerator.AssetContentBase(appName)) + .Returns(assetsUrlApp); + + var writer = A.Fake(); + + var context = new BackupContext(appId, new UserMapping(me), writer); + + await sut.BackupEventAsync(Envelope.Create(new AppCreated + { + Name = appName + }), context); + + A.CallTo(() => writer.WriteJsonAsync(A._, + A.That.Matches(x => + x.Assets == assetsUrl && + x.AssetsApp == assetsUrlApp))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_replace_asset_url_in_content() + { + var me = new RefToken(RefTokenType.Subject, "123"); + + var appId = Guid.NewGuid(); + var appName = "my-new-app"; + + var newAssetsUrl = "https://new.squidex.com/api/assets"; + var newAssetsUrlApp = "https://old.squidex.com/api/assets/my-new-app"; + + var oldAssetsUrl = "https://old.squidex.com/api/assets"; + var oldAssetsUrlApp = "https://old.squidex.com/api/assets/my-old-app"; + + var reader = A.Fake(); + + A.CallTo(() => urlGenerator.AssetContentBase()) + .Returns(newAssetsUrl); + + A.CallTo(() => urlGenerator.AssetContentBase(appName)) + .Returns(newAssetsUrlApp); + + A.CallTo(() => reader.ReadJsonAttachmentAsync(A._)) + .Returns(new BackupContents.Urls + { + Assets = oldAssetsUrl, + AssetsApp = oldAssetsUrlApp + }); + + var data = + new NamedContentData() + .AddField("asset", + new ContentFieldData() + .AddValue("en", $"Asset: {oldAssetsUrlApp}/my-asset.jpg.") + .AddValue("it", $"Asset: {oldAssetsUrl}/my-asset.jpg.")) + .AddField("assetsInArray", + new ContentFieldData() + .AddValue("iv", + JsonValue.Array( + $"Asset: {oldAssetsUrlApp}/my-asset.jpg."))) + .AddField("assetsInObj", + new ContentFieldData() + .AddValue("iv", + JsonValue.Object() + .Add("asset", $"Asset: {oldAssetsUrlApp}/my-asset.jpg."))); + + var updateData = + new NamedContentData() + .AddField("asset", + new ContentFieldData() + .AddValue("en", $"Asset: {newAssetsUrlApp}/my-asset.jpg.") + .AddValue("it", $"Asset: {newAssetsUrl}/my-asset.jpg.")) + .AddField("assetsInArray", + new ContentFieldData() + .AddValue("iv", + JsonValue.Array( + $"Asset: {newAssetsUrlApp}/my-asset.jpg."))) + .AddField("assetsInObj", + new ContentFieldData() + .AddValue("iv", + JsonValue.Object() + .Add("asset", $"Asset: {newAssetsUrlApp}/my-asset.jpg."))); + + var context = new RestoreContext(appId, new UserMapping(me), reader); + + await sut.RestoreEventAsync(Envelope.Create(new AppCreated + { + Name = appName + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new ContentUpdated + { + Data = data + }), context); + + Assert.Equal(updateData, data); + } + [Fact] public async Task Should_restore_states_for_all_contents() { + var me = new RefToken(RefTokenType.Subject, "123"); + var appId = Guid.NewGuid(); var schemaId1 = NamedId.Of(Guid.NewGuid(), "my-schema1"); @@ -49,7 +167,7 @@ namespace Squidex.Domain.Apps.Entities.Contents var contentId2 = Guid.NewGuid(); var contentId3 = Guid.NewGuid(); - var context = new RestoreContext(appId, new UserMapping(new RefToken(RefTokenType.Subject, "123")), A.Fake()); + var context = new RestoreContext(appId, new UserMapping(me), A.Fake()); await sut.RestoreEventAsync(Envelope.Create(new ContentCreated { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs index cad98469d..07472a725 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs @@ -56,6 +56,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData throw new NotSupportedException(); } + public string AssetContentBase() + { + throw new NotSupportedException(); + } + + public string AssetContentBase(string appName) + { + throw new NotSupportedException(); + } + public string BackupsUI(NamedId appId) { throw new NotSupportedException();