using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Nuuru.Server.DTOs.BBCode; using Nuuru.Server.Services; using Nuuru.Server.Services.BBCode; namespace Nuuru.Server.Controllers { [ApiController] [Route("api/bbcode")] public class BBCodeController : ControllerBase { private readonly IBBCodeService _bbCodeService; private readonly IQuoteChecksumService _quoteService; private readonly IForumPostService _forumPostService; private readonly ICommentService _commentService; public BBCodeController( IBBCodeService bbCodeService, IQuoteChecksumService quoteService, IForumPostService forumPostService, ICommentService commentService) { _bbCodeService = bbCodeService; _quoteService = quoteService; _forumPostService = forumPostService; _commentService = commentService; } /// /// Parse BBCode to AST for frontend rich editor /// [HttpPost("parse")] public IActionResult Parse([FromBody] PreviewRequest request) { if (string.IsNullOrEmpty(request.Content)) { return Ok(new ParseResponse { Nodes = [] }); } var context = request.Context?.ToLowerInvariant() == "forum" ? BBCodeContext.Forum : BBCodeContext.Comment; var nodes = _bbCodeService.ParseToAst(request.Content, context); return Ok(new ParseResponse { Nodes = nodes }); } /// /// Generate verified quote BBCode for a forum post /// [HttpGet("quote/forum/{postId:int}")] public async Task GenerateForumQuote(int postId) { var post = await _forumPostService.GetPostByIdAsync(postId); if (post == null) { return NotFound(new { error = "Post not found" }); } var plainText = _bbCodeService.ExtractPlainText(post.ContentRaw, BBCodeContext.Forum); var hash = _quoteService.GenerateHash("forum", postId.ToString(), plainText); var authorEncoded = Uri.EscapeDataString(post.Author.UserName ?? "Unknown"); var bbcode = $"[quote postId={postId} author={authorEncoded} hash={hash}]{post.ContentRaw}[/quote]"; return Ok(new QuoteResponse { BBCode = bbcode, AuthorName = post.Author.UserName ?? "Unknown", SourceType = "forum", SourceId = postId.ToString() }); } /// /// Generate verified quote BBCode for a forum post while keeping at most one nested quote level /// [HttpGet("quote/forum/{postId:int}/first-level")] public async Task GenerateForumQuoteFirstLevel(int postId) { var post = await _forumPostService.GetPostByIdAsync(postId); if (post == null) { return NotFound(new { error = "Post not found" }); } var firstLevelContent = LimitAndRehashQuotes(post.ContentRaw, 1, BBCodeContext.Forum); var plainText = _bbCodeService.ExtractPlainText(firstLevelContent, BBCodeContext.Forum); var hash = _quoteService.GenerateHash("forum", postId.ToString(), plainText); var authorEncoded = Uri.EscapeDataString(post.Author.UserName ?? "Unknown"); var bbcode = $"[quote postId={postId} author={authorEncoded} hash={hash}]{firstLevelContent}[/quote]"; return Ok(new QuoteResponse { BBCode = bbcode, AuthorName = post.Author.UserName ?? "Unknown", SourceType = "forum", SourceId = postId.ToString() }); } /// /// Generate verified quote BBCode for a booru comment /// [HttpGet("quote/comment/{commentId:int}")] public async Task GenerateCommentQuote(int commentId) { var comment = await _commentService.GetCommentByIdAsync(commentId); if (comment == null) { return NotFound(new { error = "Comment not found" }); } var plainText = _bbCodeService.ExtractPlainText(comment.ContentRaw); var hash = _quoteService.GenerateHash("comment", commentId.ToString(), plainText); var authorEncoded = Uri.EscapeDataString(comment.User.UserName ?? "Unknown"); var bbcode = $"[quote commentId={commentId} author={authorEncoded} hash={hash}]{comment.ContentRaw}[/quote]"; return Ok(new QuoteResponse { BBCode = bbcode, AuthorName = comment.User.UserName ?? "Unknown", SourceType = "comment", SourceId = commentId.ToString() }); } /// /// Generate verified quote BBCode for a booru comment while keeping at most one nested quote level /// [HttpGet("quote/comment/{commentId:int}/first-level")] public async Task GenerateCommentQuoteFirstLevel(int commentId) { var comment = await _commentService.GetCommentByIdAsync(commentId); if (comment == null) { return NotFound(new { error = "Comment not found" }); } var firstLevelContent = LimitAndRehashQuotes(comment.ContentRaw, 1, BBCodeContext.Comment); var plainText = _bbCodeService.ExtractPlainText(firstLevelContent); var hash = _quoteService.GenerateHash("comment", commentId.ToString(), plainText); var authorEncoded = Uri.EscapeDataString(comment.User.UserName ?? "Unknown"); var bbcode = $"[quote commentId={commentId} author={authorEncoded} hash={hash}]{firstLevelContent}[/quote]"; return Ok(new QuoteResponse { BBCode = bbcode, AuthorName = comment.User.UserName ?? "Unknown", SourceType = "comment", SourceId = commentId.ToString() }); } /// /// Generate verified quote BBCode for a selected portion of a forum post /// [HttpPost("quote/forum/{postId:int}/selection")] public async Task GenerateForumSelectionQuote(int postId, [FromBody] SelectionQuoteRequest request) { var post = await _forumPostService.GetPostByIdAsync(postId); if (post == null) { return NotFound(new { error = "Post not found" }); } var fullPlainText = _bbCodeService.ExtractPlainTextExcludingQuotes(post.ContentRaw, BBCodeContext.Forum); var normalizedFull = _quoteService.NormalizeContent(fullPlainText); var normalizedSelection = _quoteService.NormalizeContent(request.Text); if (!normalizedFull.Contains(normalizedSelection, StringComparison.OrdinalIgnoreCase)) { return BadRequest(new { error = "Selected text does not match post content" }); } var hash = _quoteService.GenerateHash("forum", postId.ToString(), normalizedSelection); var authorEncoded = Uri.EscapeDataString(post.Author.UserName ?? "Unknown"); var bbcode = $"[quote postId={postId} author={authorEncoded} hash={hash}]{request.Text}[/quote]"; return Ok(new QuoteResponse { BBCode = bbcode, AuthorName = post.Author.UserName ?? "Unknown", SourceType = "forum", SourceId = postId.ToString() }); } /// /// Generate verified quote BBCode for a selected portion of a booru comment /// [HttpPost("quote/comment/{commentId:int}/selection")] public async Task GenerateCommentSelectionQuote(int commentId, [FromBody] SelectionQuoteRequest request) { var comment = await _commentService.GetCommentByIdAsync(commentId); if (comment == null) { return NotFound(new { error = "Comment not found" }); } var fullPlainText = _bbCodeService.ExtractPlainTextExcludingQuotes(comment.ContentRaw); var normalizedFull = _quoteService.NormalizeContent(fullPlainText); var normalizedSelection = _quoteService.NormalizeContent(request.Text); if (!normalizedFull.Contains(normalizedSelection, StringComparison.OrdinalIgnoreCase)) { return BadRequest(new { error = "Selected text does not match comment content" }); } var hash = _quoteService.GenerateHash("comment", commentId.ToString(), normalizedSelection); var authorEncoded = Uri.EscapeDataString(comment.User.UserName ?? "Unknown"); var bbcode = $"[quote commentId={commentId} author={authorEncoded} hash={hash}]{request.Text}[/quote]"; return Ok(new QuoteResponse { BBCode = bbcode, AuthorName = comment.User.UserName ?? "Unknown", SourceType = "comment", SourceId = commentId.ToString() }); } /// /// Parse BBCode, strip quotes beyond maxDepth, rehash remaining inner quotes, /// and serialize back to BBCode. /// private string LimitAndRehashQuotes(string content, int maxDepth, BBCodeContext context) { if (string.IsNullOrEmpty(content)) return string.Empty; var tokenizer = new BbTokenizer(content); var tokens = tokenizer.Tokenize(); var parser = new BbParser(tokens, context); var ast = parser.Parse(); var limited = LimitQuoteDepth(ast, maxDepth, 0); return BbSerializer.Serialize(limited); } private List LimitQuoteDepth(List nodes, int maxDepth, int currentDepth) { var result = new List(); foreach (var node in nodes) { switch (node) { case QuoteNode q: if (currentDepth >= maxDepth) continue; // Strip this quote entirely var limitedChildren = LimitQuoteDepth(q.Children, maxDepth, currentDepth + 1); // Rehash if the children were modified (deeper quotes stripped) var newHash = q.ProvidedHash; if (!string.IsNullOrEmpty(q.ProvidedHash) && !string.IsNullOrEmpty(q.SourceType) && !string.IsNullOrEmpty(q.SourceId)) { var plainText = BbSerializer.ExtractText(limitedChildren); newHash = _quoteService.GenerateHash(q.SourceType, q.SourceId, plainText); } result.Add(new QuoteNode( q.SourceType, q.SourceId, q.AuthorName, newHash, limitedChildren)); break; case ElementNode e: result.Add(new ElementNode(e.Tag, e.Attribute, LimitQuoteDepth(e.Children, maxDepth, currentDepth))); break; case UrlNode u: result.Add(new UrlNode(u.Href, LimitQuoteDepth(u.Children, maxDepth, currentDepth))); break; default: result.Add(node); break; } } return result; } } public class PreviewRequest { [MaxLength(10000)] public string Content { get; set; } = string.Empty; /// /// Context for parsing: "comment" (default) or "forum" /// Forum context enables attachment parsing /// public string? Context { get; set; } } public class QuoteResponse { public string BBCode { get; set; } = string.Empty; public string AuthorName { get; set; } = string.Empty; public string SourceType { get; set; } = string.Empty; public string SourceId { get; set; } = string.Empty; } public class SelectionQuoteRequest { [Required] [MaxLength(5000)] public string Text { get; set; } = string.Empty; } }