using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Nuuru.Server.Auth; using Nuuru.Server.DTOs; using Nuuru.Server.DTOs.BBCode; using Nuuru.Server.DTOs.Booru; using Nuuru.Server.Extensions; using Nuuru.Server.Hubs; using Nuuru.Server.Models; using Nuuru.Server.Models.Booru; using Nuuru.Server.Services; using Nuuru.Server.Services.BBCode; using Nuuru.Server.Services.Search; namespace Nuuru.Server.Controllers { [ApiController] [Route("api/booru/posts/{postId:int}/comments")] public class CommentController : ControllerBase { private readonly ICommentService _commentService; private readonly IPostService _postService; private readonly IUserBadgeService _userBadgeService; private readonly IReactionService _reactionService; private readonly IBBCodeService _bbCodeService; private readonly IDefaultQueryFilterService _defaultQueryFilterService; private readonly IHubContext _hubContext; private readonly ILogger _logger; public CommentController( ICommentService commentService, IPostService postService, IUserBadgeService userBadgeService, IReactionService reactionService, IBBCodeService bbCodeService, IDefaultQueryFilterService defaultQueryFilterService, IHubContext hubContext, ILogger logger) { _commentService = commentService; _postService = postService; _userBadgeService = userBadgeService; _reactionService = reactionService; _bbCodeService = bbCodeService; _defaultQueryFilterService = defaultQueryFilterService; _hubContext = hubContext; _logger = logger; } /// /// Get comments for a post /// [HttpGet] [AllowAnonymous] public async Task GetComments(int postId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50) { try { if (page < 1) { return BadRequest(new { error = "Page must be greater than 0" }); } if (pageSize < 1) { return BadRequest(new { error = "Page size must be greater than 0" }); } if (!await _defaultQueryFilterService.IsPostVisibleAsync(postId)) { return NotFound(new { error = "Post not found" }); } var allComments = await _commentService.GetAllCommentsByPostIdAsync(postId); var visibleComments = await FilterVisibleCommentsAsync(allComments); var totalCount = visibleComments.Count; var comments = visibleComments .Skip((page - 1) * pageSize) .Take(pageSize) .ToList(); // Batch-fetch display info for all comment authors var authorIds = comments .Where(c => c.User != null) .Select(c => c.User!.Id) .Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(authorIds); // Batch-fetch reactions for all comments var commentIds = comments.Select(c => c.Id); var requestingUserId = User.GetUserId(); var reactionsMap = await _reactionService.GetBatchReactionsAsync( ReactionEntityType.BooruComment, commentIds, requestingUserId); var commentDtos = comments.Select(c => c.ToDto(requestingUserId, displayInfoMap, reactionsMap)).ToList(); return Ok(new PagedResult { Items = commentDtos, Page = page, PageSize = pageSize, TotalCount = totalCount }); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving comments for post {PostId}", postId); return StatusCode(500, new { error = "Failed to retrieve comments" }); } } /// /// Get a single comment /// [HttpGet("{commentId:int}")] [AllowAnonymous] public async Task GetComment(int postId, int commentId) { try { if (!await _defaultQueryFilterService.IsPostVisibleAsync(postId)) { return NotFound(new { error = "Comment not found" }); } var comment = await _commentService.GetCommentByIdAsync(commentId); if (comment == null || comment.PostId != postId || await ContainsBlockedThumbAsync(comment.ContentRaw)) { return NotFound(new { error = "Comment not found" }); } // Fetch display info for the comment author Dictionary? displayInfoMap = null; if (comment.User != null) { displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync([comment.User.Id]); } // Fetch reactions for this comment var requestingUserId = User.GetUserId(); var reactionsMap = await _reactionService.GetBatchReactionsAsync( ReactionEntityType.BooruComment, [comment.Id], requestingUserId); var commentDto = comment.ToDto(requestingUserId, displayInfoMap, reactionsMap); return Ok(commentDto); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving comment {CommentId}", commentId); return StatusCode(500, new { error = "Failed to retrieve comment" }); } } /// /// Create a new comment /// [HttpPost] [Authorize(Policy = Permissions.User.Comment)] public async Task CreateComment(int postId, [FromBody] CreateCommentRequest request) { try { var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } if (!await _defaultQueryFilterService.IsPostVisibleAsync(postId)) { return NotFound(new { error = "Post not found" }); } // Verify post exists and check if comments are locked var post = await _postService.GetPostByIdAsync(postId); if (post == null) { return NotFound(new { error = "Post not found" }); } if (post.CommentsLocked) { return BadRequest(new { error = "Comments are locked on this post" }); } var targetUserId = userId.Value; if (request.IsAnonymous) { if (!User.HasPermission(Permissions.User.CommentAnonymously)) { return Forbid(); } var chud = await _postService.GetAnonymousUserAsync(); if (chud == null) { _logger.LogError("Anonymous user 'Chud' not found"); return StatusCode(500, new { error = "Anonymous comment failed: System account not found" }); } targetUserId = chud.Id; HttpContext.Items[AuditLog.ActionKey] = "Comment.CreateComment (Anonymous)"; } var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); var comment = await _commentService.CreateCommentAsync(postId, targetUserId, request.Content, ipAddress); if (comment == null) { return StatusCode(500, new { error = "Failed to create comment" }); } _logger.LogInformation("User {UserId} created comment {CommentId} on post {PostId} (as {TargetUserId})", userId, comment.Id, postId, targetUserId); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = $"{postId}:{comment.Id}"; HttpContext.Items[AuditLog.TargetTypeKey] = "Comment"; // Fetch display info for the comment author var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync([targetUserId]); // New comment has no reactions yet var commentDto = comment.ToDto(userId, displayInfoMap, null); await _hubContext.NotifyGroupAsync(LiveUpdateGroups.PostComments(postId)); return CreatedAtAction(nameof(GetComment), new { postId, commentId = comment.Id }, commentDto); } catch (Exception ex) { _logger.LogError(ex, "Error creating comment on post {PostId}", postId); return StatusCode(500, new { error = "Failed to create comment" }); } } /// /// Update a comment (owner only) /// [HttpPut("{commentId:int}")] [Authorize(Policy = Permissions.User.EditOwnContent)] public async Task UpdateComment(int postId, int commentId, [FromBody] UpdateCommentRequest request) { try { var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } if (!await _defaultQueryFilterService.IsPostVisibleAsync(postId)) { return NotFound(new { error = "Comment not found" }); } var comment = await _commentService.GetCommentByIdAsync(commentId); if (comment == null || comment.PostId != postId) { return NotFound(new { error = "Comment not found" }); } // Only owner can edit if (comment.UserId != userId.Value) { _logger.LogWarning("User {UserId} attempted to edit comment {CommentId} without permission", userId, commentId); return Forbid(); } var updatedComment = await _commentService.UpdateCommentAsync(commentId, request.Content); if (updatedComment == null) { return StatusCode(500, new { error = "Failed to update comment" }); } _logger.LogInformation("User {UserId} updated comment {CommentId}", userId, commentId); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = $"{postId}:{commentId}"; HttpContext.Items[AuditLog.TargetTypeKey] = "Comment"; // Fetch display info for the comment author var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync([userId.Value]); // Fetch reactions for this comment var reactionsMap = await _reactionService.GetBatchReactionsAsync( ReactionEntityType.BooruComment, [commentId], userId); var commentDto = updatedComment.ToDto(userId, displayInfoMap, reactionsMap); await _hubContext.NotifyGroupAsync(LiveUpdateGroups.PostComments(postId)); return Ok(commentDto); } catch (Exception ex) { _logger.LogError(ex, "Error updating comment {CommentId}", commentId); return StatusCode(500, new { error = "Failed to update comment" }); } } /// /// Delete a comment (owner or moderator) /// [HttpDelete("{commentId:int}")] [Authorize(Policy = Permissions.User.DeleteOwnContent)] public async Task DeleteComment(int postId, int commentId) { try { var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } if (!await _defaultQueryFilterService.IsPostVisibleAsync(postId)) { return NotFound(new { error = "Comment not found" }); } var comment = await _commentService.GetCommentByIdAsync(commentId); if (comment == null || comment.PostId != postId) { return NotFound(new { error = "Comment not found" }); } // Check if user is owner or has moderator permission var isOwner = comment.UserId == userId.Value; var isModerator = User.HasPermission(Permissions.Moderation.DeleteComment); if (!isOwner && !isModerator) { _logger.LogWarning("User {UserId} attempted to delete comment {CommentId} without permission", userId, commentId); return Forbid(); } var deleted = await _commentService.DeleteCommentAsync(commentId); if (!deleted) { return StatusCode(500, new { error = "Failed to delete comment" }); } _logger.LogInformation("User {UserId} deleted comment {CommentId}", userId, commentId); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = $"{postId}:{commentId}"; HttpContext.Items[AuditLog.TargetTypeKey] = "Comment"; await _hubContext.NotifyGroupAsync(LiveUpdateGroups.PostComments(postId)); return NoContent(); } catch (Exception ex) { _logger.LogError(ex, "Error deleting comment {CommentId}", commentId); return StatusCode(500, new { error = "Failed to delete comment" }); } } private async Task> FilterVisibleCommentsAsync(IEnumerable comments) { var materialized = comments.ToList(); if (materialized.Count == 0) { return materialized; } var thumbIdsByCommentId = new Dictionary>(); var allThumbPostIds = new HashSet(); foreach (var comment in materialized) { var thumbPostIds = ExtractThumbPostIds(comment.ContentRaw); if (thumbPostIds.Count == 0) { continue; } thumbIdsByCommentId[comment.Id] = thumbPostIds; allThumbPostIds.UnionWith(thumbPostIds); } if (allThumbPostIds.Count == 0) { return materialized; } var visiblePostIds = await _defaultQueryFilterService.GetVisiblePostIdsAsync(allThumbPostIds); return materialized .Where(comment => !thumbIdsByCommentId.TryGetValue(comment.Id, out var thumbPostIds) || thumbPostIds.All(visiblePostIds.Contains)) .ToList(); } private async Task ContainsBlockedThumbAsync(string? contentRaw) { var thumbPostIds = ExtractThumbPostIds(contentRaw); if (thumbPostIds.Count == 0) { return false; } var visiblePostIds = await _defaultQueryFilterService.GetVisiblePostIdsAsync(thumbPostIds); return thumbPostIds.Any(postId => !visiblePostIds.Contains(postId)); } private List ExtractThumbPostIds(string? contentRaw) { if (string.IsNullOrWhiteSpace(contentRaw) || contentRaw.IndexOf("[thumb", StringComparison.OrdinalIgnoreCase) < 0) { return []; } var nodes = _bbCodeService.ParseToAst(contentRaw, BBCodeContext.Comment); var postIds = new HashSet(); CollectThumbPostIds(nodes, postIds); return postIds.ToList(); } private static void CollectThumbPostIds(IEnumerable nodes, ISet postIds) { foreach (var node in nodes) { switch (node) { case ThumbNodeDto thumb: postIds.Add(thumb.PostId); break; case ElementNodeDto element: CollectThumbPostIds(element.Children, postIds); break; case UrlNodeDto url: CollectThumbPostIds(url.Children, postIds); break; case QuoteNodeDto quote: CollectThumbPostIds(quote.Children, postIds); break; } } } } }