diff --git a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs index 954c74a2d..33bac48cb 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs @@ -28,13 +28,33 @@ namespace Squidex.Extensions.Actions.Webhook [Formattable] public Uri Url { get; set; } - [Display(Name = "Shared Secret", Description = "The shared secret that is used to calculate the signature.")] - [DataType(DataType.Text)] - public string SharedSecret { get; set; } + [LocalizedRequired] + [Display(Name = "Method", Description = "The type of the request.")] + public WebhookMethod Method { get; set; } [Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")] [DataType(DataType.MultilineText)] [Formattable] public string Payload { get; set; } + + [Display(Name = "Payload Type", Description = "The mime type of the payload.")] + [DataType(DataType.Text)] + public string PayloadType { get; set; } + + [Display(Name = "Headers (Optional)", Description = "The message headers in the format '[Key]=[Value]', one entry per line.")] + [DataType(DataType.MultilineText)] + [Formattable] + public string Headers { get; set; } + + [Display(Name = "Shared Secret", Description = "The shared secret that is used to calculate the payload signature.")] + [DataType(DataType.Text)] + public string SharedSecret { get; set; } + } + + public enum WebhookMethod + { + POST, + PUT, + GET } } diff --git a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs index 25fc872a8..57789717b 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.Net.Http; using System.Text; using System.Threading; @@ -29,15 +30,21 @@ namespace Squidex.Extensions.Actions.Webhook protected override async Task<(string Description, WebhookJob Data)> CreateJobAsync(EnrichedEvent @event, WebhookAction action) { - string requestBody; + var requestBody = string.Empty; + var requestSignature = string.Empty; - if (!string.IsNullOrEmpty(action.Payload)) + if (action.Method != WebhookMethod.GET) { - requestBody = await FormatAsync(action.Payload, @event); - } - else - { - requestBody = ToEnvelopeJson(@event); + if (!string.IsNullOrEmpty(action.Payload)) + { + requestBody = await FormatAsync(action.Payload, @event); + } + else + { + requestBody = ToEnvelopeJson(@event); + } + + requestSignature = $"{requestBody}{action.SharedSecret}".Sha256Base64(); } var requestUrl = await FormatAsync(action.Url, @event); @@ -45,27 +52,90 @@ namespace Squidex.Extensions.Actions.Webhook var ruleDescription = $"Send event to webhook '{requestUrl}'"; var ruleJob = new WebhookJob { + Method = action.Method, RequestUrl = await FormatAsync(action.Url.ToString(), @event), - RequestSignature = $"{requestBody}{action.SharedSecret}".Sha256Base64(), - RequestBody = requestBody + RequestSignature = requestSignature, + RequestBody = requestBody, + RequestBodyType = action.PayloadType, + Headers = await ParseHeadersAsync(action.Headers, @event) }; return (ruleDescription, ruleJob); } + private async Task> ParseHeadersAsync(string headers, EnrichedEvent @event) + { + if (string.IsNullOrWhiteSpace(headers)) + { + return null; + } + + var headersDictionary = new Dictionary(); + + var lines = headers.Split('\n'); + + foreach (var line in lines) + { + var indexEqual = line.IndexOf('='); + + if (indexEqual > 0 && indexEqual < line.Length - 1) + { + var key = line.Substring(0, indexEqual); + var val = line.Substring(indexEqual + 1); + + val = await FormatAsync(val, @event); + + headersDictionary[key] = val; + } + } + + return headersDictionary; + } + protected override async Task ExecuteJobAsync(WebhookJob job, CancellationToken ct = default) { using (var httpClient = httpClientFactory.CreateClient()) { - using (var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl) + var method = HttpMethod.Post; + + switch (job.Method) { - Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") - }) + case WebhookMethod.PUT: + method = HttpMethod.Put; + break; + case WebhookMethod.GET: + method = HttpMethod.Get; + break; + } + + var request = new HttpRequestMessage(method, job.RequestUrl); + + if (!string.IsNullOrEmpty(job.RequestBody) && job.Method != WebhookMethod.GET) + { + var mediaType = job.RequestBodyType.Or("application/json"); + + request.Content = new StringContent(job.RequestBody, Encoding.UTF8, mediaType); + } + + using (request) { - request.Headers.Add("X-Signature", job.RequestSignature); - request.Headers.Add("X-Application", "Squidex Webhook"); request.Headers.Add("User-Agent", "Squidex Webhook"); + if (job.Headers != null) + { + foreach (var (key, value) in job.Headers) + { + request.Headers.TryAddWithoutValidation(key, value); + } + } + + if (!string.IsNullOrWhiteSpace(job.RequestSignature)) + { + request.Headers.Add("X-Signature", job.RequestSignature); + } + + request.Headers.Add("X-Application", "Squidex Webhook"); + return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct); } } @@ -74,10 +144,16 @@ namespace Squidex.Extensions.Actions.Webhook public sealed class WebhookJob { + public WebhookMethod Method { get; set; } + public string RequestUrl { get; set; } public string RequestSignature { get; set; } public string RequestBody { get; set; } + + public string RequestBodyType { get; set; } + + public Dictionary Headers { get; set; } } } diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 035f51931..c5655d7a0 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -254,6 +254,7 @@ "common.httpConflict": "Failed to make the update. Another user has made a change. Please reload.", "common.httpLimit": "You have exceeded the maximum limit of API calls.", "common.label": "Label", + "common.language": "Language", "common.languages": "Languages", "common.latitudeShort": "Lat", "common.loading": "Loading", @@ -663,11 +664,12 @@ "schemas.field.enable": "Enable in UI", "schemas.field.enabledMarker": "Enabled", "schemas.field.halfWidth": "Half Width", + "schemas.field.halfWidthHint": "Shows the field with only the half width when on the edit or create page, when there is enough space.", "schemas.field.hiddenMarker": "Hidden", "schemas.field.hide": "Hide in API", - "schemas.field.hintsHint": "Describe this schema for documentation and user interfaces.", + "schemas.field.hintsHint": "Describe this field for documentation and the UI.", "schemas.field.inlineEditable": "Inline Editable", - "schemas.field.labelHint": "Display name for documentation and user interfaces.", + "schemas.field.labelHint": "Display name for documentation and the UI.", "schemas.field.localizable": "Localizable", "schemas.field.localizableHint": "You can mark the field as localizable. It means that is dependent on the language, for example a city name.", "schemas.field.localizableMarker": "localizable", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index 8284cdfea..4176a6bf6 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -21,7 +21,7 @@ "apps.createBlankApp": "Nuova App.", "apps.createBlankAppDescription": "Crea una app vuota senza contenuti o schema.", "apps.createBlogApp": "Nuovo blog", - "apps.createBlogAppDescription": "Inizia con il nostro blog già pronto per l'uso.", + "apps.createBlogAppDescription": "Inizia con un blog.", "apps.createFailed": "Non è stato possibile creare l'app. Per favore ricarica.", "apps.createIdentityApp": "Nuova Identity App", "apps.createIdentityAppDescription": "Crea un app per Squidex Identity.", @@ -254,6 +254,7 @@ "common.httpConflict": "Non è stato possibile effettuare l'aggiornamento. Un altro utente ha fatto delle modifiche. Per favore ricarica.", "common.httpLimit": "Hai superato il limite massimo di chiamate API.", "common.label": "Etichetta", + "common.language": "Language", "common.languages": "Lingue", "common.latitudeShort": "Lat", "common.loading": "Caricamento", @@ -663,6 +664,7 @@ "schemas.field.enable": "Abilita nella UI", "schemas.field.enabledMarker": "Abilitato", "schemas.field.halfWidth": "Half Width", + "schemas.field.halfWidthHint": "Shows the field with only the half width when on the edit or create page, when there is enough space.", "schemas.field.hiddenMarker": "Nasconsto", "schemas.field.hide": "Nascondi nelle API", "schemas.field.hintsHint": "Descrivi questo schema per la documentazione e le interfacce utente.", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 494565614..a90523bcb 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -254,6 +254,7 @@ "common.httpConflict": "De update is mislukt. Een andere gebruiker heeft een wijziging aangebracht. Laad opnieuw.", "common.httpLimit": "Je hebt de maximale limiet van API-aanroepen overschreden.", "common.label": "Label", + "common.language": "Language", "common.languages": "Talen", "common.latitudeShort": "Lat", "common.loading": "Laden", @@ -663,6 +664,7 @@ "schemas.field.enable": "Inschakelen in gebruikersinterface", "schemas.field.enabledMarker": "Ingeschakeld", "schemas.field.halfWidth": "Half Width", + "schemas.field.halfWidthHint": "Shows the field with only the half width when on the edit or create page, when there is enough space.", "schemas.field.hiddenMarker": "Verborgen", "schemas.field.hide": "Verbergen in API", "schemas.field.hintsHint": "Beschrijf dit schema voor documentatie en gebruikersinterfaces.", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 035f51931..c5655d7a0 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -254,6 +254,7 @@ "common.httpConflict": "Failed to make the update. Another user has made a change. Please reload.", "common.httpLimit": "You have exceeded the maximum limit of API calls.", "common.label": "Label", + "common.language": "Language", "common.languages": "Languages", "common.latitudeShort": "Lat", "common.loading": "Loading", @@ -663,11 +664,12 @@ "schemas.field.enable": "Enable in UI", "schemas.field.enabledMarker": "Enabled", "schemas.field.halfWidth": "Half Width", + "schemas.field.halfWidthHint": "Shows the field with only the half width when on the edit or create page, when there is enough space.", "schemas.field.hiddenMarker": "Hidden", "schemas.field.hide": "Hide in API", - "schemas.field.hintsHint": "Describe this schema for documentation and user interfaces.", + "schemas.field.hintsHint": "Describe this field for documentation and the UI.", "schemas.field.inlineEditable": "Inline Editable", - "schemas.field.labelHint": "Display name for documentation and user interfaces.", + "schemas.field.labelHint": "Display name for documentation and the UI.", "schemas.field.localizable": "Localizable", "schemas.field.localizableHint": "You can mark the field as localizable. It means that is dependent on the language, for example a city name.", "schemas.field.localizableMarker": "localizable", diff --git a/backend/i18n/source/frontend_it.json b/backend/i18n/source/frontend_it.json index a27b7c9e1..a7db499d6 100644 --- a/backend/i18n/source/frontend_it.json +++ b/backend/i18n/source/frontend_it.json @@ -21,7 +21,7 @@ "apps.createBlankApp": "Nuova App.", "apps.createBlankAppDescription": "Crea una app vuota senza contenuti o schema.", "apps.createBlogApp": "Nuovo blog", - "apps.createBlogAppDescription": "Inizia con il nostro blog già pronto per l'uso.", + "apps.createBlogAppDescription": "Inizia con un blog.", "apps.createFailed": "Non è stato possibile creare l'app. Per favore ricarica.", "apps.createIdentityApp": "Nuova Identity App", "apps.createIdentityAppDescription": "Crea un app per Squidex Identity.", diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionProperty.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionProperty.cs index 956314daa..61793f8bc 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionProperty.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionProperty.cs @@ -17,6 +17,8 @@ namespace Squidex.Domain.Apps.Core.HandleRules public string? Description { get; set; } + public string[]? Options { get; set; } + public bool IsFormattable { get; set; } public bool IsRequired { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs index 469e01a35..0371def5f 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs @@ -10,6 +10,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules public enum RuleActionPropertyEditor { Checkbox, + Dropdown, Email, Number, Password, diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleRegistry.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleRegistry.cs index 6a0f6fab6..b1b9c0fab 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleRegistry.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleRegistry.cs @@ -73,7 +73,10 @@ namespace Squidex.Domain.Apps.Core.HandleRules { if (property.CanRead && property.CanWrite) { - var actionProperty = new RuleActionProperty { Name = property.Name.ToCamelCase(), Display = property.Name }; + var actionProperty = new RuleActionProperty + { + Name = property.Name.ToCamelCase() + }; var display = property.GetCustomAttribute(); @@ -81,17 +84,29 @@ namespace Squidex.Domain.Apps.Core.HandleRules { actionProperty.Display = display.Name; } + else + { + actionProperty.Display = property.Name; + } if (!string.IsNullOrWhiteSpace(display?.Description)) { actionProperty.Description = display.Description; } - var type = property.PropertyType; + var type = GetType(property); - if ((GetDataAttribute(property) != null || (type.IsValueType && !IsNullable(type))) && type != typeof(bool) && type != typeof(bool?)) + if (!IsNullable(property.PropertyType)) { - actionProperty.IsRequired = true; + if (GetDataAttribute(property) != null) + { + actionProperty.IsRequired = true; + } + + if (type.IsValueType && !IsBoolean(type) && !type.IsEnum) + { + actionProperty.IsRequired = true; + } } if (property.GetCustomAttribute() != null) @@ -99,35 +114,45 @@ namespace Squidex.Domain.Apps.Core.HandleRules actionProperty.IsFormattable = true; } - var dataType = GetDataAttribute(property)?.DataType; - - if (type == typeof(bool) || type == typeof(bool?)) - { - actionProperty.Editor = RuleActionPropertyEditor.Checkbox; - } - else if (type == typeof(int) || type == typeof(int?)) - { - actionProperty.Editor = RuleActionPropertyEditor.Number; - } - else if (dataType == DataType.Url) + if (type.IsEnum) { - actionProperty.Editor = RuleActionPropertyEditor.Url; - } - else if (dataType == DataType.Password) - { - actionProperty.Editor = RuleActionPropertyEditor.Password; - } - else if (dataType == DataType.EmailAddress) - { - actionProperty.Editor = RuleActionPropertyEditor.Email; - } - else if (dataType == DataType.MultilineText) - { - actionProperty.Editor = RuleActionPropertyEditor.TextArea; + var values = Enum.GetNames(type); + + actionProperty.Options = values; + actionProperty.Editor = RuleActionPropertyEditor.Dropdown; } else { - actionProperty.Editor = RuleActionPropertyEditor.Text; + var dataType = GetDataAttribute(property)?.DataType; + + if (IsBoolean(type)) + { + actionProperty.Editor = RuleActionPropertyEditor.Checkbox; + } + else if (IsNumericType(type)) + { + actionProperty.Editor = RuleActionPropertyEditor.Number; + } + else if (dataType == DataType.Url) + { + actionProperty.Editor = RuleActionPropertyEditor.Url; + } + else if (dataType == DataType.Password) + { + actionProperty.Editor = RuleActionPropertyEditor.Password; + } + else if (dataType == DataType.EmailAddress) + { + actionProperty.Editor = RuleActionPropertyEditor.Email; + } + else if (dataType == DataType.MultilineText) + { + actionProperty.Editor = RuleActionPropertyEditor.TextArea; + } + else + { + actionProperty.Editor = RuleActionPropertyEditor.Text; + } } definition.Properties.Add(actionProperty); @@ -151,6 +176,50 @@ namespace Squidex.Domain.Apps.Core.HandleRules return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); } + private static bool IsBoolean(Type type) + { + switch (Type.GetTypeCode(type)) + { + case TypeCode.Boolean: + return true; + default: + return false; + } + } + + private static bool IsNumericType(Type type) + { + switch (Type.GetTypeCode(type)) + { + case TypeCode.Byte: + case TypeCode.SByte: + case TypeCode.UInt16: + case TypeCode.UInt32: + case TypeCode.UInt64: + case TypeCode.Int16: + case TypeCode.Int32: + case TypeCode.Int64: + case TypeCode.Decimal: + case TypeCode.Double: + case TypeCode.Single: + return true; + default: + return false; + } + } + + private static Type GetType(PropertyInfo property) + { + var type = property.PropertyType; + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + type = type.GetGenericArguments()[0]; + } + + return type; + } + private static string GetActionName(Type type) { return type.TypeName(false, ActionSuffix, ActionSuffixV2); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs index 10625d5ba..f9ed5c329 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs @@ -24,12 +24,14 @@ 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); - string ContentsUI(NamedId appId); - string ContentsUI(NamedId appId, NamedId schemaId); string ContentUI(NamedId appId, NamedId schemaId, DomainId contentId); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs index feb37c5d6..4e5c00aa8 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs @@ -8,43 +8,210 @@ 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) { +<<<<<<< HEAD case ContentCreated contentCreated: contentIdsBySchemaId.GetOrAddNew(contentCreated.SchemaId.Id).Add(@event.Headers.AggregateId()); +======= + case AppCreated appCreated: + assetsUrlNew = GetUrls(appCreated.Name); + assetsUrlOld = await ReadUrlsAsync(context.Reader); +>>>>>>> release/4.x 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 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; + } } - return Task.FromResult(true); + 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) @@ -56,5 +223,26 @@ namespace Squidex.Domain.Apps.Entities.Contents await rebuilder.InsertManyAsync(ids); } } + + 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 293faf74c..bb85f42fa 100644 --- a/backend/src/Squidex.Web/Services/UrlGenerator.cs +++ b/backend/src/Squidex.Web/Services/UrlGenerator.cs @@ -47,6 +47,16 @@ namespace Squidex.Web.Services return urlsOptions.BuildUrl($"app/{appId.Name}/settings", false); } + public string AssetContentBase() + { + return urlsOptions.BuildUrl("api/assets/"); + } + + public string AssetContentBase(string appName) + { + return urlsOptions.BuildUrl($"api/assets/{appName}/"); + } + public string AssetContent(NamedId appId, DomainId assetId) { return urlsOptions.BuildUrl($"api/assets/{appId.Name}/{assetId}"); @@ -54,7 +64,7 @@ namespace Squidex.Web.Services public string AssetContent(NamedId appId, string idOrSlug) { - return urlsOptions.BuildUrl($"assets/{appId.Name}/{idOrSlug}"); + return urlsOptions.BuildUrl($"api/assets/{appId.Name}/{idOrSlug}"); } public string? AssetSource(NamedId appId, DomainId assetId, long fileVersion) @@ -67,6 +77,11 @@ namespace Squidex.Web.Services return urlsOptions.BuildUrl($"app/{appId.Name}/assets", false) + query != null ? $"?query={query}" : string.Empty; } + public string AssetsUI(NamedId appId, string? query = null) + { + return urlsOptions.BuildUrl($"app/{appId.Name}/assets?query={query}", false); + } + public string BackupsUI(NamedId appId) { return urlsOptions.BuildUrl($"app/{appId.Name}/settings/backups", false); diff --git a/backend/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs b/backend/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs index a0bbae023..64609ed43 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs @@ -16,7 +16,7 @@ namespace Squidex.Areas.Api.Controllers.News.Service { public sealed class FeaturesService { - private const int FeatureVersion = 11; + private const int FeatureVersion = 13; private readonly QueryContext flatten = QueryContext.Default.Flatten(); private readonly IContentsClient client; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs index f6180f369..529540b1a 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs @@ -30,6 +30,11 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models [LocalizedRequired] public string Display { get; set; } + /// + /// The options, if the editor is a dropdown. + /// + public string[]? Options { get; set; } + /// /// The optional description. /// diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleElementRegistryTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleElementRegistryTests.cs index 3cfca4238..c446ab897 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleElementRegistryTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleElementRegistryTests.cs @@ -41,6 +41,12 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules public string Custom { get; set; } } + public enum ActionEnum + { + Yes, + No + } + [RuleAction( Title = "Action", IconImage = "", @@ -68,6 +74,12 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules [DataType(DataType.Password)] public string Password { get; set; } + [DataType(DataType.Text)] + public ActionEnum Enum { get; set; } + + [DataType(DataType.Text)] + public ActionEnum? EnumOptional { get; set; } + [DataType(DataType.Text)] public bool Boolean { get; set; } @@ -141,6 +153,26 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules IsRequired = false }); + expected.Properties.Add(new RuleActionProperty + { + Name = "enum", + Display = "Enum", + Description = null, + Editor = RuleActionPropertyEditor.Dropdown, + IsRequired = true, + Options = new[] { "Yes", "No" } + }); + + expected.Properties.Add(new RuleActionProperty + { + Name = "enumOptional", + Display = "EnumOptional", + Description = null, + Editor = RuleActionPropertyEditor.Dropdown, + IsRequired = false, + Options = new[] { "Yes", "No" } + }); + expected.Properties.Add(new RuleActionProperty { Name = "boolean", 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 3042f8ba5..f1d7c76b8 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs @@ -9,13 +9,17 @@ 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 @@ -23,12 +27,13 @@ namespace Squidex.Domain.Apps.Entities.Contents public class BackupContentsTests { private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); + 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,116 @@ 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 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(appId.Name)) + .Returns(assetsUrlApp); + + var writer = A.Fake(); + + var context = new BackupContext(appId.Id, new UserMapping(me), writer); + + await sut.BackupEventAsync(Envelope.Create(new AppCreated + { + Name = appId.Name + }), 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 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(appId.Name)) + .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.Id, new UserMapping(me), reader, DomainId.NewGuid()); + + await sut.RestoreEventAsync(Envelope.Create(new AppCreated + { + Name = appId.Name + }), 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 schemaId1 = NamedId.Of(DomainId.NewGuid(), "my-schema1"); var schemaId2 = NamedId.Of(DomainId.NewGuid(), "my-schema2"); @@ -47,7 +159,7 @@ namespace Squidex.Domain.Apps.Entities.Contents var contentId2 = DomainId.NewGuid(); var contentId3 = DomainId.NewGuid(); - var context = new RestoreContext(appId.Id, new UserMapping(new RefToken(RefTokenType.Subject, "123")), A.Fake(), DomainId.NewGuid()); + var context = new RestoreContext(appId.Id, new UserMapping(me), A.Fake(), DomainId.NewGuid()); await sut.RestoreEventAsync(ContentEvent(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 d54d4210d..6be67c08f 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 @@ -46,6 +46,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(); diff --git a/frontend/app/features/rules/pages/rules/actions/generic-action.component.html b/frontend/app/features/rules/pages/rules/actions/generic-action.component.html index bf34b98c8..1dc785163 100644 --- a/frontend/app/features/rules/pages/rules/actions/generic-action.component.html +++ b/frontend/app/features/rules/pages/rules/actions/generic-action.component.html @@ -13,12 +13,18 @@
- +
+ + + diff --git a/frontend/app/features/schemas/pages/schema/fields/forms/field-form-ui.component.html b/frontend/app/features/schemas/pages/schema/fields/forms/field-form-ui.component.html index 9ce287760..e66cf7103 100644 --- a/frontend/app/features/schemas/pages/schema/fields/forms/field-form-ui.component.html +++ b/frontend/app/features/schemas/pages/schema/fields/forms/field-form-ui.component.html @@ -1,5 +1,5 @@
-
+
@@ -43,14 +43,18 @@
-
-
+
+
+ + + {{ 'schemas.field.halfWidthHint' | sqxTranslate }} +
diff --git a/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.html b/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.html index f0faa91be..d3a3b57d7 100644 --- a/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.html +++ b/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.html @@ -1,5 +1,5 @@
-
+
diff --git a/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.scss b/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.scss index 99ca63921..e69de29bb 100644 --- a/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.scss +++ b/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.scss @@ -1,3 +0,0 @@ -.form-group { - margin-bottom: .5rem; -} \ No newline at end of file diff --git a/frontend/app/features/schemas/pages/schema/fields/types/string-validation.component.html b/frontend/app/features/schemas/pages/schema/fields/types/string-validation.component.html index 6148d896b..ae4bc8db4 100644 --- a/frontend/app/features/schemas/pages/schema/fields/types/string-validation.component.html +++ b/frontend/app/features/schemas/pages/schema/fields/types/string-validation.component.html @@ -61,8 +61,10 @@
+ +
-
+
diff --git a/frontend/app/features/settings/pages/clients/client.component.html b/frontend/app/features/settings/pages/clients/client.component.html index e657c70a4..5ff127d80 100644 --- a/frontend/app/features/settings/pages/clients/client.component.html +++ b/frontend/app/features/settings/pages/clients/client.component.html @@ -85,8 +85,8 @@ {{ 'clients.apiCallsLimitHint' | sqxTranslate }}
-
-
diff --git a/frontend/app/features/settings/pages/clients/client.component.scss b/frontend/app/features/settings/pages/clients/client.component.scss index 29d4f11cc..50ae688c7 100644 --- a/frontend/app/features/settings/pages/clients/client.component.scss +++ b/frontend/app/features/settings/pages/clients/client.component.scss @@ -18,4 +18,8 @@ .cell-input { padding-right: 0; +} + +.cell-button { + padding-left: .25rem; } \ No newline at end of file diff --git a/frontend/app/shared/services/rules.service.spec.ts b/frontend/app/shared/services/rules.service.spec.ts index ff60594e5..3966c6359 100644 --- a/frontend/app/shared/services/rules.service.spec.ts +++ b/frontend/app/shared/services/rules.service.spec.ts @@ -64,7 +64,11 @@ describe('RulesService', () => { display: 'Display2', description: 'Description2', isRequired: false, - isFormattable: true + isFormattable: true, + options: [ + 'Yes', + 'No' + ] }] }, action1: { @@ -82,7 +86,7 @@ describe('RulesService', () => { const action2 = new RuleElementDto('title2', 'display2', 'description2', '#222', '', null, 'link2', [ new RuleElementPropertyDto('property1', 'Editor1', 'Display1', 'Description1', false, true), - new RuleElementPropertyDto('property2', 'Editor2', 'Display2', 'Description2', true, false) + new RuleElementPropertyDto('property2', 'Editor2', 'Display2', 'Description2', true, false, ['Yes', 'No']) ]); expect(actions!).toEqual({ diff --git a/frontend/app/shared/services/rules.service.ts b/frontend/app/shared/services/rules.service.ts index 56358e7fc..db8f46bc3 100644 --- a/frontend/app/shared/services/rules.service.ts +++ b/frontend/app/shared/services/rules.service.ts @@ -97,7 +97,8 @@ export class RuleElementPropertyDto { public readonly display: string, public readonly description: string, public readonly isFormattable: boolean, - public readonly isRequired: boolean + public readonly isRequired: boolean, + public readonly options?: ReadonlyArray ) { } } @@ -228,7 +229,8 @@ export class RulesService { property.display, property.description, property.isFormattable, - property.isRequired + property.isRequired, + property.options )); actions[key] = new RuleElementDto( diff --git a/frontend/app/shell/pages/internal/profile-menu.component.html b/frontend/app/shell/pages/internal/profile-menu.component.html index 1ff7839ca..6c58a9fb8 100644 --- a/frontend/app/shell/pages/internal/profile-menu.component.html +++ b/frontend/app/shell/pages/internal/profile-menu.component.html @@ -8,16 +8,14 @@ - - {{snapshot.profileDisplayName}} - +