250 lines
9.5 KiB
C#
250 lines
9.5 KiB
C#
using SkiaSharp;
|
|
using System.Collections.Concurrent;
|
|
using PlotLine.Data;
|
|
using PlotLine.Models;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public interface IBookCoverService
|
|
{
|
|
Task<BookCoverUploadResult> UploadCoverAsync(Book book, IFormFile upload);
|
|
Task DeleteCoverFilesAsync(Book book);
|
|
}
|
|
|
|
public sealed record BookCoverUploadResult(bool Succeeded, string? ErrorMessage = null);
|
|
|
|
public sealed class BookCoverService(
|
|
IUploadStorageService uploadStorage,
|
|
IProjectCollaborationRepository collaboration,
|
|
ISubscriptionService subscriptions,
|
|
IUserFileRepository userFiles,
|
|
IBookRepository books,
|
|
ILogger<BookCoverService> 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<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
|
|
private static readonly ConcurrentDictionary<int, SemaphoreSlim> BookLocks = new();
|
|
|
|
public async Task<BookCoverUploadResult> 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<BookCoverUploadResult> 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 = uploadStorage.GetDirectory("books", book.BookID.ToString());
|
|
|
|
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 = uploadStorage.GetPublicPath("books", book.BookID.ToString(), ThumbnailFileName);
|
|
var standardStoragePath = uploadStorage.GetPublicPath("books", book.BookID.ToString(), 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;
|
|
}
|
|
|
|
uploadStorage.TryDeleteFile(oldStoragePath, "uploads/books");
|
|
}
|
|
|
|
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));
|
|
}
|