using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.Models.Forum; using Nuuru.Server.Services.Storage; using SixLabors.ImageSharp; namespace Nuuru.Server.Services { public interface IForumAttachmentService { Task UploadAttachmentAsync(IFormFile file); Task GetAttachmentAsync(Guid attachmentId); Task> GetAttachmentsByPostAsync(int postId); Task AssociateAttachmentsAsync(int postId, List attachmentIds); Task DeleteAttachmentAsync(Guid attachmentId); Task DeleteAttachmentsForPostAsync(int postId); Task CleanupOrphanedAttachmentsAsync(TimeSpan maxAge); } public class ForumAttachmentService : IForumAttachmentService { private readonly ApplicationDbContext _context; private readonly IFileStorageService _fileStorageService; private readonly IThumbnailService _thumbnailService; private readonly ICloudflareCachePurgeService _cloudflareCachePurgeService; private readonly ICurrentUserContext _userContext; private readonly ILogger _logger; private readonly long _maxFileSize; private readonly int _maxAttachmentsPerPost; private static readonly HashSet AllowedImageTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/gif", "image/webp" }; private static readonly HashSet AllowedVideoTypes = new(StringComparer.OrdinalIgnoreCase) { "video/mp4", "video/webm" }; public ForumAttachmentService( ApplicationDbContext context, IFileStorageService fileStorageService, IThumbnailService thumbnailService, ICloudflareCachePurgeService cloudflareCachePurgeService, ICurrentUserContext userContext, IConfiguration configuration, ILogger logger) { _context = context; _fileStorageService = fileStorageService; _thumbnailService = thumbnailService; _cloudflareCachePurgeService = cloudflareCachePurgeService; _userContext = userContext; _logger = logger; _maxFileSize = configuration.GetValue("ForumAttachments:MaxFileSize", 10 * 1024 * 1024); // 10MB default _maxAttachmentsPerPost = configuration.GetValue("ForumAttachments:MaxPerPost", 10); } public async Task UploadAttachmentAsync(IFormFile file) { var uploaderId = _userContext.UserId; if (uploaderId == null) { _logger.LogWarning("Attachment upload rejected: no authenticated user"); return null; } // Validate file size if (file.Length > _maxFileSize) { _logger.LogWarning("Attachment upload rejected: file too large ({Size} bytes)", file.Length); return null; } // Validate content type using magic bytes, not client-supplied header string contentType; { await using var detectStream = file.OpenReadStream(); contentType = Utilities.MIME.DetectMIME(detectStream, file.FileName); } if (!AllowedImageTypes.Contains(contentType) && !AllowedVideoTypes.Contains(contentType)) { _logger.LogWarning("Attachment upload rejected: unsupported content type {ContentType}", contentType); return null; } try { // Save the file await using var stream = file.OpenReadStream(); var saveResult = await _fileStorageService.SaveFileAsync(stream, file.FileName, new FileStorageOptions { ContentType = contentType, UploaderId = uploaderId.Value, IsPublic = true, MaxFileSize = _maxFileSize }); if (!saveResult.Success || saveResult.FileIdentifier == null) { _logger.LogError("Failed to save attachment file: {Error}", saveResult.ErrorMessage); return null; } // Generate thumbnail for images/video string? thumbnailIdentifier = null; int? width = null; int? height = null; if (_thumbnailService.SupportsThumbnail(contentType)) { // Re-read stream for thumbnail generation await using var thumbStream = file.OpenReadStream(); var thumbResult = await _thumbnailService.GenerateThumbnailAsync(thumbStream, uploaderId.Value, contentType); if (thumbResult.Success) { thumbnailIdentifier = thumbResult.FileIdentifier; width = thumbResult.SourceWidth; height = thumbResult.SourceHeight; } } // If no thumbnail was generated, try to get dimensions from image directly if (width == null && AllowedImageTypes.Contains(contentType)) { try { await using var imgStream = file.OpenReadStream(); using var image = await Image.LoadAsync(imgStream); width = image.Width; height = image.Height; } catch { // Ignore errors getting dimensions } } // Create attachment record var attachment = new ForumPostAttachment { Id = Guid.NewGuid(), UploaderId = uploaderId.Value, FileIdentifier = saveResult.FileIdentifier, OriginalFileName = file.FileName, ContentType = contentType, FileSize = file.Length, Width = width, Height = height, ThumbnailIdentifier = thumbnailIdentifier, CreatedAt = DateTime.UtcNow }; _context.ForumPostAttachments.Add(attachment); await _context.SaveChangesAsync(); _logger.LogInformation("Attachment {AttachmentId} uploaded by user {UserId}: {FileName} ({Size} bytes)", attachment.Id, uploaderId, file.FileName, file.Length); return attachment; } catch (Exception ex) { _logger.LogError(ex, "Error uploading attachment"); return null; } } public async Task GetAttachmentAsync(Guid attachmentId) { return await _context.ForumPostAttachments .Include(a => a.ForumPost) .FirstOrDefaultAsync(a => a.Id == attachmentId); } public async Task> GetAttachmentsByPostAsync(int postId) { return await _context.ForumPostAttachments .Where(a => a.ForumPostId == postId) .OrderBy(a => a.CreatedAt) .ToListAsync(); } public async Task AssociateAttachmentsAsync(int postId, List attachmentIds) { var uploaderId = _userContext.UserId; if (uploaderId == null) { _logger.LogWarning("Cannot associate attachments: no authenticated user"); return false; } if (attachmentIds.Count == 0) return true; if (attachmentIds.Count > _maxAttachmentsPerPost) { _logger.LogWarning("Too many attachments for post {PostId}: {Count}", postId, attachmentIds.Count); return false; } // Verify post exists var postExists = await _context.ForumPosts.AnyAsync(p => p.Id == postId); if (!postExists) { _logger.LogWarning("Cannot associate attachments: post {PostId} not found", postId); return false; } // Get attachments that belong to this user and are unassociated var attachments = await _context.ForumPostAttachments .Where(a => attachmentIds.Contains(a.Id) && a.UploaderId == uploaderId.Value && a.ForumPostId == null) .ToListAsync(); if (attachments.Count != attachmentIds.Count) { _logger.LogWarning("Some attachments not found or not owned by user for post {PostId}", postId); return false; } // Associate attachments with the post foreach (var attachment in attachments) { attachment.ForumPostId = postId; } await _context.SaveChangesAsync(); _logger.LogInformation("Associated {Count} attachments with post {PostId}", attachments.Count, postId); return true; } public async Task DeleteAttachmentAsync(Guid attachmentId) { var attachment = await _context.ForumPostAttachments.FindAsync(attachmentId); if (attachment == null) return false; // Delete files from storage await DeleteAttachmentFilesAsync(attachment); // Remove from database _context.ForumPostAttachments.Remove(attachment); await _context.SaveChangesAsync(); await PurgeAttachmentCacheAsync(attachment.Id); _logger.LogInformation("Attachment {AttachmentId} deleted", attachmentId); return true; } public async Task DeleteAttachmentsForPostAsync(int postId) { var attachments = await _context.ForumPostAttachments .Where(a => a.ForumPostId == postId) .ToListAsync(); if (attachments.Count == 0) return; // Delete files from storage foreach (var attachment in attachments) { await DeleteAttachmentFilesAsync(attachment); } await PurgeAttachmentCachesAsync(attachments.Select(static a => a.Id)); _logger.LogInformation("Deleted {Count} attachment files for post {PostId}", attachments.Count, postId); } public async Task CleanupOrphanedAttachmentsAsync(TimeSpan maxAge) { var cutoff = DateTime.UtcNow - maxAge; // Find unassociated attachments older than the cutoff var orphaned = await _context.ForumPostAttachments .Where(a => a.ForumPostId == null && a.CreatedAt < cutoff) .ToListAsync(); if (orphaned.Count == 0) return 0; // Delete files from storage foreach (var attachment in orphaned) { await DeleteAttachmentFilesAsync(attachment); } // Remove from database _context.ForumPostAttachments.RemoveRange(orphaned); await _context.SaveChangesAsync(); await PurgeAttachmentCachesAsync(orphaned.Select(static a => a.Id)); _logger.LogInformation("Cleaned up {Count} orphaned attachments", orphaned.Count); return orphaned.Count; } private Task PurgeAttachmentCacheAsync(Guid attachmentId) { return PurgeAttachmentCachesAsync([attachmentId]); } private async Task PurgeAttachmentCachesAsync(IEnumerable attachmentIds) { var paths = attachmentIds .Distinct() .SelectMany(static attachmentId => new[] { $"/api/forum/attachments/{attachmentId}/file", $"/api/forum/attachments/{attachmentId}/thumb" }) .ToList(); if (paths.Count == 0) { return; } var purged = await _cloudflareCachePurgeService.PurgeUrisAsync(paths); if (!purged) { _logger.LogDebug("Cloudflare forum attachment cache purge skipped or failed for {Paths}", paths); } } private async Task DeleteAttachmentFilesAsync(ForumPostAttachment attachment) { try { await _fileStorageService.DeleteFileAsync(attachment.FileIdentifier); if (!string.IsNullOrEmpty(attachment.ThumbnailIdentifier)) { await _fileStorageService.DeleteFileAsync(attachment.ThumbnailIdentifier); } } catch (Exception ex) { _logger.LogError(ex, "Error deleting files for attachment {AttachmentId}", attachment.Id); } } } }