Merge remote-tracking branch 'origin/v14/dev' into v14/dev

This commit is contained in:
Bjarke Berg
2024-01-24 11:12:45 +01:00
7 changed files with 323 additions and 283 deletions

View File

@@ -43,7 +43,7 @@ public class SetStatusRedirectUrlManagementController : RedirectUrlManagementCon
// For now I'm not gonna change this to limit breaking, but it's weird to have a "disabled" switch,
// since you're essentially negating the boolean from the get go,
// it's much easier to reason with enabled = false == disabled.
_configManipulator.SaveDisableRedirectUrlTracking(!enable);
await _configManipulator.SaveDisableRedirectUrlTrackingAsync(!enable);
// Taken from the existing implementation in RedirectUrlManagementController
// TODO this is ridiculous, but we need to ensure the configuration is reloaded, before this request is ended.

View File

@@ -2,13 +2,81 @@ namespace Umbraco.Cms.Core.Configuration;
public interface IConfigManipulator
{
[Obsolete("Use RemoveConnectionStringAsync instead, scheduled for removal in V16.")]
void RemoveConnectionString();
[Obsolete("Use SaveConnectionStringAsync instead, scheduled for removal in V16.")]
void SaveConnectionString(string connectionString, string? providerName);
[Obsolete("Use SaveConfigValueAsync instead, scheduled for removal in V16.")]
void SaveConfigValue(string itemPath, object value);
[Obsolete("Use SaveDisableRedirectUrlTrackingAsync instead, scheduled for removal in V16.")]
void SaveDisableRedirectUrlTracking(bool disable);
[Obsolete("Use SetGlobalIdAsync instead, scheduled for removal in V16.")]
void SetGlobalId(string id);
/// <summary>
/// Removes the connection string from the configuration file
/// </summary>
/// <returns></returns>
Task RemoveConnectionStringAsync()
{
RemoveConnectionString();
return Task.CompletedTask;
}
/// <summary>
/// Saves the connection string to the configuration file
/// </summary>
/// <param name="connectionString"></param>
/// <param name="providerName"></param>
/// <returns></returns>
Task SaveConnectionStringAsync(string connectionString, string? providerName)
{
SaveConnectionString(connectionString, providerName);
return Task.CompletedTask;
}
/// <summary>
/// Updates a value in the configuration file.
/// <remarks>Will only update an existing key in the configuration file, if it does not exists nothing is saved</remarks>
/// </summary>
/// <param name="itemPath">Path to update, uses : as the separator.</param>
/// <param name="value">The new value.</param>
/// <returns></returns>
Task SaveConfigValueAsync(string itemPath, object value)
{
SaveConfigValue(itemPath, value);
return Task.CompletedTask;
}
/// <summary>
/// Updates the disableRedirectUrlTracking value in the configuration file.
/// <remarks>
/// Will create the node if it does not already exist.
/// </remarks>
/// </summary>
/// <param name="disable">The value to save.</param>
/// <returns></returns>
Task SaveDisableRedirectUrlTrackingAsync(bool disable)
{
SaveDisableRedirectUrlTracking(disable);
return Task.CompletedTask;
}
/// <summary>
/// Sets the global id in the configuration file.
/// <remarks>
/// Will create the node if it does not already exist.
/// </remarks>
/// </summary>
/// <param name="id">The ID to save.</param>
/// <returns></returns>
Task SetGlobalIdAsync(string id)
{
SetGlobalId(id);
return Task.CompletedTask;
}
}

View File

