using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; using System.Web; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Notify.Models; using StellaOps.Notifier.Worker.Dispatch; namespace StellaOps.Notifier.Worker.Templates; /// /// Enhanced template renderer with multi-format output, configurable redaction, and provenance links. /// public sealed partial class EnhancedTemplateRenderer : INotifyTemplateRenderer { private readonly INotifyTemplateService _templateService; private readonly TemplateRendererOptions _options; private readonly ILogger _logger; public EnhancedTemplateRenderer( INotifyTemplateService templateService, IOptions options, ILogger logger) { _templateService = templateService ?? throw new ArgumentNullException(nameof(templateService)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task RenderAsync( NotifyTemplate template, NotifyEvent notifyEvent, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(template); ArgumentNullException.ThrowIfNull(notifyEvent); var redactionConfig = _templateService.GetRedactionConfig(template); var context = BuildContext(notifyEvent, redactionConfig); // Add provenance links to context AddProvenanceLinks(context, notifyEvent, template); // Render template body var rawBody = RenderTemplate(template.Body, context); // Convert to target format var convertedBody = ConvertFormat(rawBody, template.RenderMode, template.Format); // Render subject if present string? subject = null; if (template.Metadata.TryGetValue("subject", out var subjectTemplate) && !string.IsNullOrWhiteSpace(subjectTemplate)) { subject = RenderTemplate(subjectTemplate, context); } var bodyHash = ComputeHash(convertedBody); _logger.LogDebug( "Rendered template {TemplateId} for event {EventId}: {BodyLength} chars, format={Format}, hash={BodyHash}", template.TemplateId, notifyEvent.EventId, convertedBody.Length, template.Format, bodyHash); return new NotifyRenderedContent { Body = convertedBody, Subject = subject, BodyHash = bodyHash, Format = template.Format }; } private Dictionary BuildContext( NotifyEvent notifyEvent, TemplateRedactionConfig redactionConfig) { var context = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["eventId"] = notifyEvent.EventId.ToString(), ["kind"] = notifyEvent.Kind, ["tenant"] = notifyEvent.Tenant, ["timestamp"] = notifyEvent.Ts.ToString("O"), ["actor"] = notifyEvent.Actor, ["version"] = notifyEvent.Version, }; if (notifyEvent.Payload is JsonObject payload) { FlattenJson(payload, context, string.Empty, redactionConfig); } foreach (var (key, value) in notifyEvent.Attributes) { var contextKey = $"attr.{key}"; context[contextKey] = ShouldRedact(contextKey, redactionConfig) ? "[REDACTED]" : value; } return context; } private void FlattenJson( JsonObject obj, Dictionary context, string prefix, TemplateRedactionConfig redactionConfig) { foreach (var property in obj) { var key = string.IsNullOrEmpty(prefix) ? property.Key : $"{prefix}.{property.Key}"; if (property.Value is JsonObject nested) { FlattenJson(nested, context, key, redactionConfig); } else if (property.Value is JsonArray array) { context[key] = array; } else { var value = property.Value?.GetValue()?.ToString(); context[key] = ShouldRedact(key, redactionConfig) ? "[REDACTED]" : value; } } } private bool ShouldRedact(string key, TemplateRedactionConfig config) { var lowerKey = key.ToLowerInvariant(); // In "none" mode, never redact if (config.Mode == "none") { return false; } // Check explicit allowlist first (if in paranoid mode, must be in allowlist) if (config.AllowedFields.Count > 0) { foreach (var allowed in config.AllowedFields) { if (MatchesPattern(lowerKey, allowed.ToLowerInvariant())) { return false; // Explicitly allowed } } // In paranoid mode, if not in allowlist, redact if (config.Mode == "paranoid") { return true; } } // Check denylist foreach (var denied in config.DeniedFields) { var lowerDenied = denied.ToLowerInvariant(); // Check if key contains denied pattern if (lowerKey.Contains(lowerDenied) || MatchesPattern(lowerKey, lowerDenied)) { return true; } } return false; } private static bool MatchesPattern(string key, string pattern) { // Support wildcard patterns like "labels.*" if (pattern.EndsWith(".*")) { var prefix = pattern[..^2]; return key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); } return key.Equals(pattern, StringComparison.OrdinalIgnoreCase); } private void AddProvenanceLinks( Dictionary context, NotifyEvent notifyEvent, NotifyTemplate template) { if (string.IsNullOrWhiteSpace(_options.ProvenanceBaseUrl)) { return; } var baseUrl = _options.ProvenanceBaseUrl.TrimEnd('/'); // Add event provenance link context["provenance.eventUrl"] = $"{baseUrl}/events/{notifyEvent.EventId}"; context["provenance.traceUrl"] = $"{baseUrl}/traces/{notifyEvent.Tenant}/{notifyEvent.EventId}"; // Add template reference context["provenance.templateId"] = template.TemplateId; context["provenance.templateVersion"] = template.UpdatedAt.ToString("yyyyMMddHHmmss"); } private string RenderTemplate(string template, Dictionary context) { if (string.IsNullOrEmpty(template)) return string.Empty; var result = template; // Handle {{#each collection}}...{{/each}} blocks result = EachBlockRegex().Replace(result, match => { var collectionName = match.Groups[1].Value.Trim(); var innerTemplate = match.Groups[2].Value; if (!context.TryGetValue(collectionName, out var collection) || collection is not JsonArray array) { return string.Empty; } var sb = new StringBuilder(); var index = 0; foreach (var item in array) { var itemContext = new Dictionary(context, StringComparer.OrdinalIgnoreCase) { ["this"] = item?.ToString(), ["@index"] = index.ToString(), ["@first"] = (index == 0).ToString().ToLowerInvariant(), ["@last"] = (index == array.Count - 1).ToString().ToLowerInvariant() }; if (item is JsonObject itemObj) { foreach (var prop in itemObj) { itemContext[$"@{prop.Key}"] = prop.Value?.ToString(); } } sb.Append(RenderSimpleVariables(innerTemplate, itemContext)); index++; } return sb.ToString(); }); // Handle {{#if condition}}...{{/if}} blocks result = IfBlockRegex().Replace(result, match => { var conditionVar = match.Groups[1].Value.Trim(); var innerContent = match.Groups[2].Value; if (context.TryGetValue(conditionVar, out var value) && IsTruthy(value)) { return RenderSimpleVariables(innerContent, context); } return string.Empty; }); // Handle simple {{variable}} substitution result = RenderSimpleVariables(result, context); return result; } private static string RenderSimpleVariables(string template, Dictionary context) { return VariableRegex().Replace(template, match => { var key = match.Groups[1].Value.Trim(); // Handle format specifiers like {{timestamp|date}} or {{value|json}} var parts = key.Split('|'); var varName = parts[0].Trim(); var format = parts.Length > 1 ? parts[1].Trim() : null; if (context.TryGetValue(varName, out var value) && value is not null) { return FormatValue(value, format); } return string.Empty; }); } private static string FormatValue(object value, string? format) { if (format is null) { return value.ToString() ?? string.Empty; } return format.ToLowerInvariant() switch { "json" => JsonSerializer.Serialize(value), "html" => HttpUtility.HtmlEncode(value.ToString() ?? string.Empty) ?? string.Empty, "url" => Uri.EscapeDataString(value.ToString() ?? string.Empty), "upper" => value.ToString()?.ToUpperInvariant() ?? string.Empty, "lower" => value.ToString()?.ToLowerInvariant() ?? string.Empty, "date" when DateTimeOffset.TryParse(value.ToString(), out var dt) => dt.ToString("yyyy-MM-dd"), "datetime" when DateTimeOffset.TryParse(value.ToString(), out var dt) => dt.ToString("yyyy-MM-dd HH:mm:ss UTC"), _ => value.ToString() ?? string.Empty }; } private static bool IsTruthy(object? value) { if (value is null) return false; if (value is bool b) return b; if (value is string s) return !string.IsNullOrWhiteSpace(s) && s != "false" && s != "0"; if (value is JsonArray arr) return arr.Count > 0; return true; } private string ConvertFormat(string content, NotifyTemplateRenderMode renderMode, NotifyDeliveryFormat targetFormat) { // If source is already in target format, return as-is if (IsMatchingFormat(renderMode, targetFormat)) { return content; } return (renderMode, targetFormat) switch { // Markdown to HTML (NotifyTemplateRenderMode.Markdown, NotifyDeliveryFormat.Html) => ConvertMarkdownToHtml(content), // Markdown to PlainText (NotifyTemplateRenderMode.Markdown, NotifyDeliveryFormat.PlainText) => ConvertMarkdownToPlainText(content), // HTML to PlainText (NotifyTemplateRenderMode.Html, NotifyDeliveryFormat.PlainText) => ConvertHtmlToPlainText(content), // Markdown/PlainText to JSON (wrap in object) (_, NotifyDeliveryFormat.Json) => JsonSerializer.Serialize(new { text = content }), // Default: return as-is _ => content }; } private static bool IsMatchingFormat(NotifyTemplateRenderMode renderMode, NotifyDeliveryFormat targetFormat) { return (renderMode, targetFormat) switch { (NotifyTemplateRenderMode.Markdown, NotifyDeliveryFormat.Markdown) => true, (NotifyTemplateRenderMode.Html, NotifyDeliveryFormat.Html) => true, (NotifyTemplateRenderMode.PlainText, NotifyDeliveryFormat.PlainText) => true, (NotifyTemplateRenderMode.Json, NotifyDeliveryFormat.Json) => true, _ => false }; } private static string ConvertMarkdownToHtml(string markdown) { // Simple Markdown to HTML conversion (basic patterns) var html = markdown; // Headers html = Regex.Replace(html, @"^### (.+)$", "

