using System.Text; using System.Text.RegularExpressions; using Nuuru.Server.DTOs.BBCode; namespace Nuuru.Server.Services.BBCode { public class BBCodeService : IBBCodeService { private const int MaxInputLength = 10000; private static readonly Regex AutoUrlRegex = new( @"https?://[^\s<>\[\]""]+", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly IQuoteChecksumService? _quoteService; public BBCodeService(IQuoteChecksumService? quoteService = null) { _quoteService = quoteService; } public string Parse(string bbcode) { return Parse(bbcode, null, BBCodeContext.Comment); } public string Parse(string bbcode, BBCodeContext context) { return Parse(bbcode, null, context); } public ParseResult ParseWithMentions(string bbcode) { return ParseWithMentions(bbcode, BBCodeContext.Comment); } public ParseResult ParseWithMentions(string bbcode, BBCodeContext context) { return ParseWithMentions(bbcode, context, null); } public ParseResult ParseWithMentions(string bbcode, Func? lookupAvatar) { return ParseWithMentions(bbcode, BBCodeContext.Comment, lookupAvatar); } public ParseResult ParseWithMentions(string bbcode, BBCodeContext context, Func? lookupAvatar) { if (string.IsNullOrEmpty(bbcode)) return new ParseResult(string.Empty, [], []); if (bbcode.Length > MaxInputLength) bbcode = bbcode[..MaxInputLength]; var ast = ParseNodes(bbcode, context); var renderer = new BbRenderer(_quoteService, null, lookupAvatar); var html = renderer.Render(ast); var mentions = ExtractMentionsFromNodes(ast); var quotedForumPostIds = ExtractQuotedForumPostIdsFromNodes(ast); return new ParseResult(html, mentions, quotedForumPostIds); } public string Parse(string bbcode, Func? lookupSource) { return Parse(bbcode, lookupSource, BBCodeContext.Comment); } public string Parse(string bbcode, Func? lookupSource, BBCodeContext context) { if (string.IsNullOrEmpty(bbcode)) return string.Empty; if (bbcode.Length > MaxInputLength) bbcode = bbcode[..MaxInputLength]; var ast = ParseNodes(bbcode, context); var renderer = new BbRenderer(_quoteService, lookupSource); return renderer.Render(ast); } private static List ExtractMentionsFromNodes(List nodes) { var userIds = new List(); foreach (var node in nodes) { switch (node) { case MentionNode m: userIds.Add(m.UserId); break; case ElementNode e: userIds.AddRange(ExtractMentionsFromNodes(e.Children)); break; case QuoteNode: // Don't extract mentions from quoted content — // re-quoting someone else's mention shouldn't ping them. break; case UrlNode u: userIds.AddRange(ExtractMentionsFromNodes(u.Children)); break; } } return userIds.Distinct().ToList(); } private static List ExtractQuotedForumPostIdsFromNodes(List nodes, int quoteDepth = 0) { var postIds = new List(); foreach (var node in nodes) { switch (node) { case QuoteNode q: if (quoteDepth == 0 && q.SourceType == "forum" && int.TryParse(q.SourceId, out var forumPostId)) postIds.Add(forumPostId); postIds.AddRange(ExtractQuotedForumPostIdsFromNodes(q.Children, quoteDepth + 1)); break; case ElementNode e: postIds.AddRange(ExtractQuotedForumPostIdsFromNodes(e.Children, quoteDepth)); break; case UrlNode u: postIds.AddRange(ExtractQuotedForumPostIdsFromNodes(u.Children, quoteDepth)); break; } } return postIds.Distinct().ToList(); } public List ParseToAst(string bbcode) { return ParseToAst(bbcode, BBCodeContext.Comment); } public List ParseToAst(string bbcode, BBCodeContext context) { if (string.IsNullOrEmpty(bbcode)) return []; if (bbcode.Length > MaxInputLength) bbcode = bbcode[..MaxInputLength]; var ast = ParseNodes(bbcode, context); return ast.Select(ConvertToDto).ToList(); } private static BbNodeDto ConvertToDto(BbNode node) => node switch { TextNode t => new TextNodeDto { Content = t.Content }, NewlineNode => new NewlineNodeDto(), ElementNode e => new ElementNodeDto { Tag = e.Tag, Attribute = e.Attribute, Children = e.Children.Select(ConvertToDto).ToList() }, ThumbNode t => new ThumbNodeDto { PostId = t.PostId }, UrlNode u => new UrlNodeDto { Href = u.Href, Children = u.Children.Select(ConvertToDto).ToList() }, QuoteNode q => new QuoteNodeDto { SourceType = q.SourceType, SourceId = q.SourceId, Author = q.AuthorName, Hash = q.ProvidedHash, Children = q.Children.Select(ConvertToDto).ToList() }, MentionNode m => new MentionNodeDto { UserId = m.UserId.ToString(), UserName = m.UserName }, AttachmentNode a => new AttachmentNodeDto { AttachmentId = a.AttachmentId.ToString(), IsThumbnail = a.IsThumbnail, Width = a.Width, Height = a.Height }, EmoteNode em => new EmoteNodeDto { Name = em.Name }, _ => new TextNodeDto { Content = "" } }; public bool Validate(string bbcode, out List errors) { errors = []; if (string.IsNullOrEmpty(bbcode)) return true; if (bbcode.Length > MaxInputLength) { errors.Add($"Content exceeds maximum length of {MaxInputLength} characters"); return false; } // Tokenize and check for balanced tags var tokenizer = new BbTokenizer(bbcode); var tokens = tokenizer.Tokenize(); var tagStack = new Stack(); foreach (var token in tokens) { if (token is OpenTagToken open) { tagStack.Push(open.Name); } else if (token is CloseTagToken close) { if (tagStack.Count == 0 || !tagStack.Peek().Equals(close.Name, StringComparison.OrdinalIgnoreCase)) { errors.Add($"Unexpected closing tag [/{close.Name}]"); } else { tagStack.Pop(); } } } foreach (var unclosed in tagStack) { errors.Add($"Unclosed tag [{unclosed}]"); } return errors.Count == 0; } public string ExtractPlainText(string bbcode) { return ExtractPlainText(bbcode, BBCodeContext.Comment); } public string ExtractPlainText(string bbcode, BBCodeContext context) { if (string.IsNullOrEmpty(bbcode)) return string.Empty; if (bbcode.Length > MaxInputLength) bbcode = bbcode[..MaxInputLength]; var ast = ParseNodes(bbcode, context); return ExtractTextFromNodes(ast); } public string ExtractPlainTextExcludingQuotes(string bbcode, BBCodeContext context = BBCodeContext.Comment) { if (string.IsNullOrEmpty(bbcode)) return string.Empty; if (bbcode.Length > MaxInputLength) bbcode = bbcode[..MaxInputLength]; var ast = ParseNodes(bbcode, context); return ExtractTextFromNodes(ast, excludeQuotes: true); } private static List ParseNodes(string bbcode, BBCodeContext context) { var tokenizer = new BbTokenizer(bbcode); var tokens = tokenizer.Tokenize(); var parser = new BbParser(tokens, context); var ast = parser.Parse(); return context == BBCodeContext.Forum ? AutoLinkForumUrls(ast) : ast; } private static List AutoLinkForumUrls(List nodes) { var rewritten = new List(); foreach (var node in nodes) { switch (node) { case TextNode text: rewritten.AddRange(SplitTextNodeByUrls(text.Content)); break; case ElementNode element when ShouldSkipAutoLinking(element.Tag): rewritten.Add(element); break; case ElementNode element: rewritten.Add(new ElementNode( element.Tag, element.Attribute, AutoLinkForumUrls(element.Children))); break; case QuoteNode quote: rewritten.Add(new QuoteNode( quote.SourceType, quote.SourceId, quote.AuthorName, quote.ProvidedHash, AutoLinkForumUrls(quote.Children))); break; default: rewritten.Add(node); break; } } return rewritten; } private static bool ShouldSkipAutoLinking(string tag) { return tag.Equals("code", StringComparison.OrdinalIgnoreCase) || tag.Equals("inline", StringComparison.OrdinalIgnoreCase) || tag.Equals("url", StringComparison.OrdinalIgnoreCase); } private static List SplitTextNodeByUrls(string text) { if (string.IsNullOrEmpty(text)) return []; var nodes = new List(); var cursor = 0; foreach (Match match in AutoUrlRegex.Matches(text)) { if (!IsUrlBoundary(text, match.Index)) continue; var candidate = match.Value; var (trimmedUrl, trailingText) = TrimTrailingUrlPunctuation(candidate); if (!IsValidUrl(trimmedUrl)) continue; if (match.Index > cursor) nodes.Add(new TextNode(text[cursor..match.Index])); nodes.Add(new UrlNode(trimmedUrl, [new TextNode(trimmedUrl)])); if (!string.IsNullOrEmpty(trailingText)) nodes.Add(new TextNode(trailingText)); cursor = match.Index + candidate.Length; } if (cursor < text.Length) nodes.Add(new TextNode(text[cursor..])); return nodes.Count > 0 ? nodes : [new TextNode(text)]; } private static bool IsUrlBoundary(string text, int matchIndex) { if (matchIndex == 0) return true; var previous = text[matchIndex - 1]; return !char.IsLetterOrDigit(previous); } private static (string url, string trailingText) TrimTrailingUrlPunctuation(string candidate) { var end = candidate.Length; while (end > 0) { var trailing = candidate[end - 1]; if (trailing is '.' or ',' or '!' or '?' or ':' or ';') { end--; continue; } if (trailing == ')' && HasMoreClosersThanOpeners(candidate.AsSpan(0, end), '(', ')')) { end--; continue; } if (trailing == ']' && HasMoreClosersThanOpeners(candidate.AsSpan(0, end), '[', ']')) { end--; continue; } if (trailing == '}' && HasMoreClosersThanOpeners(candidate.AsSpan(0, end), '{', '}')) { end--; continue; } break; } return (candidate[..end], candidate[end..]); } private static bool HasMoreClosersThanOpeners(ReadOnlySpan text, char opener, char closer) { var openCount = 0; var closeCount = 0; foreach (var character in text) { if (character == opener) openCount++; if (character == closer) closeCount++; } return closeCount > openCount; } private static bool IsValidUrl(string url) { if (string.IsNullOrWhiteSpace(url) || url.Length > 2048) return false; if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps; } private static string ExtractTextFromNodes(List nodes, bool excludeQuotes = false) { 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(ExtractTextFromNodes(e.Children, excludeQuotes)); break; case QuoteNode q: if (!excludeQuotes) sb.Append(ExtractTextFromNodes(q.Children, excludeQuotes)); break; case UrlNode u: sb.Append(ExtractTextFromNodes(u.Children, excludeQuotes)); 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(); } } }