using System.Text; using System.Text.Encodings.Web; namespace Nuuru.Server.Services.BBCode { public record QuoteSourceInfo(string? AuthorName, string? OriginalContent); public class BbRenderer { private readonly IQuoteChecksumService? _quoteService; private readonly Func? _lookupSource; private readonly Func? _lookupAvatar; public BbRenderer( IQuoteChecksumService? quoteService = null, Func? lookupSource = null, Func? lookupAvatar = null) { _quoteService = quoteService; _lookupSource = lookupSource; _lookupAvatar = lookupAvatar; } public string Render(List nodes) { var sb = new StringBuilder(); foreach (var node in nodes) { sb.Append(RenderNode(node)); } return sb.ToString(); } private string RenderNode(BbNode node) { return node switch { TextNode t => HtmlEncode(t.Content), NewlineNode => "
", ElementNode e => RenderElement(e), ThumbNode t => RenderThumb(t), UrlNode u => RenderUrl(u), QuoteNode q => RenderQuote(q), MentionNode m => RenderMention(m), AttachmentNode a => RenderAttachment(a), EmoteNode em => RenderEmote(em), _ => "" }; } private string RenderElement(ElementNode node) { var (openTag, closeTag) = GetHtmlTags(node.Tag, node.Attribute); if (openTag == null) { // Unknown tag - render as text with children var tagText = node.Attribute != null ? $"[{node.Tag}={node.Attribute}]" : $"[{node.Tag}]"; return HtmlEncode(tagText) + RenderChildren(node.Children) + HtmlEncode($"[/{node.Tag}]"); } // Greentext/orangetext lines need the > / < prefix before children var prefix = node is { Attribute: "line", Tag: "greentext" } ? ">" : node is { Attribute: "line", Tag: "orangetext" } ? "<" : ""; return openTag + prefix + RenderChildren(node.Children) + closeTag; } private string RenderChildren(List children) { var sb = new StringBuilder(); foreach (var child in children) { sb.Append(RenderNode(child)); } return sb.ToString(); } private (string? open, string? close) GetHtmlTags(string tag, string? attribute) { return tag.ToLowerInvariant() switch { // Basic formatting "b" => ("", ""), "i" => ("", ""), "s" => ("", ""), "u" => ("", ""), // Code "code" => ("
", "
"), "inline" => ("", ""), // Headings "h1" => ("

", "

"), "h2" => ("

", "

"), "h3" => ("

", "

"), "h4" => ("

", "

"), // Sub/Superscript "sub" => ("", ""), "sup" => ("", ""), // Special effects "spoiler" => ("", ""), "blur" => ("", ""), // Font properties "color" => IsValidColor(attribute) ? ($"", "") : ("", ""), "size" => IsValidSize(attribute, out var size) ? ($"", "") : ("", ""), "font" => attribute != null && ValidFonts.Contains(attribute.Trim()) ? ($"", "") : ("", ""), "align" => (attribute is "left" or "right" or "center" or "justify" ? ($"
", "
") : ("
", "
")), "list" => (attribute == "1" ? ("
    ", "
") : ("
    ", "
")), "*" => ("
  • ", "
  • "), // Colored text (tag versions) "greentext" => ("", ""), "redtext" => ("", ""), "orangetext" => ("", ""), "bluetext" => ("", ""), "glowtext" => ("", ""), "redglow" => ("", ""), "yellowglow" => ("", ""), "rainbow" => ("", ""), // URL with invalid/missing href renders as span "url" => ("", ""), _ => (null, null) }; } private string RenderThumb(ThumbNode node) { return $"" + $"\"Post" + ""; } private string RenderUrl(UrlNode node) { var href = HtmlEncode(node.Href); var content = RenderChildren(node.Children); return $"{content}"; } private string RenderMention(MentionNode node) { var userName = HtmlEncode(node.UserName); var href = (node.PostId, node.CommentId) switch { (int pid, int cid) => $"/post/view/{pid}#c{cid}", (int pid, null) => $"/post/view/{pid}", _ => $"/user/{userName}", }; return $"@{userName}"; } private string RenderQuote(QuoteNode node) { var content = RenderChildren(node.Children); var hasValidSourceUrl = TryBuildQuoteSourceUrl(node, out var sourceUrl); // Simple quote without source reference (e.g. [quote=author] or [quote]) if (string.IsNullOrEmpty(node.SourceType) || string.IsNullOrEmpty(node.SourceId) || !hasValidSourceUrl) { var simpleHeader = !string.IsNullOrEmpty(node.AuthorName) ? $"
    {HtmlEncode(node.AuthorName)}
    " : ""; return $"
    {simpleHeader}
    {content}
    "; } // Quote with source reference - attempt verification bool isVerified = false; string? authorName = node.AuthorName; // Use the author from the attribute first if (_quoteService != null && !string.IsNullOrEmpty(node.ProvidedHash)) { // HMAC verification - the hash proves authenticity at quote-creation time var quotedText = ExtractTextContent(node.Children); isVerified = _quoteService.VerifyHash( node.SourceType, node.SourceId, quotedText, node.ProvidedHash); } // Optionally look up author name if not provided in attribute if (_lookupSource != null && string.IsNullOrEmpty(authorName)) { var sourceInfo = _lookupSource(node.SourceType, node.SourceId); authorName = sourceInfo.AuthorName; } var verificationClass = isVerified ? "bbcode-quote-verified" : "bbcode-quote-unverified"; var badge = isVerified ? "" : "unverified"; // Build header with avatar, author name, and go-to-post button var headerContent = ""; if (!string.IsNullOrEmpty(authorName)) { var avatarImg = RenderAvatarImg(authorName); headerContent = $"{avatarImg}{HtmlEncode(authorName)}"; } // Add the go-to-post button var goToButton = $""; var header = $"
    {headerContent}{goToButton}{badge}
    "; return $"
    {header}" + $"
    {content}
    "; } private string RenderAvatarImg(string authorName) { var avatarUrl = _lookupAvatar?.Invoke(authorName); if (avatarUrl == null) return ""; return $"\"\""; } private static bool TryBuildQuoteSourceUrl(QuoteNode node, out string sourceUrl) { sourceUrl = ""; if (node.SourceType == "forum" && int.TryParse(node.SourceId, out var postId) && postId > 0) { sourceUrl = $"#p{postId}"; return true; } if (node.SourceType == "comment" && int.TryParse(node.SourceId, out var commentId) && commentId > 0) { sourceUrl = $"#c{commentId}"; return true; } return false; } private static string ExtractTextContent(List nodes) { var sb = new StringBuilder(); foreach (var node in nodes) { switch (node) { case TextNode t: sb.Append(t.Content); break; case NewlineNode: sb.Append('\n'); break; case ElementNode e: sb.Append(ExtractTextContent(e.Children)); break; case QuoteNode q: sb.Append(ExtractTextContent(q.Children)); break; case UrlNode u: sb.Append(ExtractTextContent(u.Children)); break; case MentionNode m: sb.Append('@'); sb.Append(m.UserName); break; case EmoteNode em: sb.Append(':'); sb.Append(em.Name); sb.Append(':'); break; case AttachmentNode: // Attachments don't contribute to plain text break; } } return sb.ToString(); } private static string RenderAttachment(AttachmentNode node) { var src = node.IsThumbnail ? $"/api/forum/attachments/{node.AttachmentId}/thumb" : $"/api/forum/attachments/{node.AttachmentId}/file"; var styleParts = new List(); if (node.Width.HasValue) styleParts.Add($"width:{node.Width}px"); if (node.Height.HasValue) styleParts.Add($"height:{node.Height}px"); if (!node.Width.HasValue && !node.Height.HasValue && !node.IsThumbnail) { styleParts.Add("max-width:100%"); styleParts.Add("height:auto"); } var style = styleParts.Count > 0 ? $" style=\"{string.Join(";", styleParts)}\"" : ""; return $"" + $"" + ""; } private static string RenderEmote(EmoteNode node) { var name = HtmlEncode(node.Name); return $":{name}:"; } private static readonly HashSet ValidFonts = new(StringComparer.OrdinalIgnoreCase) { // Serif "serif", "Georgia", "Palatino Linotype", "Book Antiqua", "Palatino", "Times New Roman", "Times", "Garamond", "Bookman", "Constantia", "Lucida Bright", "Baskerville", "Big Caslon", "Bodoni MT", "Cambria", "Cochin", "Didot", "Footlight MT Light", "High Tower Text", "Hoefler Text", "Libre Baskerville", "Lora", "Merriweather", "Playfair Display", "PT Serif", "Rockwell", "Sitka", // Generic / System "monospace", "cursive", "fantasy", "system-ui", "TransLibri", // Sans-Serif "sans-serif", "Arial", "Helvetica", "Arial Black", "Gadget", "Trebuchet MS", "Verdana", "Geneva", "Tahoma", "MS Sans Serif", "Segoe UI", "Calibri", "Candara", "Impact", "Charcoal", "Gill Sans", "Optima", "Lucida Sans Unicode", "Lucida Grande", "Open Sans", "Roboto", "Lato", "Montserrat", "Oswald", "Source Sans Pro", "Raleway", "PT Sans", "Nunito", "Muli", "Ubuntu", "Fira Sans", "Droid Sans", "Noto Sans", "Quicksand", "Karla", "Work Sans", "Avenir", "Century Gothic", "Dejavu Sans", "Franklin Gothic Medium", "Futura", "Inter", "Josefin Sans", "Maven Pro", "Poppins", // Monospace "monospace", "Courier New", "Courier", "Lucida Console", "Monaco", "Consolas", "Andale Mono", "DejaVu Sans Mono", "Liberation Mono", "Fira Code", "Source Code Pro", "Inconsolata", "Ubuntu Mono", "Courier Prime", "Menlo", "Terminal", // Cursive & Decorative "cursive", "Comic Sans MS", "Comic Sans", "Brush Script MT", "Apple Chancery", "fantasy", "Copperplate", "Papyrus", "Lobster", "Pacifico", "Dancing Script", "Caveat", "Satisfy", "Courgette", "Amatic SC", "Bangers", "Fredoka One", "Great Vibes", "Kaushan Script", "Permanent Marker", "Sacramento", "Yellowtail" }; private static bool IsValidColor(string? color) { if (string.IsNullOrEmpty(color) || color.Length > 20) return false; if (color[0] == '#') { return (color.Length is 4 or 7) && color[1..].All(char.IsAsciiHexDigit); } return color.All(char.IsLetter); } private static bool IsValidSize(string? size, out string normalized) { normalized = ""; if (string.IsNullOrEmpty(size) || size.Length > 10) return false; int i = 0; while (i < size.Length && char.IsDigit(size[i])) i++; if (i == 0) return false; if (!int.TryParse(size.AsSpan(0, i), out int val)) return false; string unit = size[i..].Trim().ToLowerInvariant(); if (string.IsNullOrEmpty(unit)) unit = "%"; bool valid = unit switch { "%" => val is >= 50 and <= 300, "px" => val is >= 8 and <= 100, "pt" => val is >= 8 and <= 72, "em" => val is >= 1 and <= 5, _ => false }; if (valid) { normalized = $"{val}{unit}"; return true; } return false; } private static string HtmlEncode(string text) => HtmlEncoder.Default.Encode(text); } }