$1

", RegexOptions.Multiline); html = Regex.Replace(html, @"^## (.+)$", "

$1

", RegexOptions.Multiline); html = Regex.Replace(html, @"^# (.+)$", "

$1

", RegexOptions.Multiline); // Bold html = Regex.Replace(html, @"\*\*(.+?)\*\*", "$1"); html = Regex.Replace(html, @"__(.+?)__", "$1"); // Italic html = Regex.Replace(html, @"\*(.+?)\*", "$1"); html = Regex.Replace(html, @"_(.+?)_", "$1"); // Code html = Regex.Replace(html, @"`(.+?)`", "$1"); // Links html = Regex.Replace(html, @"\[(.+?)\]\((.+?)\)", "$1"); // Line breaks html = html.Replace("\n\n", "

"); html = html.Replace("\n", "
"); // Wrap in paragraph if not already structured if (!html.StartsWith("<")) { html = $"

{html}

"; } return html; } private static string ConvertMarkdownToPlainText(string markdown) { var text = markdown; // Remove headers markers text = Regex.Replace(text, @"^#{1,6}\s*", "", RegexOptions.Multiline); // Convert bold/italic to plain text text = Regex.Replace(text, @"\*\*(.+?)\*\*", "$1"); text = Regex.Replace(text, @"__(.+?)__", "$1"); text = Regex.Replace(text, @"\*(.+?)\*", "$1"); text = Regex.Replace(text, @"_(.+?)_", "$1"); // Convert links to "text (url)" format text = Regex.Replace(text, @"\[(.+?)\]\((.+?)\)", "$1 ($2)"); // Remove code markers text = Regex.Replace(text, @"`(.+?)`", "$1"); return text; } private static string ConvertHtmlToPlainText(string html) { var text = html; // Remove HTML tags text = Regex.Replace(text, @"", "\n"); text = Regex.Replace(text, @"

