Files
Umbraco-CMS/src/Umbraco.Infrastructure/Configuration/JsonConfigManipulator.cs

287 lines
9.8 KiB
C#

using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration;
using Umbraco.Cms.Core.Configuration.Models;
namespace Umbraco.Cms.Infrastructure.Configuration;
internal sealed 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 JsonDocumentOptions _jsonDocumentOptions = new() { CommentHandling = JsonCommentHandling.Skip };
private readonly IConfiguration _configuration;
private readonly ILogger<JsonConfigManipulator> _logger;
private readonly SemaphoreSlim _lock = new(1, 1);
public JsonConfigManipulator(IConfiguration configuration, ILogger<JsonConfigManipulator> logger)
{
_configuration = configuration;
_logger = logger;
}
/// <inheritdoc />
public async Task RemoveConnectionStringAsync()
{
JsonConfigurationProvider? provider = GetJsonConfigurationProvider(UmbracoConnectionStringPath);
JsonNode? jsonNode = await GetJsonNodeAsync(provider);
if (jsonNode is null)
{
_logger.LogWarning("Failed to remove connection string from JSON configuration");
return;
}
RemoveJsonNode(jsonNode, UmbracoConnectionStringPath);
RemoveJsonNode(jsonNode, UmbracoConnectionStringProviderNamePath);
await SaveJsonAsync(provider, jsonNode);
}
/// <inheritdoc />
public async Task SaveConnectionStringAsync(string connectionString, string? providerName)
{
JsonConfigurationProvider? provider = GetJsonConfigurationProvider();
JsonNode? node = await GetJsonNodeAsync(provider);
if (node is null)
{
_logger.LogWarning("Was unable to load the configuration file to save the connection string");
return;
}
CreateOrUpdateJsonNode(node, UmbracoConnectionStringPath, connectionString);
if (providerName is not null)
{
CreateOrUpdateJsonNode(node, UmbracoConnectionStringProviderNamePath, providerName);
}
await SaveJsonAsync(provider, node);
}
/// <inheritdoc />
public async Task SaveConfigValueAsync(string itemPath, object value)
{
JsonConfigurationProvider? provider = GetJsonConfigurationProvider();
JsonNode? node = await GetJsonNodeAsync(provider);
if (node is null)
{
_logger.LogWarning("Failed to save configuration key \"{Key}\" in JSON configuration", itemPath);
return;
}
JsonNode? propertyNode = node;
foreach (var propertyName in itemPath.Split(':'))
{
propertyNode = FindChildNode(propertyNode, propertyName);
}
if (propertyNode is null)
{
return;
}
propertyNode.ReplaceWith(value);
await SaveJsonAsync(provider, node);
}
/// <inheritdoc />
public async Task SaveDisableRedirectUrlTrackingAsync(bool disable)
=> await CreateOrUpdateConfigValueAsync(DisableRedirectUrlTrackingPath, disable);
/// <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)
{
_logger.LogWarning("Failed to save configuration key \"{Key}\" in JSON configuration", itemPath);
return;
}
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)
{
JsonNode? found = FindChildNode(propertyNode, propertyName);
if (found is null)
{
break;
}
propertyNode = found;
index++;
}
// We can now use the index to go through the remaining keys, creating them as we go.
while (index < propertyNames.Length)
{
var propertyName = propertyNames[index];
var newNode = new JsonObject();
propertyNode.AsObject()[propertyName] = newNode;
propertyNode = newNode;
index++;
}
// 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);
}
private static void RemoveJsonNode(JsonNode node, string key)
{
JsonNode? propertyNode = node;
foreach (var propertyName in key.Split(':'))
{
propertyNode = FindChildNode(propertyNode, propertyName);
}
propertyNode?.Parent?.AsObject().Remove(propertyNode.GetPropertyName());
}
private async Task SaveJsonAsync(JsonConfigurationProvider? provider, JsonNode jsonNode)
{
if (provider?.Source.FileProvider is not PhysicalFileProvider physicalFileProvider)
{
return;
}
await _lock.WaitAsync();
try
{
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);
}
finally
{
_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, documentOptions: _jsonDocumentOptions);
}
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;
}
}