Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user