using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Nuuru.Server.DTOs.Anonymous; using Nuuru.Server.Extensions; using Nuuru.Server.Models; using Nuuru.Server.Models.Booru; using Nuuru.Server.Services; using Nuuru.Server.Services.Search; using Nuuru.Server.Services.Storage; namespace Nuuru.Server.Controllers { [ApiController] [Route("api/anonymous")] public class AnonymousController : ControllerBase { private readonly ISiteSettingsService _siteSettings; private readonly ICaptchaService _captchaService; private readonly IPostService _postService; private readonly ITagService _tagService; private readonly ICommentService _commentService; private readonly IDefaultQueryFilterService _defaultQueryFilterService; private readonly IIpIntelligenceService _ipIntelligence; private readonly ILogger _logger; private readonly IConfiguration _configuration; private const int MinimumTagCount = 5; private readonly long _maxFileSize; private readonly HashSet _allowedMimeTypes; public AnonymousController( ISiteSettingsService siteSettings, ICaptchaService captchaService, IPostService postService, ITagService tagService, ICommentService commentService, IDefaultQueryFilterService defaultQueryFilterService, IIpIntelligenceService ipIntelligence, ILogger logger, IConfiguration configuration) { _siteSettings = siteSettings; _captchaService = captchaService; _postService = postService; _tagService = tagService; _commentService = commentService; _defaultQueryFilterService = defaultQueryFilterService; _ipIntelligence = ipIntelligence; _logger = logger; _configuration = configuration; _maxFileSize = _configuration.GetValue("Upload:MaxFileSizeBytes", 100 * 1024 * 1024); _allowedMimeTypes = new HashSet(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "video/mp4", "video/webm", "video/quicktime", "audio/mpeg", "audio/wav", "audio/ogg", "application/x-shockwave-flash" }; } [HttpPost("posts")] [AllowAnonymous] [EnableRateLimiting("anonymous-upload")] public async Task AnonymousUpload( [FromForm] IFormFile file, [FromForm] string? tags, [FromForm] string? rating, [FromForm] string? category, [FromForm] string? source, [FromForm] string? description, [FromForm][Required] string captchaToken) { try { if (!await _siteSettings.IsAnonymousUploadEnabledAsync()) return BadRequest(new { error = "Anonymous uploads are currently disabled" }); var vpnBlock = await CheckAnonymousVpnAsync(); if (vpnBlock != null) return vpnBlock; if (string.IsNullOrWhiteSpace(captchaToken) || !await _captchaService.ValidateAsync(captchaToken)) return BadRequest(new { error = "Invalid CAPTCHA" }); var anonUserId = await _siteSettings.GetAnonymousUserIdAsync(); if (!anonUserId.HasValue) return StatusCode(500, new { error = "Anonymous user not configured" }); // Validate minimum tags var tagList = string.IsNullOrWhiteSpace(tags) ? Array.Empty() : tags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (tagList.Length < MinimumTagCount) return BadRequest(new { error = $"At least {MinimumTagCount} tags are required" }); if (file == null || file.Length == 0) return BadRequest(new { error = "No file provided" }); if (file.Length > _maxFileSize) return BadRequest(new { error = $"File size exceeds maximum allowed size of {_maxFileSize / (1024 * 1024)}MB" }); using var tempStream = new MemoryStream(); await file.CopyToAsync(tempStream); tempStream.Position = 0; var detectedMime = Utilities.MIME.DetectMIME(tempStream, file.FileName); if (!_allowedMimeTypes.Contains(detectedMime)) return BadRequest(new { error = $"File type {detectedMime} is not allowed" }); var postRating = ParseRating(rating); var postCategory = ParseCategory(category); // Validate tags based on category var tagError = _tagService.ValidateTagsForCategory(tagList, postCategory); if (tagError != null) { return BadRequest(new { error = tagError }); } tempStream.Position = 0; var anonIp = HttpContext.Connection.RemoteIpAddress?.ToString(); var result = await _postService.CreatePostAsync(tempStream, file.FileName, detectedMime, anonUserId.Value, postRating, postCategory, source, description, autoApprove: false, anonIp); if (!result.Success) return BadRequest(new { error = result.Error ?? "Failed to create post" }); var post = result.Post!; await _tagService.UpdatePostTagsAsync(post, tagList); _logger.LogInformation("Anonymous upload: post {PostId} from IP {Ip}", post.Id, post.IpAddress); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = post.Id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; HttpContext.Items[AuditLog.TargetCategoryKey] = "Booru"; var postDto = post.ToDto(); return CreatedAtAction("GetPostById", "Post", new { id = post.Id }, postDto); } catch (Exception ex) { _logger.LogError(ex, "Error during anonymous upload"); return StatusCode(500, new { error = "Failed to upload post" }); } } [HttpPost("posts/{postId:int}/comments")] [AllowAnonymous] [EnableRateLimiting("anonymous-comment")] public async Task AnonymousComment(int postId, [FromBody] AnonymousCommentRequest request) { try { if (!await _siteSettings.IsAnonymousCommentEnabledAsync()) return BadRequest(new { error = "Anonymous comments are currently disabled" }); var vpnBlock = await CheckAnonymousVpnAsync(); if (vpnBlock != null) return vpnBlock; if (string.IsNullOrWhiteSpace(request.CaptchaToken) || !await _captchaService.ValidateAsync(request.CaptchaToken)) return BadRequest(new { error = "Invalid CAPTCHA" }); var anonUserId = await _siteSettings.GetAnonymousUserIdAsync(); if (!anonUserId.HasValue) return StatusCode(500, new { error = "Anonymous user not configured" }); if (!await _defaultQueryFilterService.IsPostVisibleAsync(postId)) return NotFound(new { error = "Post not found" }); 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 ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); var comment = await _commentService.CreateCommentAsync(postId, anonUserId.Value, request.Content, ipAddress); if (comment == null) return StatusCode(500, new { error = "Failed to create comment" }); _logger.LogInformation("Anonymous comment on post {PostId} from IP {Ip}", postId, ipAddress); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = $"{postId}:{comment.Id}"; HttpContext.Items[AuditLog.TargetTypeKey] = "Comment"; HttpContext.Items[AuditLog.TargetCategoryKey] = "Booru"; return Ok(comment.ToDto()); } catch (Exception ex) { _logger.LogError(ex, "Error during anonymous comment on post {PostId}", postId); return StatusCode(500, new { error = "Failed to create comment" }); } } private async Task CheckAnonymousVpnAsync() { if (!await _siteSettings.ShouldRejectAnonymousVpnAsync()) return null; var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); if (string.IsNullOrWhiteSpace(ip)) return null; var lookupUrl = await _siteSettings.GetSignupGeoVerificationLookupUrlAsync(); var lookup = await _ipIntelligence.LookupAsync(ip, lookupUrl); if (lookup is { IsFlagged: true }) return BadRequest(new { error = "Uploads and comments from VPN or proxy connections are not allowed." }); return null; } private static PostRating ParseRating(string? rating) { if (string.IsNullOrWhiteSpace(rating)) return PostRating.Safe; return rating.ToLowerInvariant() switch { "s" or "safe" => PostRating.Safe, "q" or "questionable" => PostRating.Questionable, "e" or "explicit" => PostRating.Explicit, _ => PostRating.Safe }; } private static PostCategory ParseCategory(string? category) { if (string.IsNullOrWhiteSpace(category)) return PostCategory.Gallery; return category.ToLowerInvariant() switch { "g" or "gallery" => PostCategory.Gallery, "a" or "artwork" or "artworks" => PostCategory.Artworks, _ => PostCategory.Gallery }; } } }