", "\n\n"); text = Regex.Replace(text, @"<[^>]+>", ""); // Decode HTML entities text = HttpUtility.HtmlDecode(text); // Normalize whitespace text = Regex.Replace(text, @"\n{3,}", "\n\n"); return text.Trim(); } private static string ComputeHash(string content) { var bytes = Encoding.UTF8.GetBytes(content); var hash = SHA256.HashData(bytes); return Convert.ToHexString(hash).ToLowerInvariant(); } [GeneratedRegex(@"\{\{#each\s+(\w+(?:\.\w+)*)\s*\}\}(.*?)\{\{/each\}\}", RegexOptions.Singleline)] private static partial Regex EachBlockRegex(); [GeneratedRegex(@"\{\{#if\s+(\w+(?:\.\w+)*)\s*\}\}(.*?)\{\{/if\}\}", RegexOptions.Singleline)] private static partial Regex IfBlockRegex(); [GeneratedRegex(@"\{\{([^#/}][^}]*)\}\}")] private static partial Regex VariableRegex(); } /// /// Configuration options for the template renderer. /// public sealed class TemplateRendererOptions { /// /// Configuration section name. /// public const string SectionName = "TemplateRenderer"; /// /// Base URL for provenance links. If null, provenance links are not added. /// public string? ProvenanceBaseUrl { get; set; } /// /// Enable HTML sanitization for output. /// public bool EnableHtmlSanitization { get; set; } = true; /// /// Maximum rendered content length in characters. /// public int MaxContentLength { get; set; } = 100_000; }