using System.Net; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Nuuru.Server.Auth; using Nuuru.Server.DTOs; using Nuuru.Server.DTOs.Admin; using Nuuru.Server.DTOs.Booru; using Nuuru.Server.DTOs.Moderation; using Nuuru.Server.Extensions; using Nuuru.Server.Models; using Nuuru.Server.Models.Requests; using Nuuru.Server.Services; namespace Nuuru.Server.Controllers { [ApiController] [Route("api/[controller]")] [Authorize] public class ModerationController : ControllerBase { private readonly IAdminService _adminService; private readonly IModerationService _moderationService; private readonly IPermissionService _permissionService; private readonly IBanService _banService; private readonly IIpBanService _ipBanService; private readonly IReportService _reportService; private readonly IBanAppealService _banAppealService; private readonly IIpBanAppealService _ipBanAppealService; private readonly IPostService _postService; private readonly ICommentService _commentService; private readonly IForumPostService _forumPostService; private readonly ISignedUrlService _signedUrlService; private readonly IPostCacheInvalidationService _postCacheInvalidationService; private readonly IIpCloakService _ipCloakService; private readonly UserManager _userManager; private readonly ILogger _logger; public ModerationController( IAdminService adminService, IModerationService moderationService, IPermissionService permissionService, IBanService banService, IIpBanService ipBanService, IReportService reportService, IBanAppealService banAppealService, IIpBanAppealService ipBanAppealService, IPostService postService, ICommentService commentService, IForumPostService forumPostService, ISignedUrlService signedUrlService, IPostCacheInvalidationService postCacheInvalidationService, IIpCloakService ipCloakService, UserManager userManager, ILogger logger) { _adminService = adminService; _moderationService = moderationService; _permissionService = permissionService; _banService = banService; _ipBanService = ipBanService; _reportService = reportService; _banAppealService = banAppealService; _ipBanAppealService = ipBanAppealService; _postService = postService; _commentService = commentService; _forumPostService = forumPostService; _signedUrlService = signedUrlService; _postCacheInvalidationService = postCacheInvalidationService; _ipCloakService = ipCloakService; _userManager = userManager; _logger = logger; } #region User Lookup (for banning) [Authorize(Policy = Permissions.Moderation.BanUser)] [HttpGet("users")] public async Task SearchUsers([FromQuery] UserSearchRequest request) { var result = await _adminService.SearchUsersAsync( request.Search, request.Role, request.Page, request.PageSize); return Ok(result); } [Authorize(Policy = Permissions.Moderation.BanUser)] [HttpGet("users/{userId:guid}")] public async Task GetUser(Guid userId) { var user = await _adminService.GetUserByIdAsync(userId); if (user == null) return NotFound(new { error = "User not found" }); return Ok(user); } #endregion /// /// Resolves an IP input that may be either a raw IP address or a cloaked value. /// private async Task ResolveIpAsync(string input) { if (IPAddress.TryParse(input, out _)) return input; return await _ipCloakService.UncloakAsync(input); } [Authorize(Policy = Permissions.Moderation.TrashPost)] [HttpDelete("posts/{postId}")] public async Task DeletePost(int postId, [FromBody] DeleteContentRequest? request = null) { if (string.IsNullOrWhiteSpace(request?.Reason)) { return BadRequest(new { error = "Reason is required" }); } var result = await _moderationService.DeletePostAsync(postId, User, request.Reason); if (!result) { return NotFound(new { error = "Post not found" }); } _logger.LogInformation("Post {PostId} deleted by moderator {ModeratorId}", postId, User.GetUserId()); await _postCacheInvalidationService.PurgePostMutationAsync(postId); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = postId.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; return Ok(new { message = "Post moved to trash" }); } [Authorize(Policy = Permissions.Moderation.DeleteComment)] [HttpDelete("comments/{commentId:int}")] public async Task DeleteComment(int commentId, [FromBody] DeleteContentRequest? request = null) { var result = await _moderationService.DeleteCommentAsync(commentId, User, request?.Reason); if (!result) { return NotFound(new { error = "Comment not found" }); } _logger.LogInformation("Comment {CommentId} deleted by moderator {ModeratorId}", commentId, User.GetUserId()); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = commentId.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Comment"; return Ok(new { message = "Comment deleted successfully" }); } [Authorize(Policy = Permissions.Moderation.EditTags)] [HttpPut("posts/{postId}/tags")] public async Task EditPostTags(int postId, [FromBody] EditTagsRequest request) { var result = await _moderationService.EditPostTagsAsync(postId, request.Tags, User); if (!result.Success) { if (result.Error == "Post not found") return NotFound(new { error = result.Error }); return BadRequest(new { error = result.Error }); } _logger.LogInformation("Tags for post {PostId} edited by moderator {ModeratorId}", postId, User.GetUserId()); await _postCacheInvalidationService.PurgePostMutationAsync(postId); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = postId.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; return Ok(new { message = "Tags updated successfully" }); } [Authorize(Policy = Permissions.Moderation.BanUser)] [HttpPost("users/ban")] public async Task BanUser([FromBody] BanUserRequest request) { // Prevent self-ban if (request.UserId == User.GetUserId()) { return BadRequest(new { error = "You cannot ban yourself" }); } var user = await _moderationService.BanUserAsync( request.UserId, User, request.Reason, request.BanUntil, request.Zone); if (user == null) { return NotFound(new { error = "User not found" }); } _logger.LogInformation("User {UserId} banned by moderator {ModeratorId} until {BanUntil} in zone {Zone}", request.UserId, User.GetUserId(), request.BanUntil ?? DateTime.MaxValue, request.Zone); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = user.UserName ?? request.UserId.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "User"; return Ok(new { message = "User banned successfully", banUntil = request.BanUntil, zone = request.Zone }); } [Authorize(Policy = Permissions.Moderation.BanUser)] [HttpPost("users/unban")] public async Task UnbanUser([FromBody] UnbanUserRequest request) { var user = await _moderationService.UnbanUserAsync( request.UserId, User, request.Zone); if (user == null) { return NotFound(new { error = "User not found or no active bans" }); } _logger.LogInformation("User {UserId} unbanned by moderator {ModeratorId} from {ZoneInfo}", request.UserId, User.GetUserId(), request.Zone.HasValue ? $"zone {request.Zone.Value}" : "all zones"); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = user.UserName ?? request.UserId.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "User"; return Ok(new { message = "User unbanned successfully", zone = request.Zone }); } [Authorize(Policy = Permissions.Moderation.BanUser)] [HttpGet("bans")] public async Task GetActiveBans([FromQuery] BansListRequest request) { try { var (bans, totalCount) = await _banService.GetAllActiveBansAsync( request.Page, request.PageSize, request.Zone); var banDtos = bans.ToDto(); return Ok(new PagedResult { Items = banDtos.ToList(), TotalCount = totalCount, Page = request.Page, PageSize = request.PageSize }); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving active bans"); return StatusCode(500, new { error = "Failed to retrieve bans" }); } } [Authorize(Policy = Permissions.Moderation.ViewAuditLog)] [HttpGet("logs")] public async Task GetModerationLog([FromQuery] ModerationLogRequest request) { var (logs, totalCount) = await _moderationService.GetModerationLogAsync(request.Page, request.PageSize); var logDtos = logs.ToDto(); return Ok(new PagedResult { Items = logDtos.ToList(), Page = request.Page, PageSize = request.PageSize, TotalCount = totalCount }); } [Authorize(Policy = Permissions.Moderation.ViewAuditLog)] [HttpGet("logs/user/{userId}")] public async Task GetUserModerationLog(Guid userId, [FromQuery] ModerationLogRequest request) { var (logs, totalCount) = await _moderationService.GetUserModerationLogAsync(userId, request.Page, request.PageSize); var logDtos = logs.ToDto(); return Ok(new PagedResult { Items = logDtos.ToList(), Page = request.Page, PageSize = request.PageSize, TotalCount = totalCount }); } /// /// Get pending posts queue for approval /// [Authorize(Policy = Permissions.Moderation.ApprovePost)] [HttpGet("posts/pending")] public async Task GetPendingPosts([FromQuery] int page = 1, [FromQuery] int pageSize = 20) { try { var (posts, totalCount) = await _moderationService.GetPendingPostsAsync(page, pageSize); var postDtos = posts.ToDto(); return Ok(new PagedResult { Items = postDtos, TotalCount = totalCount, Page = page, PageSize = pageSize }); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving pending posts"); return StatusCode(500, new { error = "Failed to retrieve pending posts" }); } } /// /// Approve a pending post /// [Authorize(Policy = Permissions.Moderation.ApprovePost)] [HttpPost("posts/{postId:int}/approve")] public async Task ApprovePost(int postId) { var result = await _moderationService.ApprovePostAsync(postId, User); if (!result) { return NotFound(new { error = "Post not found" }); } _logger.LogInformation("Post {PostId} approved by moderator {ModeratorId}", postId, User.GetUserId()); await _postCacheInvalidationService.PurgePostMutationAsync(postId); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = postId.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; return Ok(new { message = "Post approved successfully" }); } /// /// Lock or unlock comments on a post /// [Authorize(Policy = Permissions.Moderation.LockComments)] [HttpPost("posts/{postId:int}/lock-comments")] public async Task LockComments(int postId, [FromBody] LockCommentsRequest request) { var result = await _moderationService.LockCommentsAsync(postId, request.Locked, User, request.Reason); if (!result) { return NotFound(new { error = "Post not found" }); } _logger.LogInformation("Post {PostId} comments {Action} by moderator {ModeratorId}", postId, request.Locked ? "locked" : "unlocked", User.GetUserId()); await _postCacheInvalidationService.PurgePostMutationAsync(postId); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = postId.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; return Ok(new { message = request.Locked ? "Comments locked" : "Comments unlocked" }); } /// /// Feature or unfeature a post on the gallery sidebar /// [Authorize(Policy = Permissions.Moderation.FeaturePost)] [HttpPost("posts/{postId:int}/feature")] public async Task FeaturePost(int postId, [FromBody] FeaturePostRequest request) { var result = await _moderationService.FeaturePostAsync(postId, request.Featured, User); if (!result) { return NotFound(new { error = "Post not found" }); } _logger.LogInformation("Post {PostId} {Action} by moderator {ModeratorId}", postId, request.Featured ? "featured" : "unfeatured", User.GetUserId()); await _postCacheInvalidationService.PurgePostMutationAsync(postId); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = postId.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; return Ok(new { message = request.Featured ? "Post featured" : "Post unfeatured" }); } /// /// Reject a pending post (moves it to trash) /// [Authorize(Policy = Permissions.Moderation.ApprovePost)] [HttpDelete("posts/{postId:int}/reject")] public async Task RejectPost(int postId, [FromBody] RejectPostRequest request) { if (string.IsNullOrWhiteSpace(request.Reason)) { return BadRequest(new { error = "Reason is required" }); } var result = await _moderationService.RejectPostAsync(postId, User, request.Reason); if (!result) { return NotFound(new { error = "Post not found" }); } _logger.LogInformation("Post {PostId} rejected by moderator {ModeratorId}", postId, User.GetUserId()); await _postCacheInvalidationService.PurgePostMutationAsync(postId); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = postId.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; return Ok(new { message = "Post rejected and moved to trash" }); } #region Trash /// /// Get trashed posts /// [Authorize(Policy = Permissions.Admin.ViewTrash)] [HttpGet("posts/trashed")] public async Task GetTrashedPosts([FromQuery] int page = 1, [FromQuery] int pageSize = 20) { try { var (posts, totalCount) = await _moderationService.GetTrashedPostsAsync(page, pageSize); var postDtos = posts.ToDto(); return Ok(new PagedResult { Items = postDtos, TotalCount = totalCount, Page = page, PageSize = pageSize }); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving trashed posts"); return StatusCode(500, new { error = "Failed to retrieve trashed posts" }); } } /// /// Get count of trashed posts for badge /// [Authorize(Policy = Permissions.Admin.ViewTrash)] [HttpGet("posts/trashed/count")] public async Task GetTrashedCount() { var count = await _moderationService.GetTrashedCountAsync(); return Ok(new { count }); } /// /// Get short-lived signed URLs for trashed media (thumbnail + file). /// [Authorize(Policy = Permissions.Admin.ViewTrash)] [HttpGet("posts/{postId:int}/media-urls")] public async Task GetTrashedMediaUrls(int postId, [FromQuery] int expiresInSeconds = 900) { var post = await _postService.GetPostByIdAsync(postId); if (post == null || !post.IsTrashed) { return NotFound(new { error = "Post not found" }); } var fileSignedUrl = _signedUrlService.CreateSignedUrl( $"/api/booru/posts/{postId}/file", expiresInSeconds); var thumbnailSignedUrl = !string.IsNullOrWhiteSpace(post.ThumbnailPath) ? _signedUrlService.CreateSignedUrl($"/api/booru/posts/{postId}/thumbnail", expiresInSeconds) : null; return Ok(new TrashedMediaUrlsResponse { FileUrl = fileSignedUrl.Url, ThumbnailUrl = thumbnailSignedUrl?.Url, ExpiresAt = fileSignedUrl.ExpiresAt }); } /// /// Restore a trashed post /// [Authorize(Policy = Permissions.Admin.ViewTrash)] [HttpPost("posts/{postId:int}/restore")] public async Task RestorePost(int postId) { var result = await _moderationService.RestorePostAsync(postId, User); if (!result) { return NotFound(new { error = "Post not found" }); } _logger.LogInformation("Post {PostId} restored by admin {AdminId}", postId, User.GetUserId()); await _postCacheInvalidationService.PurgePostMutationAsync(postId); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = postId.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; return Ok(new { message = "Post restored successfully" }); } /// /// Permanently delete a trashed post /// [Authorize(Policy = Permissions.Admin.DeletePost)] [HttpDelete("posts/{postId:int}/permanent")] public async Task PermanentlyDeletePost(int postId) { var result = await _moderationService.PermanentlyDeletePostAsync(postId, User); if (!result) { return NotFound(new { error = "Post not found" }); } _logger.LogInformation("Post {PostId} permanently deleted by admin {AdminId}", postId, User.GetUserId()); await _postCacheInvalidationService.PurgePostMutationAsync(postId); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = postId.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; return Ok(new { message = "Post permanently deleted" }); } #endregion #region IP Bans [Authorize(Policy = Permissions.Moderation.BanIp)] [HttpPost("ip-bans")] public async Task CreateIpBan([FromBody] CreateIpBanRequest request) { var resolvedIp = await ResolveIpAsync(request.IpAddress); if (resolvedIp == null) return BadRequest(new { error = "Could not resolve IP address. The cloaked IP was not found in any known records." }); var createdById = User.GetUserId(); var ban = await _ipBanService.CreateIpBanAsync( resolvedIp, request.Reason, request.BanUntil, createdById); _logger.LogInformation("IP {IpAddress} banned by moderator {ModeratorId} until {BanUntil}", resolvedIp, createdById, request.BanUntil ?? DateTime.MaxValue); var dto = ban.ToDto(); if (!User.HasPermission(Permissions.Moderation.ViewIps)) dto.IpAddress = await _ipCloakService.CloakAsync(dto.IpAddress); return Ok(dto); } [Authorize(Policy = Permissions.Moderation.BanIp)] [HttpDelete("ip-bans/{id:guid}")] public async Task RemoveIpBan(Guid id) { var result = await _ipBanService.RemoveIpBanAsync(id); if (!result) return NotFound(new { error = "IP ban not found" }); _logger.LogInformation("IP ban {BanId} removed by moderator {ModeratorId}", id, User.GetUserId()); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "IpBan"; return Ok(new { message = "IP ban removed" }); } [Authorize(Policy = Permissions.Moderation.BanIp)] [HttpGet("ip-bans")] public async Task GetActiveIpBans([FromQuery] IpBansListRequest request) { var canViewIps = User.HasPermission(Permissions.Moderation.ViewIps); string? search = null; ICollection? matchIpAddresses = null; if (!string.IsNullOrWhiteSpace(request.Search)) { if (canViewIps) { search = request.Search; } else { matchIpAddresses = await _ipCloakService.FindRawIpsByCloakedSubstringAsync(request.Search); } } var (bans, totalCount) = await _ipBanService.GetActiveIpBansAsync( request.Page, request.PageSize, search, matchIpAddresses); var dtos = bans.ToDto(); if (!canViewIps) { foreach (var dto in dtos) dto.IpAddress = await _ipCloakService.CloakAsync(dto.IpAddress); } return Ok(new PagedResult { Items = dtos, TotalCount = totalCount, Page = request.Page, PageSize = request.PageSize }); } [Authorize(Policy = Permissions.Moderation.BanIp)] [HttpGet("ip-bans/check")] public async Task CheckIpBan([FromQuery] string ip) { if (string.IsNullOrWhiteSpace(ip)) return BadRequest(new { error = "IP address is required" }); var resolvedIp = await ResolveIpAsync(ip); if (resolvedIp == null) return BadRequest(new { error = "Could not resolve IP address. The cloaked IP was not found in any known records." }); var isBanned = await _ipBanService.IsIpBannedAsync(resolvedIp); var ban = isBanned ? await _ipBanService.GetActiveIpBanAsync(resolvedIp) : null; var dto = ban?.ToDto(); if (dto != null && !User.HasPermission(Permissions.Moderation.ViewIps)) dto.IpAddress = await _ipCloakService.CloakAsync(dto.IpAddress); return Ok(new { isBanned, ban = dto }); } #endregion #region Ban Appeals - User-facing /// /// Get current user's active bans /// [HttpGet("bans/mine")] public async Task GetMyBans() { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var bans = await _banService.GetActiveBansAsync(userId.Value); return Ok(bans.ToDto()); } /// /// Submit a ban appeal /// [HttpPost("bans/appeals")] public async Task CreateBanAppeal([FromBody] CreateBanAppealRequest request) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); try { var appeal = await _banAppealService.CreateAppealAsync(userId.Value, request.BanId, request.Reason); return Ok(appeal.ToDto()); } catch (ArgumentException ex) { return NotFound(new { error = ex.Message }); } catch (UnauthorizedAccessException) { return Forbid(); } catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } } /// /// Get current user's ban appeals /// [HttpGet("bans/appeals/mine")] public async Task GetMyAppeals() { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var appeals = await _banAppealService.GetUserAppealsAsync(userId.Value); return Ok(appeals.ToDto()); } #endregion #region IP Ban Appeals - User-facing /// /// Get current IP's active bans /// [AllowAnonymous] [HttpGet("ip-bans/mine")] public async Task GetMyIpBans() { var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); if (string.IsNullOrEmpty(ip)) return BadRequest(new { error = "Could not determine IP address" }); var ban = await _ipBanService.GetActiveIpBanAsync(ip); return Ok(ban != null ? new List { ban.ToDto() } : new List()); } /// /// Submit an IP ban appeal /// [AllowAnonymous] [HttpPost("ip-bans/appeals")] public async Task CreateIpBanAppeal([FromBody] CreateIpBanAppealRequest request) { var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); if (string.IsNullOrEmpty(ip)) return BadRequest(new { error = "Could not determine IP address" }); var userId = User.Identity?.IsAuthenticated == true ? User.GetUserId() : null; try { var appeal = await _ipBanAppealService.CreateAppealAsync(ip, request.IpBanId, request.Reason, userId); return Ok(appeal.ToDto()); } catch (ArgumentException ex) { return NotFound(new { error = ex.Message }); } catch (UnauthorizedAccessException) { return Forbid(); } catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } } /// /// Get current IP's ban appeals /// [AllowAnonymous] [HttpGet("ip-bans/appeals/mine")] public async Task GetMyIpAppeals() { var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); if (string.IsNullOrEmpty(ip)) return BadRequest(new { error = "Could not determine IP address" }); var appeals = await _ipBanAppealService.GetIpAppealsAsync(ip); return Ok(appeals.ToDto()); } #endregion #region IP Ban Appeals - Admin-facing /// /// Get all IP ban appeals (paginated, filterable by status) /// [Authorize(Policy = Permissions.Moderation.ReviewBanAppeals)] [HttpGet("ip-bans/appeals")] public async Task GetIpBanAppeals([FromQuery] BanAppealsListRequest request) { var (appeals, totalCount) = await _ipBanAppealService.GetAppealsAsync( request.Status, request.Page, request.PageSize); var dtos = appeals.ToDto(); if (!User.HasPermission(Permissions.Moderation.ViewIps)) { foreach (var dto in dtos) { dto.IpAddress = await _ipCloakService.CloakAsync(dto.IpAddress); if (dto.IpBan != null) { dto.IpBan.IpAddress = await _ipCloakService.CloakAsync(dto.IpBan.IpAddress); } } } return Ok(new PagedResult { Items = dtos, TotalCount = totalCount, Page = request.Page, PageSize = request.PageSize }); } /// /// Get count of pending IP ban appeals /// [Authorize(Policy = Permissions.Moderation.ReviewBanAppeals)] [HttpGet("ip-bans/appeals/pending/count")] public async Task GetPendingIpAppealCount() { var count = await _ipBanAppealService.GetPendingAppealCountAsync(); return Ok(new { count }); } /// /// Accept an IP ban appeal (unbans the IP) /// [Authorize(Policy = Permissions.Moderation.ReviewBanAppeals)] [HttpPost("ip-bans/appeals/{id:guid}/accept")] public async Task AcceptIpBanAppeal(Guid id, [FromBody] ResolveBanAppealRequest? request = null) { var moderator = await _userManager.FindByIdAsync(User.GetUserId().ToString()!); if (moderator == null) return Unauthorized(); try { var appeal = await _ipBanAppealService.AcceptAppealAsync(id, moderator, request?.Note); if (appeal == null) return NotFound(new { error = "Appeal not found" }); _logger.LogInformation("IP ban appeal {AppealId} accepted by moderator {ModeratorId}", id, moderator.Id); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "IpBanAppeal"; var dto = appeal.ToDto(); if (!User.HasPermission(Permissions.Moderation.ViewIps)) { dto.IpAddress = await _ipCloakService.CloakAsync(dto.IpAddress); if (dto.IpBan != null) { dto.IpBan.IpAddress = await _ipCloakService.CloakAsync(dto.IpBan.IpAddress); } } return Ok(dto); } catch (UnauthorizedAccessException) { return Forbid(); } catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } } /// /// Reject an IP ban appeal /// [Authorize(Policy = Permissions.Moderation.ReviewBanAppeals)] [HttpPost("ip-bans/appeals/{id:guid}/reject")] public async Task RejectIpBanAppeal(Guid id, [FromBody] ResolveBanAppealRequest? request = null) { var moderator = await _userManager.FindByIdAsync(User.GetUserId().ToString()!); if (moderator == null) return Unauthorized(); try { var appeal = await _ipBanAppealService.RejectAppealAsync(id, moderator, request?.Note, request?.DenyFurtherAppeals ?? false); if (appeal == null) return NotFound(new { error = "Appeal not found" }); _logger.LogInformation("IP ban appeal {AppealId} rejected by moderator {ModeratorId}", id, moderator.Id); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "IpBanAppeal"; var dto = appeal.ToDto(); if (!User.HasPermission(Permissions.Moderation.ViewIps)) { dto.IpAddress = await _ipCloakService.CloakAsync(dto.IpAddress); if (dto.IpBan != null) { dto.IpBan.IpAddress = await _ipCloakService.CloakAsync(dto.IpBan.IpAddress); } } return Ok(dto); } catch (UnauthorizedAccessException) { return Forbid(); } catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } } #endregion #region Ban Appeals - Admin-facing /// /// Get all ban appeals (paginated, filterable by status) /// [Authorize(Policy = Permissions.Moderation.ReviewBanAppeals)] [HttpGet("bans/appeals")] public async Task GetBanAppeals([FromQuery] BanAppealsListRequest request) { var (appeals, totalCount) = await _banAppealService.GetAppealsAsync( request.Status, request.Page, request.PageSize); return Ok(new PagedResult { Items = appeals.ToDto(), TotalCount = totalCount, Page = request.Page, PageSize = request.PageSize }); } /// /// Get count of pending ban appeals /// [Authorize(Policy = Permissions.Moderation.ReviewBanAppeals)] [HttpGet("bans/appeals/pending/count")] public async Task GetPendingAppealCount() { var count = await _banAppealService.GetPendingAppealCountAsync(); return Ok(new { count }); } /// /// Accept a ban appeal (unbans the user) /// [Authorize(Policy = Permissions.Moderation.ReviewBanAppeals)] [HttpPost("bans/appeals/{id:guid}/accept")] public async Task AcceptBanAppeal(Guid id, [FromBody] ResolveBanAppealRequest? request = null) { var moderator = await _userManager.FindByIdAsync(User.GetUserId().ToString()!); if (moderator == null) return Unauthorized(); try { var appeal = await _banAppealService.AcceptAppealAsync(id, moderator, request?.Note); if (appeal == null) return NotFound(new { error = "Appeal not found" }); _logger.LogInformation("Ban appeal {AppealId} accepted by moderator {ModeratorId}", id, moderator.Id); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "BanAppeal"; return Ok(appeal.ToDto()); } catch (UnauthorizedAccessException ex) { return Forbid(); } catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } } /// /// Reject a ban appeal /// [Authorize(Policy = Permissions.Moderation.ReviewBanAppeals)] [HttpPost("bans/appeals/{id:guid}/reject")] public async Task RejectBanAppeal(Guid id, [FromBody] ResolveBanAppealRequest? request = null) { var moderator = await _userManager.FindByIdAsync(User.GetUserId().ToString()!); if (moderator == null) return Unauthorized(); try { var appeal = await _banAppealService.RejectAppealAsync(id, moderator, request?.Note, request?.DenyFurtherAppeals ?? false); if (appeal == null) return NotFound(new { error = "Appeal not found" }); _logger.LogInformation("Ban appeal {AppealId} rejected by moderator {ModeratorId}", id, moderator.Id); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "BanAppeal"; return Ok(appeal.ToDto()); } catch (UnauthorizedAccessException ex) { return Forbid(); } catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } } #endregion #region Reports /// /// Submit a report for content /// [Authorize(Policy = Permissions.User.CreateReport)] [HttpPost("reports")] public async Task CreateReport([FromBody] CreateReportRequest request) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); // Check if target exists if (!await _reportService.TargetExistsAsync(request.TargetType, request.TargetId)) { return NotFound(new { error = "Target not found" }); } // Check for duplicate pending report if (await _reportService.HasPendingReportAsync(userId.Value, request.TargetType, request.TargetId)) { return Conflict(new { error = "You already have a pending report for this content" }); } var report = await _reportService.CreateReportAsync( userId.Value, request.TargetType, request.TargetId, request.Reason); var preview = await _reportService.GetTargetPreviewAsync(report.TargetType, report.TargetId); return Ok(report.ToDto(preview)); } /// /// Get list of reports with optional filtering /// [Authorize(Policy = Permissions.Moderation.ViewReports)] [HttpGet("reports")] public async Task GetReports([FromQuery] ReportsListRequest request) { var (reports, totalCount) = await _reportService.GetReportsAsync( request.Status, request.Page, request.PageSize); var reportDtos = new List(); foreach (var report in reports) { var preview = await _reportService.GetTargetPreviewAsync(report.TargetType, report.TargetId); reportDtos.Add(report.ToDto(preview)); } return Ok(new PagedResult { Items = reportDtos, TotalCount = totalCount, Page = request.Page, PageSize = request.PageSize }); } /// /// Get a single report by ID /// [Authorize(Policy = Permissions.Moderation.ViewReports)] [HttpGet("reports/{id:guid}")] public async Task GetReport(Guid id) { var report = await _reportService.GetReportByIdAsync(id); if (report == null) return NotFound(new { error = "Report not found" }); var preview = await _reportService.GetTargetPreviewAsync(report.TargetType, report.TargetId); return Ok(report.ToDto(preview)); } /// /// Get count of pending reports for badge /// [Authorize(Policy = Permissions.Moderation.ViewReports)] [HttpGet("reports/pending/count")] public async Task GetPendingReportCount() { var count = await _reportService.GetPendingReportCountAsync(); return Ok(new { count }); } /// /// Resolve a report (take action) /// [Authorize(Policy = Permissions.Moderation.ViewReports)] [HttpPost("reports/{id:guid}/resolve")] public async Task ResolveReport(Guid id, [FromBody] ResolveReportRequest? request = null) { var report = await _reportService.ResolveReportAsync(id, User, request?.Note); if (report == null) return NotFound(new { error = "Report not found" }); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Report"; var preview = await _reportService.GetTargetPreviewAsync(report.TargetType, report.TargetId); return Ok(report.ToDto(preview)); } /// /// Dismiss a report (no action needed) /// [Authorize(Policy = Permissions.Moderation.ViewReports)] [HttpPost("reports/{id:guid}/dismiss")] public async Task DismissReport(Guid id, [FromBody] ResolveReportRequest? request = null) { var report = await _reportService.DismissReportAsync(id, User, request?.Note); if (report == null) return NotFound(new { error = "Report not found" }); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Report"; var preview = await _reportService.GetTargetPreviewAsync(report.TargetType, report.TargetId); return Ok(report.ToDto(preview)); } /// /// Check if user has already reported this content /// [Authorize(Policy = Permissions.User.CreateReport)] [HttpGet("reports/check")] public async Task CheckPendingReport([FromQuery] string targetType, [FromQuery] string targetId) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); if (!Enum.TryParse(targetType, true, out var type)) { return BadRequest(new { error = "Invalid target type" }); } var hasPending = await _reportService.HasPendingReportAsync(userId.Value, type, targetId); return Ok(new { hasPendingReport = hasPending }); } #endregion #region IP Lookup /// /// Get the IP address for a comment or forum post. /// Requires moderation.ban_ip. Returns cloaked IP unless the caller also has moderation.view_ips. /// [Authorize(Policy = Permissions.Moderation.LookupIps)] [HttpGet("ip/{entityType}/{entityId:int}")] public async Task GetEntityIp(string entityType, int entityId) { string? ip; switch (entityType.ToLowerInvariant()) { case "comment": var comment = await _commentService.GetCommentByIdAsync(entityId); if (comment == null) return NotFound(new { error = "Comment not found" }); ip = comment.IpAddress; break; case "forum-post": var forumPost = await _forumPostService.GetPostByIdAsync(entityId); if (forumPost == null) return NotFound(new { error = "Forum post not found" }); ip = forumPost.IpAddress; break; default: return BadRequest(new { error = "Invalid entity type. Use 'comment' or 'forum-post'." }); } if (ip != null && !User.HasPermission(Permissions.Moderation.ViewIps)) ip = await _ipCloakService.CloakAsync(ip); return Ok(new { ipAddress = ip }); } #endregion } /// /// Request model for rejecting a post /// public class RejectPostRequest { public string Reason { get; set; } = string.Empty; } /// /// Request model for featuring/unfeaturing a post /// public class FeaturePostRequest { public bool Featured { get; set; } } }