using Hutopy.Infrastructure.BlobStorage.Contracts; using Hutopy.Infrastructure.Security; using Hutopy.Modules.Contents.Data; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; namespace Hutopy.Modules.Contents.Features; [PublicAPI] public record AddPhotoToAlbumRequest( Guid AlbumId, Guid PhotoId, IFormFile File, string? Caption = null); [PublicAPI] public record AddPhotoToAlbumResponse( Guid PhotoId, string OriginalUrl, string ThumbnailUrl); [PublicAPI] public sealed class AddPhotoToAlbumRequestValidator : Validator { private const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10MB private static readonly string[] AllowedImageTypes = [ "image/jpeg", "image/png", "image/gif", "image/webp" ]; public AddPhotoToAlbumRequestValidator() { RuleFor(x => x.AlbumId) .NotNull() .NotEmpty(); RuleFor(x => x.PhotoId) .NotNull() .NotEmpty(); RuleFor(x => x.File) .NotNull() .NotEmpty() .Must(file => AllowedImageTypes.Contains(file.ContentType)) .WithMessage("File must be a valid image (JPEG, PNG, GIF, or WebP)") .Must(file => file.Length <= MaxFileSizeBytes) .WithMessage($"File size must not exceed {MaxFileSizeBytes / 1024 / 1024}MB"); RuleFor(x => x.Caption) .MaximumLength(255); } } [PublicAPI] public class AddPhotoToAlbumHandler( ContentsDbContext context, IBlobStorage blobStorage) : Endpoint { private const int MaxThumbnailWidth = 500; private const int MaxThumbnailHeight = 500; public override void Configure() { Post("/api/albums/{AlbumId}/photos"); Options(o => o.WithTags("Albums")); AllowFileUploads(); } public override async Task HandleAsync( AddPhotoToAlbumRequest request, CancellationToken ct) { Guid userId = User.GetUserId(); // Fetch the album we want to add photos to Album? album = await context .Albums .SingleOrDefaultAsync( a => a.Id == request.AlbumId && a.CreatedBy == userId, ct); if (album is null) { await SendNotFoundAsync(ct); return; } // Check if a photo with the same ID already exists bool existingPhoto = await context .AlbumPhotos .AnyAsync(p => p.Id == request.PhotoId, ct); if (existingPhoto) { await SendErrorsAsync(409, ct); return; } try { (string originalUrl, string thumbnailUrl) = await ProcessAndUploadImage(request, ct); // Get the next order number int nextOrder = await context .AlbumPhotos .Where(p => p.AlbumId == request.AlbumId) .MaxAsync(p => (int?)p.Order, ct) ?? 0; // Create the album photo AlbumPhoto photo = new() { Id = request.PhotoId, CreatedBy = userId, AlbumId = request.AlbumId, OriginalUrl = originalUrl, ThumbnailUrl = thumbnailUrl, Caption = request.Caption, Order = nextOrder + 1 }; context.AlbumPhotos.Add(photo); await context.SaveChangesAsync(ct); await SendOkAsync( new AddPhotoToAlbumResponse(photo.Id, originalUrl, thumbnailUrl), ct); } catch (UnknownImageFormatException) { await SendStringAsync("Invalid image format", 400, cancellation: ct); } catch (Exception) { await SendStringAsync("Error processing image", 500, cancellation: ct); } } private async Task<(string originalUrl, string thumbnailUrl)> ProcessAndUploadImage( AddPhotoToAlbumRequest request, CancellationToken ct) { string originalFileName = Path.GetFileName(request.File.FileName); string nameWithoutExt = Path.GetFileNameWithoutExtension(originalFileName); string extension = Path.GetExtension(originalFileName); string filenameOriginal = $"{nameWithoutExt}{extension}"; string filenameThumbnail = $"{nameWithoutExt}.thumbnail{extension}"; string blobOriginal = $"{SubDirectoryNames.Albums}/{request.AlbumId}/{filenameOriginal}"; string blobThumbnail = $"{SubDirectoryNames.Albums}/{request.AlbumId}/{filenameThumbnail}"; // Process the original image await using Stream originalStream = request.File.OpenReadStream(); using Image image = await Image.LoadAsync(originalStream, ct); // Calculate target size while preserving the original aspect ratio int originalWidth = image.Width; int originalHeight = image.Height; double ratioX = (double)MaxThumbnailWidth / originalWidth; double ratioY = (double)MaxThumbnailHeight / originalHeight; double ratio = Math.Min(ratioX, ratioY); int newWidth = (int)(originalWidth * ratio); int newHeight = (int)(originalHeight * ratio); // Create thumbnail using MemoryStream thumbnailStream = new(); image.Mutate(x => x.Resize(newWidth, newHeight)); await image.SaveAsync(thumbnailStream, image.Metadata.DecodedImageFormat!, ct); thumbnailStream.Position = 0; // Upload both versions string originalUrl = await blobStorage.UploadFileAsync( ContainerNames.Creators, blobOriginal, request.File.OpenReadStream(), request.File.ContentType, ct); string thumbnailUrl = await blobStorage.UploadFileAsync( ContainerNames.Creators, blobThumbnail, thumbnailStream, request.File.ContentType, ct); return (originalUrl, thumbnailUrl); } }