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 _logger; private readonly SemaphoreSlim _lock = new(1, 1); public JsonConfigManipulator(IConfiguration configuration, ILogger logger) { _configuration = configuration; _logger = logger; } /// 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); } /// 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); } /// 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); } /// public async Task SaveDisableRedirectUrlTrackingAsync(bool disable) => await CreateOrUpdateConfigValueAsync(DisableRedirectUrlTrackingPath, disable); /// public async Task SetGlobalIdAsync(string id) => await CreateOrUpdateConfigValueAsync(GlobalIdPath, id); /// /// Creates or updates a config value at the specified path. /// This causes a rewrite of the configuration file. /// /// Path to update, uses : as the separator. /// The value of the node. 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); } /// /// Updates or creates a json node at the specified path. /// /// Will also create any missing nodes in the path. /// /// /// Node to create or update. /// Path to create or update, uses : as the separator. /// The value of the node. 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 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; } /// /// Finds the immediate child with the specified name, in a case insensitive manner. /// /// /// This is required since keys are case insensitive in IConfiguration. /// But not in JsonNode. /// /// The node to search. /// The key to search for. /// The found node, null if no match is found. private static JsonNode? FindChildNode(JsonNode? node, string key) { if (node is null) { return node; } foreach (KeyValuePair property in node.AsObject()) { if (property.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) { return property.Value; } } return null; } }