using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.Models; using Nuuru.Server.Models.Booru; using Nuuru.Server.Services.Search; using Nuuru.Server.Services.Storage; namespace Nuuru.Server.Services { public class CreatePostResult { public Post? Post { get; set; } public bool Success => Post != null; public string? Error { get; set; } public static CreatePostResult Succeeded(Post post) => new() { Post = post }; public static CreatePostResult Failed(string error) => new() { Error = error }; } public sealed class PostFileInfo { public int Id { get; init; } public string StorageIdentifier { get; init; } = string.Empty; public string? ThumbnailPath { get; init; } public string MimeType { get; init; } = string.Empty; public string OriginalFileName { get; init; } = string.Empty; public bool IsApproved { get; init; } public bool IsTrashed { get; init; } public Guid UploaderId { get; init; } public List TagNames { get; set; } = []; } public interface IPostService { Task CreatePostAsync(Stream fileStream, string fileName, string mimeType, Guid uploaderId, PostRating rating = PostRating.Safe, PostCategory category = PostCategory.Gallery, string? source = null, string? description = null, bool autoApprove = false, string? ipAddress = null); Task GetPostByIdAsync(int id); Task GetPostFileInfoByIdAsync(int id, bool includeTagNames = false); Task PostExistsByIdAsync(int id); Task DeletePostAsync(int id); Task PostExistsAsync(string imageHash); Task UpdateThumbnailAsync(int postId, string thumbnailIdentifier, int? width, int? height); Task UpdateRatingAsync(int id, PostRating rating); Task UpdateCategoryAsync(int id, PostCategory category); Task UpdateSourceAsync(int id, string? source); Task UpdateDescriptionAsync(int id, string? description); Task GetFeaturedPostAsync(); Task TrashPostAsync(int id, Guid trashedById, string? reason = null); Task RestorePostAsync(int id); Task<(IEnumerable Items, int TotalCount)> GetTrashedPostsAsync(int page, int pageSize); Task GetTrashedCountAsync(); Task GetAnonymousUserAsync(); } public class PostService : IPostService { private readonly ApplicationDbContext _context; private readonly ILogger _logger; private readonly IFileStorageService _fileStorageService; private readonly ITagService _tagService; private readonly IThumbnailService _thumbnailService; private readonly IWatchService _watchService; private readonly IUserSettingsService _settingsService; private readonly IDefaultQueryFilterService _defaultQueryFilterService; private readonly IBointsService _bointsService; public PostService( ApplicationDbContext context, ILogger logger, IFileStorageService fileStorageService, ITagService tagService, IThumbnailService thumbnailService, IWatchService watchService, IUserSettingsService settingsService, IDefaultQueryFilterService defaultQueryFilterService, IBointsService bointsService) { _context = context; _logger = logger; _fileStorageService = fileStorageService; _tagService = tagService; _thumbnailService = thumbnailService; _watchService = watchService; _bointsService = bointsService; _settingsService = settingsService; _defaultQueryFilterService = defaultQueryFilterService; } public async Task CreatePostAsync(Stream fileStream, string fileName, string mimeType, Guid uploaderId, PostRating rating = PostRating.Safe, PostCategory category = PostCategory.Gallery, string? source = null, string? description = null, bool autoApprove = false, string? ipAddress = null) { var user = await _context.Users.FindAsync(uploaderId); if (user == null) { _logger.LogWarning("User {UserId} not found when creating post", uploaderId); return CreatePostResult.Failed("Uploader account not found"); } // Pause uploads if too many posts pending review var pendingCount = await _context.BooruPosts.CountAsync(p => !p.IsApproved && !p.IsTrashed); var trashedCount = await _context.BooruPosts.CountAsync(p => p.IsTrashed); if (pendingCount >= 20 || trashedCount >= 20) { return CreatePostResult.Failed("Uploads are temporarily paused. Please try again later."); } // Save the file (hash is calculated during save) var storageResult = await _fileStorageService.SaveFileAsync(fileStream, fileName, new FileStorageOptions { UploaderId = uploaderId, ContentType = mimeType }); if (!storageResult.Success) { _logger.LogError("Failed to save file: {ErrorMessage}", storageResult.ErrorMessage); return CreatePostResult.Failed($"Failed to save file: {storageResult.ErrorMessage ?? "unknown storage error"}"); } // Check if a post with this hash already exists var hash = storageResult.Metadata!.Hash; if (await PostExistsAsync(hash)) { return CreatePostResult.Failed("A post with this file already exists"); } var post = new Post { StorageIdentifier = storageResult.FileIdentifier!, MimeType = storageResult.Metadata.ContentType, ImageHash = storageResult.Metadata.Hash, FileSize = storageResult.Metadata.FileSize, OriginalFileName = storageResult.Metadata.OriginalFileName, Source = source, Description = description, UploadedAt = DateTime.UtcNow, Rating = rating, Category = category, IsApproved = autoApprove, ApprovedById = autoApprove ? uploaderId : null, ApprovedAt = autoApprove ? DateTime.UtcNow : null, IpAddress = ipAddress, Uploader = user, PostTags = new List() }; // Generate thumbnail for supported image types if (_thumbnailService.SupportsThumbnail(mimeType)) { // Reset stream position for thumbnail generation if (fileStream.CanSeek) { fileStream.Position = 0; } var thumbnailResult = await _thumbnailService.GenerateThumbnailAsync(fileStream, uploaderId, mimeType); if (thumbnailResult.Success) { post.ThumbnailPath = thumbnailResult.FileIdentifier; post.Width = thumbnailResult.SourceWidth; post.Height = thumbnailResult.SourceHeight; post.DurationSeconds = thumbnailResult.DurationSeconds; } else { _logger.LogWarning("Failed to generate thumbnail: {Error}", thumbnailResult.ErrorMessage); } } _context.BooruPosts.Add(post); await _context.SaveChangesAsync(); // Auto-watch the post if preference is enabled var prefs = await _settingsService.GetAutoWatchPreferencesAsync(uploaderId); if (prefs.AutoWatchOwnPosts) { await _watchService.EnsureWatchingAsync(uploaderId, WatchTargetType.BooruPost, post.Id); } // Boints: 1 per upload, max once per hour await _bointsService.CreditAsync(uploaderId, BointsReason.Upload, 1, sourcePostId: post.Id); return CreatePostResult.Succeeded(post); } public async Task GetPostByIdAsync(int id) { return await _context.BooruPosts .Include(p => p.Uploader) .Include(p => p.ApprovedBy) .Include(p => p.PostTags) .ThenInclude(pt => pt.Tag) .ThenInclude(t => t.Category) .AsSplitQuery() .FirstOrDefaultAsync(p => p.Id == id); } public async Task GetPostFileInfoByIdAsync(int id, bool includeTagNames = false) { var info = await _context.BooruPosts .AsNoTracking() .Where(p => p.Id == id) .Select(p => new PostFileInfo { Id = p.Id, StorageIdentifier = p.StorageIdentifier, ThumbnailPath = p.ThumbnailPath, MimeType = p.MimeType, OriginalFileName = p.OriginalFileName ?? $"{p.Id}", IsApproved = p.IsApproved, IsTrashed = p.IsTrashed, UploaderId = p.UploaderId }) .FirstOrDefaultAsync(); if (info == null || !includeTagNames) { return info; } info.TagNames = await _context.Set() .AsNoTracking() .Where(pt => pt.PostId == id) .OrderBy(pt => pt.Tag.Name) .Select(pt => pt.Tag.Name) .ToListAsync(); return info; } public async Task GetFeaturedPostAsync() { // No default query filters — featured posts are manually curated by moderators return await _context.BooruPosts .Include(p => p.Uploader) .Include(p => p.PostTags) .ThenInclude(pt => pt.Tag) .ThenInclude(t => t.Category) .AsSplitQuery() .Where(p => p.IsFeatured && p.IsApproved && !p.IsTrashed) .OrderByDescending(p => p.FeaturedAt) .FirstOrDefaultAsync(); } public async Task PostExistsByIdAsync(int id) { return await _context.BooruPosts.AnyAsync(p => p.Id == id); } public async Task DeletePostAsync(int id) { var post = await _context.BooruPosts .Include(p => p.PostTags) .FirstOrDefaultAsync(p => p.Id == id); if (post == null) { return false; } // Store tag IDs before deletion for count updates var affectedTagIds = post.PostTags.Select(pt => pt.TagId).ToList(); // Delete the physical files var fileDeleted = await _fileStorageService.DeleteFileAsync(post.StorageIdentifier); if (!fileDeleted) { _logger.LogWarning("Failed to delete file for post {PostId} at path {StorageIdentifier}", id, post.StorageIdentifier); } if (!string.IsNullOrEmpty(post.ThumbnailPath)) { var thumbnailDeleted = await _fileStorageService.DeleteFileAsync(post.ThumbnailPath); if (!thumbnailDeleted) { _logger.LogWarning("Failed to delete thumbnail for post {PostId} at path {ThumbnailPath}", id, post.ThumbnailPath); } } _context.BooruPosts.Remove(post); await _context.SaveChangesAsync(); // Update tag counts (this will also delete orphaned tags) foreach (var tagId in affectedTagIds) { await _tagService.UpdatePostCountAsync(tagId); } return true; } public async Task PostExistsAsync(string imageHash) { return await _context.BooruPosts.AnyAsync(p => p.ImageHash == imageHash); } public async Task UpdateThumbnailAsync(int postId, string thumbnailIdentifier, int? width, int? height) { var post = await _context.BooruPosts.FindAsync(postId); if (post != null) { post.ThumbnailPath = thumbnailIdentifier; if (width.HasValue) post.Width = width; if (height.HasValue) post.Height = height; await _context.SaveChangesAsync(); } } public async Task UpdateRatingAsync(int postId, PostRating rating) { var post = await _context.BooruPosts .FirstOrDefaultAsync(p => p.Id == postId); if (post == null) { return null; } post.Rating = rating; await _context.SaveChangesAsync(); return post; } public async Task UpdateCategoryAsync(int postId, PostCategory category) { var post = await _context.BooruPosts .FirstOrDefaultAsync(p => p.Id == postId); if (post == null) { return null; } post.Category = category; await _context.SaveChangesAsync(); return post; } public async Task UpdateSourceAsync(int postId, string? source) { var post = await _context.BooruPosts .FirstOrDefaultAsync(p => p.Id == postId); if (post == null) { return null; } post.Source = source; await _context.SaveChangesAsync(); return post; } public async Task UpdateDescriptionAsync(int postId, string? description) { var post = await _context.BooruPosts .FirstOrDefaultAsync(p => p.Id == postId); if (post == null) { return null; } post.Description = description; await _context.SaveChangesAsync(); return post; } public async Task TrashPostAsync(int id, Guid trashedById, string? reason = null) { var post = await _context.BooruPosts .Include(p => p.PostTags) .FirstOrDefaultAsync(p => p.Id == id); if (post == null) return false; post.IsTrashed = true; post.TrashedAt = DateTime.UtcNow; post.TrashedById = trashedById; post.TrashReason = reason; await _context.SaveChangesAsync(); // Revoke boints earned from this post (only if trashed by mod, not self) if (trashedById != post.UploaderId) { await _bointsService.RevokePostCreditsAsync(post.Id); } // Decrement tag counts since trashed posts shouldn't count var affectedTagIds = post.PostTags.Select(pt => pt.TagId).ToList(); foreach (var tagId in affectedTagIds) { await _tagService.UpdatePostCountAsync(tagId); } _logger.LogInformation("Post {PostId} moved to trash by user {UserId}", id, trashedById); return true; } public async Task RestorePostAsync(int id) { var post = await _context.BooruPosts .Include(p => p.PostTags) .FirstOrDefaultAsync(p => p.Id == id); if (post == null) return false; post.IsTrashed = false; post.TrashedAt = null; post.TrashedById = null; post.TrashReason = null; await _context.SaveChangesAsync(); // Re-increment tag counts var affectedTagIds = post.PostTags.Select(pt => pt.TagId).ToList(); foreach (var tagId in affectedTagIds) { await _tagService.UpdatePostCountAsync(tagId); } _logger.LogInformation("Post {PostId} restored from trash", id); return true; } public async Task<(IEnumerable Items, int TotalCount)> GetTrashedPostsAsync(int page, int pageSize) { var query = _context.BooruPosts .AsNoTracking() .Where(p => p.IsTrashed) .OrderByDescending(p => p.TrashedAt) .Include(p => p.Uploader) .Include(p => p.PostTags) .ThenInclude(pt => pt.Tag) .ThenInclude(t => t.Category) .AsSplitQuery(); var totalCount = await query.CountAsync(); var items = await query .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return (items, totalCount); } public async Task GetTrashedCountAsync() { return await _context.BooruPosts.CountAsync(p => p.IsTrashed); } public async Task GetAnonymousUserAsync() { return await _context.Users .FirstOrDefaultAsync(u => u.UserName == "Chud" && u.IsSystemAccount); } } }