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
");
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;
}