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
491 lines
16 KiB
C#
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;
|
|
}
|