Files
git.stella-ops.org/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Templates/EnhancedTemplateRenderer.cs
master e950474a77
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
up
2025-11-27 15:16:31 +02:00

491 lines
16 KiB
C#

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;
/// <summary>
/// Enhanced template renderer with multi-format output, configurable redaction, and provenance links.
/// </summary>
public sealed partial class EnhancedTemplateRenderer : INotifyTemplateRenderer
{
private readonly INotifyTemplateService _templateService;
private readonly TemplateRendererOptions _options;
private readonly ILogger<EnhancedTemplateRenderer> _logger;
public EnhancedTemplateRenderer(
INotifyTemplateService templateService,
IOptions<TemplateRendererOptions> options,
ILogger<EnhancedTemplateRenderer> 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<NotifyRenderedContent> 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<string, object?> BuildContext(
NotifyEvent notifyEvent,
TemplateRedactionConfig redactionConfig)
{
var context = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["eventId"] = notifyEvent.EventId.ToString(),
["kind"] = notifyEvent.Kind,
["tenant"] = notifyEvent.Tenant,
["timestamp"] = notifyEvent.Timestamp.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<string, object?> 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<object>()?.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<string, object?> 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<string, object?> 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<string, object?>(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<string, object?> 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()),
"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, @"^### (.+)$", "<h3>$1</h3>", RegexOptions.Multiline);
html = Regex.Replace(html, @"^## (.+)$", "<h2>$1</h2>", RegexOptions.Multiline);
html = Regex.Replace(html, @"^# (.+)$", "<h1>$1</h1>", RegexOptions.Multiline);
// Bold
html = Regex.Replace(html, @"\*\*(.+?)\*\*", "<strong>$1</strong>");
html = Regex.Replace(html, @"__(.+?)__", "<strong>$1</strong>");
// Italic
html = Regex.Replace(html, @"\*(.+?)\*", "<em>$1</em>");
html = Regex.Replace(html, @"_(.+?)_", "<em>$1</em>");
// Code
html = Regex.Replace(html, @"`(.+?)`", "<code>$1</code>");
// Links
html = Regex.Replace(html, @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>");
// Line breaks
html = html.Replace("\n\n", "</p><p>");
html = html.Replace("\n", "<br/>");
// Wrap in paragraph if not already structured
if (!html.StartsWith("<"))
{
html = $"<p>{html}</p>";
}
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, @"<br\s*/?>", "\n");
text = Regex.Replace(text, @"</p>", "\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();
}
/// <summary>
/// Configuration options for the template renderer.
/// </summary>
public sealed class TemplateRendererOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "TemplateRenderer";
/// <summary>
/// Base URL for provenance links. If null, provenance links are not added.
/// </summary>
public string? ProvenanceBaseUrl { get; set; }
/// <summary>
/// Enable HTML sanitization for output.
/// </summary>
public bool EnableHtmlSanitization { get; set; } = true;
/// <summary>
/// Maximum rendered content length in characters.
/// </summary>
public int MaxContentLength { get; set; } = 100_000;
}