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