@@ -65,7 +65,7 @@ internal class SiteIdentifierService : ISiteIdentifierService
try
{
_configManipulator.SetGlobalId(createdGuid.ToString());
_configManipulator.SetGlobalIdAsync(createdGuid.ToString()).GetAwaiter().GetResult();
}
catch (Exception ex)
{

View File

@@ -1,329 +1,300 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
namespace Umbraco.Cms.Core.Configuration
namespace Umbraco.Cms.Infrastructure.Configuration;
internal class JsonConfigManipulator : IConfigManipulator
{
public class JsonConfigManipulator : IConfigManipulator
private const string ConnectionStringObjectName = "ConnectionStrings";
private const string UmbracoConnectionStringPath = $"{ConnectionStringObjectName}:{Constants.System.UmbracoConnectionName}";
private const string UmbracoConnectionStringProviderNamePath = UmbracoConnectionStringPath + ConnectionStrings.ProviderNamePostfix;
private const string CmsObjectPath = "Umbraco:CMS";
private const string GlobalIdPath = $"{CmsObjectPath}:Global:Id";
private const string DisableRedirectUrlTrackingPath = $"{CmsObjectPath}:WebRouting:DisableRedirectUrlTracking";
private readonly IConfiguration _configuration;
private readonly ILogger<JsonConfigManipulator> _logger;
private readonly SemaphoreSlim _lock = new(1, 1);
public JsonConfigManipulator(IConfiguration configuration, ILogger<JsonConfigManipulator> logger)
{
private const string UmbracoConnectionStringPath = $"ConnectionStrings:{Cms.Core.Constants.System.UmbracoConnectionName}";
private const string UmbracoConnectionStringProviderNamePath = UmbracoConnectionStringPath + ConnectionStrings.ProviderNamePostfix;
_configuration = configuration;
_logger = logger;
}
private readonly IConfiguration _configuration;
private readonly ILogger<JsonConfigManipulator> _logger;
private readonly object _locker = new object();
public void RemoveConnectionString()
=> RemoveConnectionStringAsync().GetAwaiter().GetResult();
public JsonConfigManipulator(IConfiguration configuration, ILogger<JsonConfigManipulator> logger)
/// <inheritdoc />
public async Task RemoveConnectionStringAsync()
{
JsonConfigurationProvider? provider = GetJsonConfigurationProvider(UmbracoConnectionStringPath);
JsonNode? jsonNode = await GetJsonNodeAsync(provider);
if (jsonNode is null)
{
_configuration = configuration;
_logger = logger;
_logger.LogWarning("Failed to remove connection string from JSON configuration");
return;
}
[Obsolete]
public string UmbracoConnectionPath { get; } = UmbracoConnectionStringPath;
RemoveJsonNode(jsonNode, UmbracoConnectionStringPath);
RemoveJsonNode(jsonNode, UmbracoConnectionStringProviderNamePath);
public void RemoveConnectionString()
await SaveJsonAsync(provider, jsonNode);
}
public void SaveConnectionString(string connectionString, string? providerName)
=> SaveConnectionStringAsync(connectionString, providerName).GetAwaiter().GetResult();
/// <inheritdoc />
public async Task SaveConnectionStringAsync(string connectionString, string? providerName)
{
JsonConfigurationProvider? provider = GetJsonConfigurationProvider();
JsonNode? node = await GetJsonNodeAsync(provider);
if (node is null)
{
// Remove keys from JSON
var provider = GetJsonConfigurationProvider(UmbracoConnectionStringPath);
var json = GetJson(provider);
if (json is null)
{
_logger.LogWarning("Failed to remove connection string from JSON configuration.");
return;
}
RemoveJsonKey(json, UmbracoConnectionStringPath);
RemoveJsonKey(json, UmbracoConnectionStringProviderNamePath);
SaveJson(provider, json);
_logger.LogWarning("Was unable to load the configuration file to save the connection string");
return;
}
public void SaveConnectionString(string connectionString, string? providerName)
CreateOrUpdateJsonNode(node, UmbracoConnectionStringPath, connectionString);
if (providerName is not null)
{
// Save keys to JSON
var provider = GetJsonConfigurationProvider();
var json = GetJson(provider);
if (json is null)
{
_logger.LogWarning("Failed to save connection string in JSON configuration.");
return;
}
var item = GetConnectionItem(connectionString, providerName);
if (item is not null)
{
json.Merge(item, new JsonMergeSettings());
}
SaveJson(provider, json);
CreateOrUpdateJsonNode(node, UmbracoConnectionStringProviderNamePath, providerName);
}
public void SaveConfigValue(string key, object value)
await SaveJsonAsync(provider, node);
}
public void SaveConfigValue(string key, object value)
=> SaveConfigValueAsync(key, value).GetAwaiter().GetResult();
/// <inheritdoc />
public async Task SaveConfigValueAsync(string itemPath, object value)
{
JsonConfigurationProvider? provider = GetJsonConfigurationProvider();
JsonNode? node = await GetJsonNodeAsync(provider);
if (node is null)
{
// Save key to JSON
var provider = GetJsonConfigurationProvider();
var json = GetJson(provider);
if (json is null)
{
_logger.LogWarning("Failed to save configuration key \"{Key}\" in JSON configuration.", key);
return;
}
JToken? token = json;
foreach (var propertyName in key.Split(new[] { ':' }))
{
if (token is null)
break;
token = CaseSelectPropertyValues(token, propertyName);
}
if (token is null)
return;
var writer = new JTokenWriter();
writer.WriteValue(value);
if (writer.Token is not null)
{
token.Replace(writer.Token);
}
SaveJson(provider, json);
_logger.LogWarning("Failed to save configuration key \"{Key}\" in JSON configuration", itemPath);
return;
}
public void SaveDisableRedirectUrlTracking(bool disable)
JsonNode? propertyNode = node;
foreach (var propertyName in itemPath.Split(':'))
{
// Save key to JSON
var provider = GetJsonConfigurationProvider();
var json = GetJson(provider);
if (json is null)
{
_logger.LogWarning("Failed to save enabled/disabled state for redirect URL tracking in JSON configuration.");
return;
}
var item = GetDisableRedirectUrlItem(disable);
if (item is not null)
{
json.Merge(item, new JsonMergeSettings());
}
SaveJson(provider, json);
propertyNode = FindChildNode(propertyNode, propertyName);
}
public void SetGlobalId(string id)
if (propertyNode is null)
{
// Save key to JSON
var provider = GetJsonConfigurationProvider();
var json = GetJson(provider);
if (json is null)
{
_logger.LogWarning("Failed to save global identifier in JSON configuration.");
return;
}
var item = GetGlobalIdItem(id);
if (item is not null)
{
json.Merge(item, new JsonMergeSettings());
}
SaveJson(provider, json);
return;
}
private object? GetGlobalIdItem(string id)
propertyNode.ReplaceWith(value);
await SaveJsonAsync(provider, node);
}
public void SaveDisableRedirectUrlTracking(bool disable)
=> SaveDisableRedirectUrlTrackingAsync(disable).GetAwaiter().GetResult();
/// <inheritdoc />
public async Task SaveDisableRedirectUrlTrackingAsync(bool disable)
=> await CreateOrUpdateConfigValueAsync(DisableRedirectUrlTrackingPath, disable);
public void SetGlobalId(string id)
=> SetGlobalIdAsync(id).GetAwaiter().GetResult();
/// <inheritdoc />
public async Task SetGlobalIdAsync(string id)
=> await CreateOrUpdateConfigValueAsync(GlobalIdPath, id);
/// <summary>
/// Creates or updates a config value at the specified path.
/// <remarks>This causes a rewrite of the configuration file.</remarks>
/// </summary>
/// <param name="itemPath">Path to update, uses : as the separator.</param>
/// <param name="value">The value of the node.</param>
private async Task CreateOrUpdateConfigValueAsync(string itemPath, object value)
{
JsonConfigurationProvider? provider = GetJsonConfigurationProvider();
JsonNode? node = await GetJsonNodeAsync(provider);
if (node is null)
{
JTokenWriter writer = new JTokenWriter();
writer.WriteStartObject();
writer.WritePropertyName("Umbraco");
writer.WriteStartObject();
writer.WritePropertyName("CMS");
writer.WriteStartObject();
writer.WritePropertyName("Global");
writer.WriteStartObject();
writer.WritePropertyName("Id");
writer.WriteValue(id);
writer.WriteEndObject();
writer.WriteEndObject();
writer.WriteEndObject();
writer.WriteEndObject();
return writer.Token;
_logger.LogWarning("Failed to save configuration key \"{Key}\" in JSON configuration", itemPath);
return;
}
private JToken? GetDisableRedirectUrlItem(bool value)
CreateOrUpdateJsonNode(node, itemPath, value);
await SaveJsonAsync(provider, node);
}
/// <summary>
/// Updates or creates a json node at the specified path.
/// <remarks>
/// Will also create any missing nodes in the path.
/// </remarks>
/// </summary>
/// <param name="node">Node to create or update.</param>
/// <param name="itemPath">Path to create or update, uses : as the separator.</param>
/// <param name="value">The value of the node.</param>
private static void CreateOrUpdateJsonNode(JsonNode node, string itemPath, object value)
{
// This is required because System.Text.Json has no merge function, and doesn't support patch
// this is a problem because we don't know if the key(s) exists yet, so we can't simply update it,
// we may have to create one ore more json objects.
// First we find the inner most child that already exists.
var propertyNames = itemPath.Split(':');
JsonNode propertyNode = node;
var index = 0;
foreach (var propertyName in propertyNames)
{
JTokenWriter writer = new JTokenWriter();
JsonNode? found = FindChildNode(propertyNode, propertyName);
if (found is null)
{
break;
}
writer.WriteStartObject();
writer.WritePropertyName("Umbraco");
writer.WriteStartObject();
writer.WritePropertyName("CMS");
writer.WriteStartObject();
writer.WritePropertyName("WebRouting");
writer.WriteStartObject();
writer.WritePropertyName("DisableRedirectUrlTracking");
writer.WriteValue(value);
writer.WriteEndObject();
writer.WriteEndObject();
writer.WriteEndObject();
writer.WriteEndObject();
return writer.Token;
propertyNode = found;
index++;
}
private JToken? GetConnectionItem(string connectionString, string? providerName)
// We can now use the index to go through the remaining keys, creating them as we go.
while (index < propertyNames.Length)
{
JTokenWriter writer = new JTokenWriter();
writer.WriteStartObject();
writer.WritePropertyName("ConnectionStrings");
writer.WriteStartObject();
writer.WritePropertyName(Constants.System.UmbracoConnectionName);
writer.WriteValue(connectionString);
if (!string.IsNullOrEmpty(providerName))
{
writer.WritePropertyName(Constants.System.UmbracoConnectionName + ConnectionStrings.ProviderNamePostfix);
writer.WriteValue(providerName);
}
writer.WriteEndObject();
writer.WriteEndObject();
return writer.Token;
var propertyName = propertyNames[index];
var newNode = new JsonObject();
propertyNode.AsObject()[propertyName] = newNode;
propertyNode = newNode;
index++;
}
private static void RemoveJsonKey(JObject? json, string key)
{
JToken? token = json;
foreach (var propertyName in key.Split(new[] { ':' }))
{
token = CaseSelectPropertyValues(token, propertyName);
}
// System.Text.Json doesn't like just setting an Object as a value, so instead we first create the node,
// and then replace the value
propertyNode.ReplaceWith(value);
}
token?.Parent?.Remove();
private static void RemoveJsonNode(JsonNode node, string key)
{
JsonNode? propertyNode = node;
foreach (var propertyName in key.Split(':'))
{
propertyNode = FindChildNode(propertyNode, propertyName);
}
private void SaveJson(JsonConfigurationProvider? provider, JObject? json)
propertyNode?.Parent?.AsObject().Remove(propertyNode.GetPropertyName());
}
private async Task SaveJsonAsync(JsonConfigurationProvider? provider, JsonNode jsonNode)
{
if (provider?.Source.FileProvider is not PhysicalFileProvider physicalFileProvider)
{
if (provider is null)
{
return;
}
lock (_locker)
{
if (provider.Source.FileProvider is PhysicalFileProvider physicalFileProvider)
{
var jsonFilePath = Path.Combine(physicalFileProvider.Root, provider.Source.Path!);
try
{
using (var sw = new StreamWriter(jsonFilePath, false))
using (var jsonTextWriter = new JsonTextWriter(sw)
{
Formatting = Formatting.Indented,
})
{
json?.WriteTo(jsonTextWriter);
}
}
catch (IOException exception)
{
_logger.LogWarning(exception, "JSON configuration could not be written: {path}", jsonFilePath);
}
}
}
return;
}
private JObject? GetJson(JsonConfigurationProvider? provider)
await _lock.WaitAsync();
try
{
if (provider is null)
{
return null;
}
lock (_locker)
{
if (provider.Source.FileProvider is not PhysicalFileProvider physicalFileProvider)
{
return null;
}
var jsonFilePath = Path.Combine(physicalFileProvider.Root, provider.Source.Path!);
try
{
var serializer = new JsonSerializer();
using var sr = new StreamReader(jsonFilePath);
using var jsonTextReader = new JsonTextReader(sr);
return serializer.Deserialize<JObject>(jsonTextReader);
}
catch (IOException exception)
{
_logger.LogWarning(exception, "JSON configuration could not be read: {path}", jsonFilePath);
return null;
}
}
var jsonFilePath = Path.Combine(physicalFileProvider.Root, provider.Source.Path!);
await using var jsonConfigStream = new FileStream(jsonFilePath, FileMode.Create);
await using var writer = new Utf8JsonWriter(jsonConfigStream, new JsonWriterOptions { Indented = true });
jsonNode.WriteTo(writer);
}
private JsonConfigurationProvider? GetJsonConfigurationProvider(string? requiredKey = null)
finally
{
if (_configuration is IConfigurationRoot configurationRoot)
{
foreach (var provider in configurationRoot.Providers)
{
if (provider is JsonConfigurationProvider jsonConfigurationProvider &&
(requiredKey is null || provider.TryGet(requiredKey, out _)))
{
return jsonConfigurationProvider;
}
}
}
return null;
}
/// <summary>
/// Returns the property value when case insensative
/// </summary>
/// <remarks>
/// This method is required because keys are case insensative in IConfiguration.
/// JObject[..] do not support case insensative and JObject.Property(...) do not return a new JObject.
/// </remarks>
private static JToken? CaseSelectPropertyValues(JToken? token, string name)
{
if (token is JObject obj)
{
foreach (var property in obj.Properties())
{
if (name is null)
{
return property.Value;
}
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
{
return property.Value;
}
}
}
return null;
_lock.Release();
}
}
private async Task<JsonNode?> GetJsonNodeAsync(JsonConfigurationProvider? provider)
{
if (provider is null)
{
return null;
}
await _lock.WaitAsync();
if (provider.Source.FileProvider is not PhysicalFileProvider physicalFileProvider)
{
return null;
}
var jsonFilePath = Path.Combine(physicalFileProvider.Root, provider.Source.Path!);
try
{
using var streamReader = new StreamReader(jsonFilePath);
return await JsonNode.ParseAsync(streamReader.BaseStream);
}
catch (IOException exception)
{
_logger.LogWarning(exception, "JSON configuration could not be read: {Path}", jsonFilePath);
return null;
}
finally
{
_lock.Release();
}
}
private JsonConfigurationProvider? GetJsonConfigurationProvider(string? requiredKey = null)
{
if (_configuration is not IConfigurationRoot configurationRoot)
{
return null;
}
foreach (IConfigurationProvider provider in configurationRoot.Providers)
{
if (provider is JsonConfigurationProvider jsonConfigurationProvider &&
(requiredKey is null || provider.TryGet(requiredKey, out _)))
{
return jsonConfigurationProvider;
}
}
return null;
}
/// <summary>
/// Finds the immediate child with the specified name, in a case insensitive manner.
/// </summary>
/// <remarks>
/// This is required since keys are case insensitive in IConfiguration.
/// But not in JsonNode.
/// </remarks>
/// <param name="node">The node to search.</param>
/// <param name="key">The key to search for.</param>
/// <returns>The found node, null if no match is found.</returns>
private static JsonNode? FindChildNode(JsonNode? node, string key)
{
if (node is null)
{
return node;
}
foreach (KeyValuePair<string, JsonNode?> property in node.AsObject())
{
if (property.Key.Equals(key, StringComparison.OrdinalIgnoreCase))
{
return property.Value;
}
}
return null;
}
}

View File

@@ -42,6 +42,7 @@ using Umbraco.Cms.Core.Templates;
using Umbraco.Cms.Core.Trees;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Core.Webhooks;
using Umbraco.Cms.Infrastructure.Configuration;
using Umbraco.Cms.Infrastructure.DeliveryApi;
using Umbraco.Cms.Infrastructure.DistributedLocking;
using Umbraco.Cms.Infrastructure.Examine;

View File

@@ -219,7 +219,7 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Install
});
// Update configuration and wait for change
_configManipulator.SaveConnectionString(connectionString, providerName);
_configManipulator.SaveConnectionStringAsync(connectionString, providerName).GetAwaiter().GetResult();
if (!isChanged.WaitOne(10_000))
{
throw new InstallException("Didn't retrieve updated connection string within 10 seconds, try manual configuration instead.");

View File

@@ -55,13 +55,13 @@ public class SiteIdentifierServiceTests
if (shouldCreate)
{
configManipulatorMock.Verify(x => x.SetGlobalId(It.IsAny<string>()), Times.Once);
configManipulatorMock.Verify(x => x.SetGlobalIdAsync(It.IsAny<string>()), Times.Once);
Assert.AreNotEqual(Guid.Empty, identifier);
Assert.IsTrue(result);
}
else
{
configManipulatorMock.Verify(x => x.SetGlobalId(It.IsAny<string>()), Times.Never());
configManipulatorMock.Verify(x => x.SetGlobalIdAsync(It.IsAny<string>()), Times.Never());
Assert.AreEqual(guidString.ToLower(), identifier.ToString());
Assert.IsTrue(result);
}