using SkiaSharp; using System.Collections.Concurrent; using PlotLine.Data; using PlotLine.Models; namespace PlotLine.Services; public interface IBookCoverService { Task UploadCoverAsync(Book book, IFormFile upload); Task DeleteCoverFilesAsync(Book book); } public sealed record BookCoverUploadResult(bool Succeeded, string? ErrorMessage = null); public sealed class BookCoverService( IWebHostEnvironment environment, IProjectCollaborationRepository collaboration, ISubscriptionService subscriptions, IUserFileRepository userFiles, IBookRepository books, ILogger logger) : IBookCoverService { public const string PlaceholderPath = "/images/placeholders/book-cover.svg"; private const long MaxUploadBytes = 10L * 1024L * 1024L; private const string ThumbnailFileName = "cover-thumb.webp"; private const string StandardFileName = "cover-standard.webp"; private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" }; private static readonly ConcurrentDictionary BookLocks = new(); public async Task UploadCoverAsync(Book book, IFormFile upload) { var uploadLock = BookLocks.GetOrAdd(book.BookID, _ => new SemaphoreSlim(1, 1)); await uploadLock.WaitAsync(); try { return await UploadCoverCoreAsync(book, upload); } finally { uploadLock.Release(); } } private async Task UploadCoverCoreAsync(Book book, IFormFile upload) { if (upload.Length <= 0) { return new(false, "Choose a cover image to upload."); } if (upload.Length > MaxUploadBytes) { return new(false, "Cover image must be 10 MB or smaller."); } if (!SupportedExtensions.Contains(Path.GetExtension(upload.FileName))) { return new(false, "Cover image must be a JPG, PNG or WebP file."); } var ownerUserId = await collaboration.GetOwnerUserIdAsync(book.ProjectID); if (!ownerUserId.HasValue) { return new(false, "Book owner could not be found."); } var storageLimit = await subscriptions.CanUploadFileAsync(ownerUserId.Value, EstimatedGeneratedSize(upload.Length)); if (!storageLimit.Allowed) { return new(false, storageLimit.Message); } SKBitmap bitmap; try { await using var uploadStream = upload.OpenReadStream(); using var memory = new MemoryStream(); await uploadStream.CopyToAsync(memory); memory.Position = 0; using var codec = SKCodec.Create(memory) ?? throw new InvalidDataException(); if (codec.EncodedFormat is not (SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Png or SKEncodedImageFormat.Webp)) { return new(false, "Cover image must be a valid JPG, PNG or WebP file."); } memory.Position = 0; bitmap = SKBitmap.Decode(memory) ?? throw new InvalidDataException(); } catch (Exception ex) when (ex is InvalidDataException or ArgumentException) { return new(false, "Cover image must be a valid JPG, PNG or WebP file."); } using (bitmap) { if (bitmap.Width <= 0 || bitmap.Height <= 0) { return new(false, "Cover image could not be read as a valid image."); } var uploadRoot = Path.Combine(environment.WebRootPath, "uploads", "books", book.BookID.ToString()); Directory.CreateDirectory(uploadRoot); var version = Guid.NewGuid().ToString("N"); var thumbnailTemp = Path.Combine(uploadRoot, $"cover-thumb-{version}.tmp"); var standardTemp = Path.Combine(uploadRoot, $"cover-standard-{version}.tmp"); var thumbnailPath = Path.Combine(uploadRoot, ThumbnailFileName); var standardPath = Path.Combine(uploadRoot, StandardFileName); var thumbnailStoragePath = $"/uploads/books/{book.BookID}/{ThumbnailFileName}"; var standardStoragePath = $"/uploads/books/{book.BookID}/{StandardFileName}"; try { SaveWebpVariant(bitmap, thumbnailTemp, 400, 600, 72); SaveWebpVariant(bitmap, standardTemp, 600, 900, 78); ReplaceFile(thumbnailTemp, thumbnailPath); ReplaceFile(standardTemp, standardPath); var oldThumbnailPath = book.CoverThumbnailPath; var oldStandardPath = book.CoverStandardPath; await books.UpdateCoverAsync(book.BookID, thumbnailStoragePath, standardStoragePath); await MarkPreviousLedgerRowsAsync(oldThumbnailPath); await MarkPreviousLedgerRowsAsync(oldStandardPath); await TrackCoverAsync(ownerUserId.Value, book.ProjectID, book.BookID, thumbnailStoragePath, "Book cover thumbnail", new FileInfo(thumbnailPath).Length); await TrackCoverAsync(ownerUserId.Value, book.ProjectID, book.BookID, standardStoragePath, "Book cover standard", new FileInfo(standardPath).Length); DeletePreviousPhysicalFile(oldThumbnailPath, thumbnailStoragePath); DeletePreviousPhysicalFile(oldStandardPath, standardStoragePath); return new(true); } catch (Exception ex) { TryDeleteFile(thumbnailTemp); TryDeleteFile(standardTemp); logger.LogWarning(ex, "Book cover upload failed for book {BookID}.", book.BookID); return new(false, "Cover image could not be processed. Try another image."); } } } public async Task DeleteCoverFilesAsync(Book book) { await MarkAndDeletePreviousAsync(book.CoverThumbnailPath, null); await MarkAndDeletePreviousAsync(book.CoverStandardPath, null); } private static void SaveWebpVariant(SKBitmap source, string path, int width, int height, int quality) { using var surface = SKSurface.Create(new SKImageInfo(width, height)); var canvas = surface.Canvas; canvas.Clear(SKColors.Transparent); var sourceRatio = source.Width / (float)source.Height; var targetRatio = width / (float)height; SKRect sourceRect; if (sourceRatio > targetRatio) { var cropWidth = source.Height * targetRatio; var left = (source.Width - cropWidth) / 2f; sourceRect = new SKRect(left, 0, left + cropWidth, source.Height); } else { var cropHeight = source.Width / targetRatio; var top = (source.Height - cropHeight) / 2f; sourceRect = new SKRect(0, top, source.Width, top + cropHeight); } using var paint = new SKPaint { IsAntialias = true }; using var sourceImage = SKImage.FromBitmap(source); canvas.DrawImage(sourceImage, sourceRect, new SKRect(0, 0, width, height), new SKSamplingOptions(SKCubicResampler.Mitchell), paint); canvas.Flush(); using var image = surface.Snapshot(); using var data = image.Encode(SKEncodedImageFormat.Webp, quality); using var output = File.Create(path); data.SaveTo(output); } private async Task TrackCoverAsync(int ownerUserId, int projectId, int bookId, string storagePath, string originalFileName, long fileSizeBytes) { await userFiles.CreateAsync(new UserFile { UserID = ownerUserId, ProjectID = projectId, EntityType = "BookCover", EntityID = bookId, FileName = Path.GetFileName(storagePath), OriginalFileName = originalFileName, ContentType = "image/webp", FileSizeBytes = fileSizeBytes, StoragePath = storagePath }); } private async Task MarkPreviousLedgerRowsAsync(string? oldStoragePath) { if (string.IsNullOrWhiteSpace(oldStoragePath) || string.Equals(oldStoragePath, PlaceholderPath, StringComparison.OrdinalIgnoreCase)) { return; } await userFiles.MarkDeletedByStoragePathAsync(oldStoragePath); } private async Task MarkAndDeletePreviousAsync(string? oldStoragePath, string? currentStoragePath) { await MarkPreviousLedgerRowsAsync(oldStoragePath); DeletePreviousPhysicalFile(oldStoragePath, currentStoragePath); } private void DeletePreviousPhysicalFile(string? oldStoragePath, string? currentStoragePath) { if (string.IsNullOrWhiteSpace(oldStoragePath) || string.Equals(oldStoragePath, currentStoragePath, StringComparison.OrdinalIgnoreCase) || string.Equals(oldStoragePath, PlaceholderPath, StringComparison.OrdinalIgnoreCase)) { return; } DeleteUploadFile(oldStoragePath); } private void DeleteUploadFile(string storagePath) { var absolutePath = ResolveUploadPath(storagePath); if (absolutePath is null) { return; } TryDeleteFile(absolutePath); } private string? ResolveUploadPath(string storagePath) { var normalised = storagePath.Replace('\\', '/').TrimStart('/'); if (!normalised.StartsWith("uploads/books/", StringComparison.OrdinalIgnoreCase)) { return null; } var uploadsRoot = Path.GetFullPath(Path.Combine(environment.WebRootPath, "uploads")); var absolutePath = Path.GetFullPath(Path.Combine(environment.WebRootPath, normalised.Replace('/', Path.DirectorySeparatorChar))); return absolutePath.StartsWith(uploadsRoot, StringComparison.OrdinalIgnoreCase) ? absolutePath : null; } private static void ReplaceFile(string tempPath, string finalPath) { if (File.Exists(finalPath)) { File.Delete(finalPath); } File.Move(tempPath, finalPath); } private static void TryDeleteFile(string path) { if (!File.Exists(path)) { return; } File.Delete(path); } private static long EstimatedGeneratedSize(long uploadBytes) => Math.Max(1, Math.Min(uploadBytes, MaxUploadBytes)); }