using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Nuuru.Server.Auth; using Nuuru.Server.DTOs.Booru; using Nuuru.Server.Extensions; using Nuuru.Server.Models; using Nuuru.Server.Models.Booru; using Nuuru.Server.Models.Requests; using Nuuru.Server.Services; using Nuuru.Server.Services.Search; using Nuuru.Server.Services.Storage; namespace Nuuru.Server.Controllers { [ApiController] [Route("api/booru/posts")] public class PostController : ControllerBase { private readonly IPostService _postService; private readonly ITagService _tagService; private readonly IPostHistoryService _postHistoryService; private readonly IBooruSearchService _searchService; private readonly IFileStorageService _fileStorageService; private readonly IThumbnailService _thumbnailService; private readonly IWatermarkService _watermarkService; private readonly IReactionService _reactionService; private readonly ISignedUrlService _signedUrlService; private readonly INotificationService _notificationService; private readonly IUserBadgeService _userBadgeService; private readonly IDefaultQueryFilterService _defaultQueryFilterService; private readonly IPostCacheInvalidationService _postCacheInvalidationService; private readonly ILogger _logger; private readonly IConfiguration _configuration; // Configurable limits private const int MinimumTagCount = 5; private readonly long _maxFileSize; private readonly HashSet _allowedMimeTypes; public PostController( IPostService postService, ITagService tagService, IPostHistoryService postHistoryService, IBooruSearchService searchService, IFileStorageService fileStorageService, IThumbnailService thumbnailService, IWatermarkService watermarkService, IReactionService reactionService, ISignedUrlService signedUrlService, INotificationService notificationService, IUserBadgeService userBadgeService, IDefaultQueryFilterService defaultQueryFilterService, IPostCacheInvalidationService postCacheInvalidationService, ILogger logger, IConfiguration configuration) { _postService = postService; _tagService = tagService; _postHistoryService = postHistoryService; _searchService = searchService; _fileStorageService = fileStorageService; _thumbnailService = thumbnailService; _watermarkService = watermarkService; _reactionService = reactionService; _signedUrlService = signedUrlService; _notificationService = notificationService; _userBadgeService = userBadgeService; _defaultQueryFilterService = defaultQueryFilterService; _postCacheInvalidationService = postCacheInvalidationService; _logger = logger; _configuration = configuration; // Default 100MB max file size, configurable via appsettings _maxFileSize = _configuration.GetValue("Upload:MaxFileSizeBytes", 100 * 1024 * 1024); // Allowed MIME types for uploads _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" }; } private Task IsPostVisibleAsync(int postId) { return _defaultQueryFilterService.IsPostVisibleAsync(postId); } /// /// Get paginated list of posts with full search syntax support. /// Supports tag queries, boolean operators (-tag, {tag1 ~ tag2}), wildcards (tag*), /// and meta-tags (rating:safe, uploader:name, order:score, etc.) /// [HttpGet] [AllowAnonymous] public async Task GetPosts( [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? q = null) { try { if (page < 1) { return BadRequest(new { error = "Page must be greater than 0" }); } if (pageSize < 1 || pageSize > 100) { return BadRequest(new { error = "Page size must be between 1 and 100" }); } if (q != null && q.Length > 500) { return BadRequest(new { error = "Search query must not exceed 500 characters" }); } // Validate query syntax if (!string.IsNullOrWhiteSpace(q)) { var validation = _searchService.ValidateQuery(q); if (!validation.IsValid) { return BadRequest(new { error = "Invalid search query", details = validation.Errors }); } } var result = await _searchService.SearchPostsAsync(q, page, pageSize); return Ok(new { posts = result.Posts.Items, totalCount = result.Posts.TotalCount, page = result.Posts.Page, pageSize = result.Posts.PageSize, totalPages = result.Posts.TotalPages, meta = new { queryTimeMs = result.Metadata.QueryTimeMs, warnings = result.Metadata.Warnings } }); } catch (Exception ex) { _logger.LogError(ex, "Error searching posts with query: {Query}", q); return StatusCode(500, new { error = "Failed to search posts" }); } } /// /// Get the currently featured post for the gallery sidebar /// [HttpGet("featured")] [AllowAnonymous] public async Task GetFeaturedPost() { try { var post = await _postService.GetFeaturedPostAsync(); if (post == null || !await IsPostVisibleAsync(post.Id)) { return NotFound(new { error = "No featured post" }); } // Batch-fetch display info for uploader and approver var userIdsToFetch = new List { post.Uploader.Id }; if (post.ApprovedBy != null) { userIdsToFetch.Add(post.ApprovedBy.Id); } var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(userIdsToFetch.Distinct()); var postDto = post.ToDto(displayInfoMap); return Ok(postDto); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving featured post"); return StatusCode(500, new { error = "Failed to retrieve featured post" }); } } [HttpGet("random/id")] [AllowAnonymous] public async Task GetRandomPostId() { try { var result = await _searchService.SearchPostsAsync("order:random", 1, 1); var post = result.Posts.Items.FirstOrDefault(); if (post == null) { return NotFound(new { error = "No posts found" }); } return Ok(new { id = post.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving random post"); return StatusCode(500, new { error = "Failed to retrieve random post" }); } } /// /// Get a single post by ID /// [HttpGet("{id:int}")] [AllowAnonymous] public async Task GetPostById(int id) { try { if (!await IsPostVisibleAsync(id)) { return NotFound(new { error = "Post not found" }); } var post = await _postService.GetPostByIdAsync(id); if (post == null) { return NotFound(new { error = "Post not found" }); } // Fetch reactions for this post var currentUserId = User.GetUserId(); var reactions = await _reactionService.GetReactionsAsync( ReactionEntityType.BooruPost, id, currentUserId); if (currentUserId.HasValue) { await _notificationService.MarkReadByPostAsync(currentUserId.Value, id); } // Batch-fetch display info for uploader and approver var userIdsToFetch = new List { post.Uploader.Id }; if (post.ApprovedBy != null) { userIdsToFetch.Add(post.ApprovedBy.Id); } var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(userIdsToFetch.Distinct()); var postDto = post.ToDto(displayInfoMap, reactions); return Ok(postDto); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving post {PostId}", id); return StatusCode(500, new { error = "Failed to retrieve post" }); } } /// /// Serve the actual file for a post /// [HttpGet("{id:int}/file")] [AllowAnonymous] public async Task GetPostFile( int id, [FromQuery] long? expires = null, [FromQuery] string? sig = null) { try { var post = await _postService.GetPostFileInfoByIdAsync(id, includeTagNames: true); if (post == null) { return NotFound(new { error = "Post not found" }); } if (post.IsTrashed) { var canViewTrash = User.HasPermission(Permissions.Admin.ViewTrash); var hasValidSignature = expires.HasValue && !string.IsNullOrWhiteSpace(sig) && _signedUrlService.IsValid($"/api/booru/posts/{id}/file", expires.Value, sig); if (!canViewTrash && !hasValidSignature) { return NotFound(new { error = "Post not found" }); } } var fileResult = await _fileStorageService.GetFileAsync(post.StorageIdentifier); if (fileResult == null) { _logger.LogError("File not found for post {PostId} at {StorageIdentifier}", id, post.StorageIdentifier); return NotFound(new { error = "File not found" }); } Response.SetFileCacheHeaders(isPrivate: !post.IsApproved || post.IsTrashed); var tagsString = string.Join(" ", post.TagNames); var ext = Path.GetExtension(post.OriginalFileName) ?? ""; var baseName = $"{id} - soybooru.com - {tagsString}"; // Truncate base filename to ensure total length with extension is <= 255 if (baseName.Length + ext.Length > 255) { baseName = baseName.Substring(0, 255 - ext.Length); } // Sanitize filename by removing invalid characters and joining segments var invalidChars = Path.GetInvalidFileNameChars(); var sanitizedBaseName = string.Join("_", baseName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries)).Trim(); var fileName = sanitizedBaseName + ext; Response.SetInlineFileHeaders(fileName); return File(fileResult.Stream, post.MimeType); } catch (Exception ex) { _logger.LogError(ex, "Error serving file for post {PostId}", id); return StatusCode(500, new { error = "Failed to serve file" }); } } /// /// Serve the file with watermark applied. /// [HttpGet("{id:int}/file-watermark")] [AllowAnonymous] public async Task GetPostFileWatermark( int id, [FromQuery] long? expires = null, [FromQuery] string? sig = null) { try { var post = await _postService.GetPostFileInfoByIdAsync(id, includeTagNames: true); if (post == null) { return NotFound(new { error = "Post not found" }); } if (post.IsTrashed) { var canViewTrash = User.HasPermission(Permissions.Admin.ViewTrash); var hasValidSignature = expires.HasValue && !string.IsNullOrWhiteSpace(sig) && _signedUrlService.IsValid($"/api/booru/posts/{id}/file-watermark", expires.Value, sig); if (!canViewTrash && !hasValidSignature) { return NotFound(new { error = "Post not found" }); } } var fileResult = await _fileStorageService.GetFileAsync(post.StorageIdentifier); if (fileResult == null) { _logger.LogError("File not found for post {PostId} at {StorageIdentifier}", id, post.StorageIdentifier); return NotFound(new { error = "File not found" }); } var watermarked = await _watermarkService.ApplyAsync(fileResult.Stream, post.MimeType, post.StorageIdentifier); Response.SetFileCacheHeaders(isPrivate: !post.IsApproved || post.IsTrashed); var tagsString = string.Join(" ", post.TagNames); var ext = Path.GetExtension(post.OriginalFileName) ?? ""; var baseName = $"{id} - soybooru.com - {tagsString}"; if (baseName.Length + ext.Length > 255) { baseName = baseName.Substring(0, 255 - ext.Length); } var invalidChars = Path.GetInvalidFileNameChars(); var sanitizedBaseName = string.Join("_", baseName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries)).Trim(); var fileName = sanitizedBaseName + ext; Response.SetInlineFileHeaders(fileName); return File(watermarked, post.MimeType); } catch (Exception ex) { _logger.LogError(ex, "Error serving watermarked file for post {PostId}", id); return StatusCode(500, new { error = "Failed to serve file" }); } } /// /// Serve the thumbnail for a post (generates on-demand if missing) /// [HttpGet("{id:int}/thumbnail")] [AllowAnonymous] public async Task GetPostThumbnail( int id, [FromQuery] long? expires = null, [FromQuery] string? sig = null) { try { var post = await _postService.GetPostFileInfoByIdAsync(id); if (post == null) { return NotFound(new { error = "Post not found" }); } if (post.IsTrashed) { var canViewTrash = User.HasPermission(Permissions.Admin.ViewTrash); var hasValidSignature = expires.HasValue && !string.IsNullOrWhiteSpace(sig) && _signedUrlService.IsValid($"/api/booru/posts/{id}/thumbnail", expires.Value, sig); if (!canViewTrash && !hasValidSignature) { return NotFound(new { error = "Post not found" }); } } Response.SetFileCacheHeaders(isPrivate: !post.IsApproved || post.IsTrashed); var thumbFileName = $"{id} - SoyBooru thumbnail.webp"; // Try to serve existing thumbnail if (!string.IsNullOrEmpty(post.ThumbnailPath)) { var thumbnailResult = await _thumbnailService.GetThumbnailAsync(post.ThumbnailPath); if (thumbnailResult != null) { Response.SetInlineFileHeaders(thumbFileName); return File(thumbnailResult.Stream, thumbnailResult.Metadata.ContentType); } _logger.LogWarning("Thumbnail file not found for post {PostId}: {Identifier}", id, post.ThumbnailPath); } // Try to generate thumbnail on-demand if supported if (_thumbnailService.SupportsThumbnail(post.MimeType)) { var fileResult = await _fileStorageService.GetFileAsync(post.StorageIdentifier); if (fileResult != null) { var genResult = await _thumbnailService.GenerateThumbnailAsync( fileResult.Stream, post.UploaderId, post.MimeType); // Dispose the source stream await fileResult.Stream.DisposeAsync(); if (genResult.Success) { // Update post with thumbnail path await _postService.UpdateThumbnailAsync(post.Id, genResult.FileIdentifier!, genResult.SourceWidth, genResult.SourceHeight); // Serve the newly generated thumbnail var newThumbnail = await _thumbnailService.GetThumbnailAsync(genResult.FileIdentifier!); if (newThumbnail != null) { Response.SetInlineFileHeaders(thumbFileName); return File(newThumbnail.Stream, newThumbnail.Metadata.ContentType); } } else { _logger.LogWarning("Failed to generate thumbnail for post {PostId}: {Error}", id, genResult.ErrorMessage); } } } // Return fallback image Response.SetInlineFileHeaders(thumbFileName); return PhysicalFile( Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "nothumb.png"), "image/png"); } catch (Exception ex) { _logger.LogError(ex, "Error serving thumbnail for post {PostId}", id); return StatusCode(500, new { error = "Failed to serve thumbnail" }); } } /// /// Upload a new post /// [HttpPost] [Authorize(Policy = Permissions.User.UploadPost)] public async Task UploadPost( [FromForm] IFormFile file, [FromForm] string? tags, [FromForm] string? rating, [FromForm] string? category, [FromForm] string? source, [FromForm] string? description) { try { var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } // Parse and validate tags (minimum 5 required) 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" }); } // Parse rating (default to Safe) var postRating = ParseRating(rating); // Parse category (default to Gallery) var postCategory = ParseCategory(category); // Validate tags based on category var tagError = _tagService.ValidateTagsForCategory(tagList, postCategory); if (tagError != null) { return BadRequest(new { error = tagError }); } // Validate file 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" }); } // Detect MIME type from file content (don't trust client-provided MIME) 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" }); } // Check if user has auto-approve permission var autoApprove = User.HasPermission(Permissions.User.AutoApprove); // Create the post tempStream.Position = 0; var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); var result = await _postService.CreatePostAsync(tempStream, file.FileName, detectedMime, userId.Value, postRating, postCategory, source, description, autoApprove, userIp); if (!result.Success) { return BadRequest(new { error = result.Error ?? "Failed to create post" }); } var post = result.Post!; // Apply tags await _tagService.UpdatePostTagsAsync(post, tagList); // Record initial tag history await _postHistoryService.RecordTagChangeAsync(post.Id, userId.Value, userIp, tagList); // Record initial source history if (!string.IsNullOrWhiteSpace(source)) { await _postHistoryService.RecordSourceChangeAsync(post.Id, userId.Value, userIp, source); } await _postCacheInvalidationService.PurgePostMutationAsync(post.Id); _logger.LogInformation("User {UserId} uploaded post {PostId} with rating {Rating}", userId, post.Id, postRating); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = post.Id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; var postDto = post.ToDto(); return CreatedAtAction(nameof(GetPostById), new { id = post.Id }, postDto); } catch (Exception ex) { _logger.LogError(ex, "Error uploading post"); return StatusCode(500, new { error = "Failed to upload post" }); } } 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 }; } /// /// Delete a post (owner or moderator) /// [HttpDelete("{id:int}")] [Authorize(Policy = Permissions.User.DeleteOwnContent)] public async Task DeletePost(int id, [FromBody] DeleteContentRequest? request = null) { try { if (string.IsNullOrWhiteSpace(request?.Reason)) { return BadRequest(new { error = "Reason is required" }); } var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } if (!await IsPostVisibleAsync(id)) { return NotFound(new { error = "Post not found" }); } var post = await _postService.GetPostByIdAsync(id); if (post == null) { return NotFound(new { error = "Post not found" }); } // Check if user is owner var isOwner = post.Uploader.Id == userId.Value; if (!isOwner) { _logger.LogWarning("User {UserId} attempted to delete post {PostId} without permission", userId, id); return Forbid(); } var trashed = await _postService.TrashPostAsync(id, userId.Value, request.Reason); if (!trashed) { return StatusCode(500, new { error = "Failed to trash post" }); } _logger.LogInformation("User {UserId} trashed post {PostId}", userId, id); await _postCacheInvalidationService.PurgePostMutationAsync(id); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; return NoContent(); } catch (Exception ex) { _logger.LogError(ex, "Error deleting post {PostId}", id); return StatusCode(500, new { error = "Failed to delete post" }); } } /// /// Update post tags (owner or moderator) /// [HttpPut("{id:int}/tags")] [Authorize(Policy = Permissions.User.EditOwnContent)] public async Task UpdatePostTags(int id, [FromBody] UpdateTagsRequest request) { try { var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } if (!await IsPostVisibleAsync(id)) { return NotFound(new { error = "Post not found" }); } var post = await _postService.GetPostByIdAsync(id); if (post == null) { return NotFound(new { error = "Post not found" }); } // Check if user is owner var isOwner = post.Uploader.Id == userId.Value; if (isOwner) { if (!User.HasPermission(Permissions.User.EditOwnContent)) { return Forbid(); } } else { if (!User.HasPermission(Permissions.User.EditTags)) { return Forbid(); } } if (request.Tags == null || !request.Tags.Any()) { return BadRequest(new { error = "Tags list cannot be empty" }); } var targetCategory = post.Category; if (!string.IsNullOrEmpty(request.Category)) { targetCategory = ParseCategory(request.Category); // Check if user has permission to change category too if (isOwner) { if (!User.HasPermission(Permissions.User.SetCategory)) return Forbid(); } else { if (!User.HasPermission(Permissions.Moderation.SetCategory)) return Forbid(); } } // 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 category if provided if (!string.IsNullOrEmpty(request.Category)) { await _postService.UpdateCategoryAsync(id, targetCategory); } // Update tags using the service await _tagService.UpdatePostTagsAsync(post, request.Tags); // Reload post with updated tags to get the full entity with navigation properties post = await _postService.GetPostByIdAsync(id); // Only record tag history if tags actually changed var newTagNames = post.PostTags .Select(pt => pt.Tag.Category != null ? $"{pt.Tag.Category.Slug}:{pt.Tag.Name}" : pt.Tag.Name) .OrderBy(t => t) .ToList(); if (!oldTagNames.SequenceEqual(newTagNames)) { var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); await _postHistoryService.RecordTagChangeAsync(id, userId.Value, userIp, newTagNames); } _logger.LogInformation("User {UserId} updated tags for post {PostId}", userId, id); await _postCacheInvalidationService.PurgePostMutationAsync(id); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; var postDto = post.ToDto(); return Ok(postDto); } catch (Exception ex) { _logger.LogError(ex, "Error updating tags for post {PostId}", id); return StatusCode(500, new { error = "Failed to update tags" }); } } /// /// Update post rating (owner or moderator) /// [HttpPut("{id:int}/rating")] [Authorize] public async Task UpdatePostRating(int id, [FromBody] UpdateRatingRequest request) { try { var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } if (!await IsPostVisibleAsync(id)) { return NotFound(new { error = "Post not found" }); } var post = await _postService.GetPostByIdAsync(id); if (post == null) { return NotFound(new { error = "Post not found" }); } // Check permissions var isOwner = post.Uploader.Id == userId.Value; if (isOwner) { if (!User.HasPermission(Permissions.User.SetRating)) { _logger.LogWarning("User {UserId} attempted to update rating for own post {PostId} but lacks {Permission} permission", userId, id, Permissions.User.SetRating); return Forbid(); } } else { if (!User.HasPermission(Permissions.Moderation.SetRating)) { _logger.LogWarning("User {UserId} attempted to update rating for post {PostId} but lacks {Permission} permission", userId, id, Permissions.Moderation.SetRating); return Forbid(); } } var newRating = ParseRating(request.Rating); var updatedPost = await _postService.UpdateRatingAsync(id, newRating); if (updatedPost == null) { return NotFound(new { error = "Post not found" }); } _logger.LogInformation("User {UserId} updated rating for post {PostId} to {Rating}", userId, id, newRating); await _postCacheInvalidationService.PurgePostMutationAsync(id); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; return Ok(updatedPost.ToDto()); } catch (Exception ex) { _logger.LogError(ex, "Error updating rating for post {PostId}", id); return StatusCode(500, new { error = "Failed to update rating" }); } } /// /// Update post category (owner or moderator) /// [HttpPut("{id:int}/category")] [Authorize] public async Task UpdatePostCategory(int id, [FromBody] UpdateCategoryRequest request) { try { var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } if (!await IsPostVisibleAsync(id)) { return NotFound(new { error = "Post not found" }); } var post = await _postService.GetPostByIdAsync(id); if (post == null) { return NotFound(new { error = "Post not found" }); } // Check permissions var isOwner = post.Uploader.Id == userId.Value; if (isOwner) { if (!User.HasPermission(Permissions.User.SetCategory)) { _logger.LogWarning("User {UserId} attempted to update category for own post {PostId} but lacks {Permission} permission", userId, id, Permissions.User.SetCategory); return Forbid(); } } else { if (!User.HasPermission(Permissions.Moderation.SetCategory)) { _logger.LogWarning("User {UserId} attempted to update category for post {PostId} but lacks {Permission} permission", userId, id, Permissions.Moderation.SetCategory); return Forbid(); } } var newCategory = ParseCategory(request.Category); // Update tags if provided if (request.Tags != null && request.Tags.Any()) { // Check if user has permission to change tags too if (isOwner) { if (!User.HasPermission(Permissions.User.EditOwnContent)) return Forbid(); } else { if (!User.HasPermission(Permissions.Moderation.EditTags)) return Forbid(); } // Capture current tag names for history var oldTagNames = post.PostTags .Select(pt => pt.Tag.Category != null ? $"{pt.Tag.Category.Slug}:{pt.Tag.Name}" : pt.Tag.Name) .OrderBy(t => t) .ToList(); await _tagService.UpdatePostTagsAsync(post, request.Tags); // Record tag history var newTagNames = request.Tags.OrderBy(t => t).ToList(); if (!oldTagNames.SequenceEqual(newTagNames)) { var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); await _postHistoryService.RecordTagChangeAsync(id, userId.Value, userIp, newTagNames); } } var updatedPost = await _postService.UpdateCategoryAsync(id, newCategory); if (updatedPost == null) { return NotFound(new { error = "Post not found" }); } _logger.LogInformation("User {UserId} updated category for post {PostId} to {Category}", userId, id, newCategory); await _postCacheInvalidationService.PurgePostMutationAsync(id); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; return Ok(updatedPost.ToDto()); } catch (Exception ex) { _logger.LogError(ex, "Error updating category for post {PostId}", id); return StatusCode(500, new { error = "Failed to update category" }); } } /// /// Update post source (owner or moderator) /// [HttpPut("{id:int}/source")] [Authorize] public async Task UpdatePostSource(int id, [FromBody] UpdateSourceRequest request) { try { var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } if (!await IsPostVisibleAsync(id)) { return NotFound(new { error = "Post not found" }); } var post = await _postService.GetPostByIdAsync(id); if (post == null) { return NotFound(new { error = "Post not found" }); } // Check permissions var isOwner = post.Uploader.Id == userId.Value; if (isOwner) { if (!User.HasPermission(Permissions.User.SetSource)) { _logger.LogWarning("User {UserId} attempted to update source for own post {PostId} but lacks {Permission} permission", userId, id, Permissions.User.SetSource); return Forbid(); } } else { if (!User.HasPermission(Permissions.Moderation.SetSource)) { _logger.LogWarning("User {UserId} attempted to update source for post {PostId} but lacks {Permission} permission", userId, id, Permissions.Moderation.SetSource); return Forbid(); } } // Skip update if source hasn't changed if (post.Source == request.Source) { return Ok(post.ToDto()); } var updatedPost = await _postService.UpdateSourceAsync(id, request.Source); if (updatedPost == null) { return NotFound(new { error = "Post not found" }); } // Record source history var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); await _postHistoryService.RecordSourceChangeAsync(id, userId.Value, userIp, request.Source); _logger.LogInformation("User {UserId} updated source for post {PostId}", userId, id); await _postCacheInvalidationService.PurgePostMutationAsync(id); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; return Ok(updatedPost.ToDto()); } catch (Exception ex) { _logger.LogError(ex, "Error updating source for post {PostId}", id); return StatusCode(500, new { error = "Failed to update source" }); } } /// /// Update post description (owner or moderator) /// [HttpPut("{id:int}/description")] [Authorize] public async Task UpdatePostDescription(int id, [FromBody] UpdateDescriptionRequest request) { try { var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } if (!await IsPostVisibleAsync(id)) { return NotFound(new { error = "Post not found" }); } var post = await _postService.GetPostByIdAsync(id); if (post == null) { return NotFound(new { error = "Post not found" }); } // Check permissions var isOwner = post.Uploader.Id == userId.Value; if (isOwner) { if (!User.HasPermission(Permissions.User.EditDescription)) { return Forbid(); } } else { if (!User.HasPermission(Permissions.Moderation.EditDescription)) { return Forbid(); } } // Skip update if description hasn't changed if (post.Description == request.Description) { return Ok(post.ToDto()); } var updatedPost = await _postService.UpdateDescriptionAsync(id, request.Description); if (updatedPost == null) { return NotFound(new { error = "Post not found" }); } _logger.LogInformation("User {UserId} updated description for post {PostId}", userId, id); await _postCacheInvalidationService.PurgePostMutationAsync(id); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = id.ToString(); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; return Ok(updatedPost.ToDto()); } catch (Exception ex) { _logger.LogError(ex, "Error updating description for post {PostId}", id); return StatusCode(500, new { error = "Failed to update description" }); } } /// /// Upload multiple posts at once with common and individual tags /// [HttpPost("batch")] [Authorize(Policy = Permissions.User.UploadPost)] public async Task BatchUpload( [FromForm] List files, [FromForm] string? commonTags, [FromForm] string? fileTags, [FromForm] string? rating, [FromForm] string? fileRatings, [FromForm] string? category, [FromForm] string? fileCategories, [FromForm] string? source, [FromForm] string? fileSources, [FromForm] string? description, [FromForm] string? fileDescriptions, [FromForm] bool uploadAnonymously = false) { try { var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } if (files == null || files.Count == 0) { return BadRequest(new { error = "No files provided" }); } // If uploading anonymously, we need the system account ID Guid? targetUserId = userId; bool autoApprove = User.HasPermission(Permissions.User.AutoApprove); if (uploadAnonymously) { if (!User.HasPermission(Permissions.User.UploadAnonymously)) { return Forbid(); } var chud = await _postService.GetAnonymousUserAsync(); if (chud == null) { _logger.LogError("Anonymous user 'Chud' not found"); return StatusCode(500, new { error = "Anonymous upload failed: System account not found" }); } targetUserId = chud.Id; autoApprove = false; // Anonymous uploads always require approval HttpContext.Items[AuditLog.ActionKey] = "Post.BatchUpload (Anonymous)"; } // Parse default rating (applies to files without specific rating) var defaultRating = ParseRating(rating); // Parse default category var defaultCategory = ParseCategory(category); // Parse file-specific ratings (JSON format: {"0": "safe", "1": "explicit"}) Dictionary? fileRatingsDict = null; if (!string.IsNullOrWhiteSpace(fileRatings)) { try { var parsed = System.Text.Json.JsonSerializer.Deserialize>(fileRatings); if (parsed != null) { fileRatingsDict = parsed .Where(kvp => int.TryParse(kvp.Key, out _)) .ToDictionary(kvp => int.Parse(kvp.Key), kvp => ParseRating(kvp.Value)); } } catch (System.Text.Json.JsonException) { return BadRequest(new { error = "Invalid fileRatings format. Expected JSON object with numeric keys." }); } } // Parse file-specific categories (JSON format: {"0": "gallery", "1": "artworks"}) Dictionary? fileCategoriesDict = null; if (!string.IsNullOrWhiteSpace(fileCategories)) { try { var parsed = System.Text.Json.JsonSerializer.Deserialize>(fileCategories); if (parsed != null) { fileCategoriesDict = parsed .Where(kvp => int.TryParse(kvp.Key, out _)) .ToDictionary(kvp => int.Parse(kvp.Key), kvp => ParseCategory(kvp.Value)); } } catch (System.Text.Json.JsonException) { return BadRequest(new { error = "Invalid fileCategories format. Expected JSON object with numeric keys." }); } } // Parse file-specific tags (JSON format: {"0": "tag1,tag2", "1": "tag3"}) Dictionary? fileTagsDict = null; if (!string.IsNullOrWhiteSpace(fileTags)) { try { var parsed = System.Text.Json.JsonSerializer.Deserialize>(fileTags); if (parsed != null) { fileTagsDict = parsed .Where(kvp => int.TryParse(kvp.Key, out _)) .ToDictionary(kvp => int.Parse(kvp.Key), kvp => kvp.Value); } } catch (System.Text.Json.JsonException) { return BadRequest(new { error = "Invalid fileTags format. Expected JSON object with numeric keys." }); } } // Parse file-specific sources (JSON format: {"0": "http://example.com", "1": "http://other.com"}) Dictionary? fileSourcesDict = null; if (!string.IsNullOrWhiteSpace(fileSources)) { try { var parsed = System.Text.Json.JsonSerializer.Deserialize>(fileSources); if (parsed != null) { fileSourcesDict = parsed .Where(kvp => int.TryParse(kvp.Key, out _)) .ToDictionary(kvp => int.Parse(kvp.Key), kvp => kvp.Value); } } catch (System.Text.Json.JsonException) { return BadRequest(new { error = "Invalid fileSources format. Expected JSON object with numeric keys." }); } } // Parse file-specific descriptions (JSON format: {"0": "desc 1", "1": "desc 2"}) Dictionary? fileDescriptionsDict = null; if (!string.IsNullOrWhiteSpace(fileDescriptions)) { try { var parsed = System.Text.Json.JsonSerializer.Deserialize>(fileDescriptions); if (parsed != null) { fileDescriptionsDict = parsed .Where(kvp => int.TryParse(kvp.Key, out _)) .ToDictionary(kvp => int.Parse(kvp.Key), kvp => kvp.Value); } } catch (System.Text.Json.JsonException) { return BadRequest(new { error = "Invalid fileDescriptions format. Expected JSON object with numeric keys." }); } } // Parse common tags var commonTagList = string.IsNullOrWhiteSpace(commonTags) ? new List() : commonTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); // Combine common tags with file-specific tags for validation for (int i = 0; i < files.Count; i++) { var allFileTags = new HashSet(commonTagList, StringComparer.OrdinalIgnoreCase); if (fileTagsDict != null && fileTagsDict.TryGetValue(i, out var specificTags)) { foreach (var tag in specificTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) allFileTags.Add(tag); } if (allFileTags.Count < MinimumTagCount) { return BadRequest(new { error = $"File \"{files[i].FileName}\" has {allFileTags.Count} tag(s), but at least {MinimumTagCount} are required" }); } // Determine category for this file var fileCategory = fileCategoriesDict != null && fileCategoriesDict.TryGetValue(i, out var specificCategory) ? specificCategory : defaultCategory; var tagError = _tagService.ValidateTagsForCategory(allFileTags, fileCategory); if (tagError != null) { return BadRequest(new { error = $"File \"{files[i].FileName}\": {tagError}" }); } } var response = new BatchUploadResponse { TotalFiles = files.Count, Results = new List() }; var createdPostIds = new List(); for (int i = 0; i < files.Count; i++) { var file = files[i]; var result = new BatchUploadResultItem { Index = i, FileName = file.FileName }; try { // Validate file if (file.Length == 0) { result.Success = false; result.Error = "Empty file"; response.Results.Add(result); continue; } if (file.Length > _maxFileSize) { result.Success = false; result.Error = $"File size exceeds maximum allowed size of {_maxFileSize / (1024 * 1024)}MB"; response.Results.Add(result); continue; } // Detect MIME type using var tempStream = new MemoryStream(); await file.CopyToAsync(tempStream); tempStream.Position = 0; var detectedMime = Utilities.MIME.DetectMIME(tempStream, file.FileName); if (!_allowedMimeTypes.Contains(detectedMime)) { result.Success = false; result.Error = $"File type {detectedMime} is not allowed"; response.Results.Add(result); continue; } // Determine rating for this file (file-specific or default) var fileRating = fileRatingsDict != null && fileRatingsDict.TryGetValue(i, out var specificRating) ? specificRating : defaultRating; // Determine category for this file var fileCategory = fileCategoriesDict != null && fileCategoriesDict.TryGetValue(i, out var specificCategory) ? specificCategory : defaultCategory; // Determine source for this file (file-specific or common) var fileSource = fileSourcesDict != null && fileSourcesDict.TryGetValue(i, out var specificSource) ? specificSource : source; // Determine description for this file (file-specific or common) var fileDescription = fileDescriptionsDict != null && fileDescriptionsDict.TryGetValue(i, out var specificDescription) ? specificDescription : description; // Create the post tempStream.Position = 0; var batchUserIp = HttpContext.Connection.RemoteIpAddress?.ToString(); var createResult = await _postService.CreatePostAsync(tempStream, file.FileName, detectedMime, targetUserId!.Value, fileRating, fileCategory, fileSource, fileDescription, autoApprove, batchUserIp); if (!createResult.Success) { result.Success = false; result.Error = createResult.Error ?? "Upload failed"; response.Results.Add(result); continue; } var post = createResult.Post!; // Combine common tags with file-specific tags var allTags = new List(commonTagList); if (fileTagsDict != null && fileTagsDict.TryGetValue(i, out var specificTags)) { var specificTagList = specificTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); allTags.AddRange(specificTagList); } // Apply tags if any if (allTags.Count > 0) { var distinctTags = allTags.Distinct().ToList(); await _tagService.UpdatePostTagsAsync(post, distinctTags); // Record initial tag history await _postHistoryService.RecordTagChangeAsync(post.Id, targetUserId.Value, batchUserIp, distinctTags); } // Record initial source history if (!string.IsNullOrWhiteSpace(fileSource)) { await _postHistoryService.RecordSourceChangeAsync(post.Id, targetUserId.Value, batchUserIp, fileSource); } result.Success = true; result.Post = post.ToDto(); createdPostIds.Add(post.Id); _logger.LogInformation("User {UserId} uploaded post {PostId} in batch with rating {Rating} (as {TargetUserId})", userId, post.Id, fileRating, targetUserId); } catch (Exception ex) { _logger.LogError(ex, "Error uploading file {FileName} in batch", file.FileName); result.Success = false; result.Error = "Upload failed"; } response.Results.Add(result); } response.SuccessCount = response.Results.Count(r => r.Success); response.FailureCount = response.Results.Count(r => !r.Success); if (createdPostIds.Count > 0) { await _postCacheInvalidationService.PurgePostMutationsAsync(createdPostIds); } _logger.LogInformation("User {UserId} completed batch upload: {Success}/{Total} succeeded", userId, response.SuccessCount, response.TotalFiles); // Set target for audit log (all successfully uploaded post IDs) var successIds = response.Results.Where(r => r.Success).Select(r => r.Post?.Id.ToString()); HttpContext.Items[AuditLog.TargetIdKey] = string.Join("\n", successIds); HttpContext.Items[AuditLog.TargetTypeKey] = "Post"; return Ok(response); } catch (Exception ex) { _logger.LogError(ex, "Error during batch upload"); return StatusCode(500, new { error = "Failed to process batch upload" }); } } } /// /// Request model for updating post tags /// public class UpdateTagsRequest { public List Tags { get; set; } = new(); public string? Category { get; set; } } /// /// Request model for updating post rating /// public class UpdateRatingRequest { public string Rating { get; set; } = "safe"; } /// /// Request model for updating post category /// public class UpdateCategoryRequest { public string Category { get; set; } = "gallery"; public List? Tags { get; set; } } /// /// Request model for updating post source /// public class UpdateSourceRequest { [MaxLength(2000)] public string? Source { get; set; } } /// /// Request model for updating post description /// public class UpdateDescriptionRequest { [MaxLength(5000)] public string? Description { get; set; } } /// /// Request model for batch upload /// public class BatchUploadRequest { /// /// Common tags applied to all posts (comma-separated) /// public string? CommonTags { get; set; } /// /// Individual tags per file index (comma-separated), keyed by file index /// Format: "0" -> "tag1,tag2", "1" -> "tag3,tag4" /// public Dictionary? FileTags { get; set; } } /// /// Result for a single file in batch upload /// public class BatchUploadResultItem { public int Index { get; set; } public string FileName { get; set; } = string.Empty; public bool Success { get; set; } public string? Error { get; set; } public PostDto? Post { get; set; } } /// /// Response for batch upload /// public class BatchUploadResponse { public int TotalFiles { get; set; } public int SuccessCount { get; set; } public int FailureCount { get; set; } public List Results { get; set; } = new(); } }