using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Nuuru.Server.Auth; using Nuuru.Server.DTOs.Booru; using Nuuru.Server.Extensions; using Nuuru.Server.Services; using Nuuru.Server.Services.Search; namespace Nuuru.Server.Controllers { [ApiController] [Route("api/booru/posts/{postId:int}/history")] public class PostHistoryController : ControllerBase { private readonly IPostHistoryService _postHistoryService; private readonly IPostService _postService; private readonly ITagService _tagService; private readonly IUserBadgeService _userBadgeService; private readonly IDefaultQueryFilterService _defaultQueryFilterService; private readonly ILogger _logger; public PostHistoryController( IPostHistoryService postHistoryService, IPostService postService, ITagService tagService, IUserBadgeService userBadgeService, IDefaultQueryFilterService defaultQueryFilterService, ILogger logger) { _postHistoryService = postHistoryService; _postService = postService; _tagService = tagService; _userBadgeService = userBadgeService; _defaultQueryFilterService = defaultQueryFilterService; _logger = logger; } /// /// Get tag history for a post /// [HttpGet("tags")] [AllowAnonymous] public async Task GetTagHistory(int postId) { try { if (!await _defaultQueryFilterService.IsPostVisibleAsync(postId)) { return NotFound(new { error = "Post not found" }); } // Moderators can see suppressed entries var includeSuppressed = User.HasPermission(Permissions.Moderation.SuppressHistory); var history = await _postHistoryService.GetTagHistoryAsync(postId, includeSuppressed); // Batch-fetch display info for all users var userIds = history .SelectMany(h => new[] { h.UserId, h.SuppressedById }) .Where(id => id.HasValue) .Select(id => id!.Value) .Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(userIds); var dtos = history.Select(h => h.ToDto(displayInfoMap, includeSuppressed)).ToList(); return Ok(dtos); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving tag history for post {PostId}", postId); return StatusCode(500, new { error = "Failed to retrieve tag history" }); } } /// /// Get source history for a post /// [HttpGet("source")] [AllowAnonymous] public async Task GetSourceHistory(int postId) { try { if (!await _defaultQueryFilterService.IsPostVisibleAsync(postId)) { return NotFound(new { error = "Post not found" }); } // Moderators can see suppressed entries var includeSuppressed = User.HasPermission(Permissions.Moderation.SuppressHistory); var history = await _postHistoryService.GetSourceHistoryAsync(postId, includeSuppressed); // Batch-fetch display info for all users var userIds = history .SelectMany(h => new[] { h.UserId, h.SuppressedById }) .Where(id => id.HasValue) .Select(id => id!.Value) .Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(userIds); var dtos = history.Select(h => h.ToDto(displayInfoMap, includeSuppressed)).ToList(); return Ok(dtos); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving source history for post {PostId}", postId); return StatusCode(500, new { error = "Failed to retrieve source history" }); } } /// /// Suppress a tag history entry /// [HttpPost("tags/{historyId:int}/suppress")] [Authorize(Policy = Permissions.Moderation.SuppressHistory)] public async Task SuppressTagHistory(int postId, int historyId, [FromBody] SuppressHistoryRequest? 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" }); } var success = await _postHistoryService.SuppressTagHistoryAsync(historyId, userId.Value, request?.Reason); if (!success) { return NotFound(new { error = "History entry not found" }); } _logger.LogInformation("User {UserId} suppressed tag history {HistoryId} for post {PostId}", userId, historyId, postId); return NoContent(); } catch (Exception ex) { _logger.LogError(ex, "Error suppressing tag history {HistoryId}", historyId); return StatusCode(500, new { error = "Failed to suppress history entry" }); } } /// /// Unsuppress a tag history entry /// [HttpDelete("tags/{historyId:int}/suppress")] [Authorize(Policy = Permissions.Moderation.SuppressHistory)] public async Task UnsuppressTagHistory(int postId, int historyId) { try { if (!await _defaultQueryFilterService.IsPostVisibleAsync(postId)) { return NotFound(new { error = "Post not found" }); } var success = await _postHistoryService.UnsuppressTagHistoryAsync(historyId); if (!success) { return NotFound(new { error = "History entry not found" }); } var userId = User.GetUserId(); _logger.LogInformation("User {UserId} unsuppressed tag history {HistoryId} for post {PostId}", userId, historyId, postId); return NoContent(); } catch (Exception ex) { _logger.LogError(ex, "Error unsuppressing tag history {HistoryId}", historyId); return StatusCode(500, new { error = "Failed to unsuppress history entry" }); } } /// /// Suppress a source history entry /// [HttpPost("source/{historyId:int}/suppress")] [Authorize(Policy = Permissions.Moderation.SuppressHistory)] public async Task SuppressSourceHistory(int postId, int historyId, [FromBody] SuppressHistoryRequest? 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" }); } var success = await _postHistoryService.SuppressSourceHistoryAsync(historyId, userId.Value, request?.Reason); if (!success) { return NotFound(new { error = "History entry not found" }); } _logger.LogInformation("User {UserId} suppressed source history {HistoryId} for post {PostId}", userId, historyId, postId); return NoContent(); } catch (Exception ex) { _logger.LogError(ex, "Error suppressing source history {HistoryId}", historyId); return StatusCode(500, new { error = "Failed to suppress history entry" }); } } /// /// Unsuppress a source history entry /// [HttpDelete("source/{historyId:int}/suppress")] [Authorize(Policy = Permissions.Moderation.SuppressHistory)] public async Task UnsuppressSourceHistory(int postId, int historyId) { try { if (!await _defaultQueryFilterService.IsPostVisibleAsync(postId)) { return NotFound(new { error = "Post not found" }); } var success = await _postHistoryService.UnsuppressSourceHistoryAsync(historyId); if (!success) { return NotFound(new { error = "History entry not found" }); } var userId = User.GetUserId(); _logger.LogInformation("User {UserId} unsuppressed source history {HistoryId} for post {PostId}", userId, historyId, postId); return NoContent(); } catch (Exception ex) { _logger.LogError(ex, "Error unsuppressing source history {HistoryId}", historyId); return StatusCode(500, new { error = "Failed to unsuppress history entry" }); } } /// /// Revert to a historical tag state /// [HttpPost("tags/{historyId:int}/revert")] [Authorize(Policy = Permissions.User.EditTags)] public async Task RevertToTagHistory(int postId, int historyId) { 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" }); } // Get the history entry to verify it belongs to this post var history = await _postHistoryService.GetTagHistoryByIdAsync(historyId); if (history == null || history.PostId != postId) { return NotFound(new { error = "History entry not found" }); } // Prevent reverting to suppressed entries unless user has moderator permission if (history.SuppressedAt.HasValue && !User.HasPermission(Permissions.Moderation.SuppressHistory)) { return Forbid(); } // Get the post and update its tags using TagService var post = await _postService.GetPostByIdAsync(postId); if (post == null) { return NotFound(new { error = "Post not found" }); } var tagNames = history.Tags.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); // Capture current tag names for change detection var oldTagNames = post.PostTags .Select(pt => pt.Tag.Category != null ? $"{pt.Tag.Category.Slug}:{pt.Tag.Name}" : pt.Tag.Name) .OrderBy(t => t) .ToList(); // Update tags through TagService await _tagService.UpdatePostTagsAsync(post, tagNames); // Reload to get resolved tags post = await _postService.GetPostByIdAsync(postId); var newTagNames = post!.PostTags .Select(pt => pt.Tag.Category != null ? $"{pt.Tag.Category.Slug}:{pt.Tag.Name}" : pt.Tag.Name) .OrderBy(t => t) .ToList(); // Only record history if tags actually changed if (!oldTagNames.SequenceEqual(newTagNames)) { var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); await _postHistoryService.RecordTagChangeAsync(postId, userId.Value, userIp, newTagNames); } _logger.LogInformation("User {UserId} reverted tags for post {PostId} to history entry {HistoryId}", userId, postId, historyId); return NoContent(); } catch (Exception ex) { _logger.LogError(ex, "Error reverting to tag history {HistoryId}", historyId); return StatusCode(500, new { error = "Failed to revert tags" }); } } /// /// Revert to a historical source state /// [HttpPost("source/{historyId:int}/revert")] [Authorize(Policy = Permissions.User.EditTags)] public async Task RevertToSourceHistory(int postId, int historyId) { 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 the history entry belongs to this post var history = await _postHistoryService.GetSourceHistoryByIdAsync(historyId); if (history == null || history.PostId != postId) { return NotFound(new { error = "History entry not found" }); } // Prevent reverting to suppressed entries unless user has moderator permission if (history.SuppressedAt.HasValue && !User.HasPermission(Permissions.Moderation.SuppressHistory)) { return Forbid(); } var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); var success = await _postHistoryService.RevertSourceDirectAsync(historyId, userId.Value, userIp); if (!success) { return NotFound(new { error = "History entry not found" }); } _logger.LogInformation("User {UserId} reverted source for post {PostId} to history entry {HistoryId}", userId, postId, historyId); return NoContent(); } catch (Exception ex) { _logger.LogError(ex, "Error reverting to source history {HistoryId}", historyId); return StatusCode(500, new { error = "Failed to revert source" }); } } } }