using CMSBLL.BlockDataBinding; using CMSBLL.Common; using CMSBLL.DataSource; using CMSBLL.Site; using CMSDAL.DTO.BlockDataBinding; using CMSDAL.DTO.DataSource; using GB5Shared.DALCache; using GB5Shared.DTO.Framework.Login; using GB5Shared.Telemetry; using GB5Shared.Vault; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; using System.Net.Http.Headers; using System.Text; using System.Text.RegularExpressions; namespace CMSBLL.DataSourceProxy; // Dynamic data binding workhorse (Site/Page platform Phase 12). Fetches an external DataSource, // authenticates via a Vault-resolved secret (never inline — CLAUDE.md's non-negotiable secrets // rule), applies its ResponseMappingJson to shape the raw response into the flat prop patch a // block actually needs, and caches the mapped result for DataSourceDTO.CacheTtlSeconds. Both the // server-merge path (SiteDocumentBLL) and the client-fetch proxy endpoint (GetBlockData) share // this exact logic — the third-party endpoint and its secret are never exposed past this class. public class DataSourceProxyBLL : IDataSourceProxyBLL { private static readonly Regex PlaceholderPattern = new(@"\{(\w+)\}", RegexOptions.Compiled); private readonly IBlockDataBindingBLL _blockDataBindingBll; private readonly IDataSourceBLL _dataSourceBll; private readonly ISiteResolverBLL _siteResolverBll; private readonly IVaultService _vaultService; private readonly IHttpClientFactory _httpClientFactory; private readonly IDALCache _cache; private readonly ILogger _logger; private const string HttpClientName = "CmsDataSourceClient"; public DataSourceProxyBLL( IBlockDataBindingBLL blockDataBindingBll, IDataSourceBLL dataSourceBll, ISiteResolverBLL siteResolverBll, IVaultService vaultService, IHttpClientFactory httpClientFactory, IDALCache cache, ILogger logger) { _blockDataBindingBll = blockDataBindingBll; _dataSourceBll = dataSourceBll; _siteResolverBll = siteResolverBll; _vaultService = vaultService; _httpClientFactory = httpClientFactory; _cache = cache; _logger = logger; } public async Task ResolveServerMergePatchAsync(int contentBlockId, LoginDTO loginDTO, CancellationToken ct) { try { var binding = await _blockDataBindingBll.GetBindingForBlockAsync(contentBlockId, loginDTO, ct); if (binding is null || binding.BindingMode != BlockDataBindingMode.ServerSideRenderMerge) return null; var dataSource = await _dataSourceBll.GetByIdTypedAsync(binding.DataSourceId, loginDTO, ct); if (dataSource is null) { _logger.LogWarning("BlockDataBinding {BlockDataBindingId} references a missing DataSource {DataSourceId}.", binding.BlockDataBindingId, binding.DataSourceId); return null; } return await FetchAndMapAsync(dataSource, binding.ParamsJson, ct); } catch (Exception ex) { // A dynamic block failing to resolve must not fail the whole page render — the block // simply keeps its authored default PropsJson. GB5Trace.MarkFailed("resolve-server-merge-patch-failed", ex); _logger.LogWarning(ex, "Server-merge data binding resolution failed for ContentBlockId {ContentBlockId}.", contentBlockId); return null; } } public async Task GetClientFetchDescriptorAsync(int contentBlockId, LoginDTO loginDTO, CancellationToken ct) { try { var binding = await _blockDataBindingBll.GetBindingForBlockAsync(contentBlockId, loginDTO, ct); if (binding is null || binding.BindingMode != BlockDataBindingMode.ClientSideFetch) return null; var dataSource = await _dataSourceBll.GetByIdTypedAsync(binding.DataSourceId, loginDTO, ct); if (dataSource is null) return null; return new ClientFetchDescriptorDTO { DataSourceCode = dataSource.DataSourceCode, ParamsJson = binding.ParamsJson }; } catch (Exception ex) { GB5Trace.MarkFailed("get-client-fetch-descriptor-failed", ex); _logger.LogWarning(ex, "Client-fetch descriptor resolution failed for ContentBlockId {ContentBlockId}.", contentBlockId); return null; } } public async Task ResolveDataAsync(string dataSourceCode, string? paramsJson, LoginDTO loginDTO, CancellationToken ct) { var dataSource = await _dataSourceBll.GetByCodeAsync(dataSourceCode, loginDTO, ct) ?? throw new InvalidOperationException($"DataSource '{dataSourceCode}' not found."); return await FetchAndMapAsync(dataSource, paramsJson, ct); } public async Task ResolveDataForDomainAsync(string domainName, string dataSourceCode, string? paramsJson, LoginDTO loginDTO, CancellationToken ct) { var site = await _siteResolverBll.ResolveByDomainAsync(domainName, loginDTO, ct); if (site is null) return null; var siteLogin = LoginTenantRebase.RebaseTenant(loginDTO, site.TenantId); return await ResolveDataAsync(dataSourceCode, paramsJson, siteLogin, ct); } private async Task FetchAndMapAsync(DataSourceDTO dataSource, string? paramsJson, CancellationToken ct) { var cacheKey = $"cms-databinding:{dataSource.DataSourceCode}:{paramsJson ?? string.Empty}"; var cached = await _cache.GetOrSetAsync( cacheKey, async innerCt => await FetchAndMapUncachedAsync(dataSource, paramsJson, innerCt).ConfigureAwait(false), dataSource.CacheTtlSeconds, ct); return cached ?? "{}"; } private async Task FetchAndMapUncachedAsync(DataSourceDTO dataSource, string? paramsJson, CancellationToken ct) { var paramsDict = string.IsNullOrWhiteSpace(paramsJson) ? new Dictionary() : JObject.Parse(paramsJson).Properties().ToDictionary(p => p.Name, p => p.Value.ToString()); var url = PlaceholderPattern.Replace(dataSource.EndpointTemplate, m => paramsDict.TryGetValue(m.Groups[1].Value, out var value) ? Uri.EscapeDataString(value) : m.Value); using var request = new HttpRequestMessage( new HttpMethod(string.IsNullOrWhiteSpace(dataSource.HttpMethod) ? "GET" : dataSource.HttpMethod), url); await ApplyAuthAsync(request, dataSource, ct).ConfigureAwait(false); var client = _httpClientFactory.CreateClient(HttpClientName); using var response = await client.SendAsync(request, ct).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { _logger.LogWarning("DataSource {DataSourceCode} returned HTTP {StatusCode}.", dataSource.DataSourceCode, (int)response.StatusCode); return null; } var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); return ApplyResponseMapping(body, dataSource.ResponseMappingJson); } // AuthHeaderName/VaultSecretPath are the only auth-relevant fields ever logged — the actual // secret value from IVaultService.GetSecretAsync must never reach a log line. private async Task ApplyAuthAsync(HttpRequestMessage request, DataSourceDTO dataSource, CancellationToken ct) { if (dataSource.AuthMode == DataSourceAuthMode.None || string.IsNullOrWhiteSpace(dataSource.VaultSecretPath)) return; var secret = await _vaultService.GetSecretAsync(dataSource.VaultSecretPath, ct).ConfigureAwait(false); switch (dataSource.AuthMode) { case DataSourceAuthMode.ApiKey: request.Headers.Remove(dataSource.AuthHeaderName ?? "X-Api-Key"); request.Headers.TryAddWithoutValidation(dataSource.AuthHeaderName ?? "X-Api-Key", secret); break; case DataSourceAuthMode.Bearer: request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secret); break; case DataSourceAuthMode.Basic: // VaultSecretPath is expected to hold the already-combined "user:pass" for Basic // auth — GB5 only ever base64-encodes it here, it never assembles credentials. var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(secret)); request.Headers.Authorization = new AuthenticationHeaderValue("Basic", encoded); break; } } // ResponseMappingJson: {"targetPropKey": "$.path.in.response", ...}. Newtonsoft's // JObject.SelectToken already implements the JSONPath subset this needs — no reason to hand- // roll a second path-resolution mechanism. private static string ApplyResponseMapping(string responseBody, string? responseMappingJson) { if (string.IsNullOrWhiteSpace(responseMappingJson)) return responseBody; JObject responseObj; try { responseObj = JObject.Parse(responseBody); } catch { return "{}"; } var mapping = JObject.Parse(responseMappingJson); var patch = new JObject(); foreach (var prop in mapping.Properties()) { var path = prop.Value.ToString(); var token = responseObj.SelectToken(path); if (token is not null) patch[prop.Name] = token.DeepClone(); } return patch.ToString(Newtonsoft.Json.Formatting.None); } }