Files
social-media/backend/Modules/Contents/Features/AddPhotoToAlbum.cs
2025-06-13 02:22:35 -04:00

195 lines
6.0 KiB
C#

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<AddPhotoToAlbumRequest>
{
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<AddPhotoToAlbumRequest, AddPhotoToAlbumResponse>
{
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)
{
var userId = User.GetUserId();
// Fetch the album we want to add photos to
var album = await context
.Albums
.SingleOrDefaultAsync(
a => a.Id == request.AlbumId && a.CreatedBy == userId,
cancellationToken: ct);
if (album is null)
{
await SendNotFoundAsync(ct);
return;
}
// Check if a photo with the same ID already exists
var existingPhoto = await context
.AlbumPhotos
.AnyAsync(p => p.Id == request.PhotoId, ct);
if (existingPhoto)
{
await SendErrorsAsync(409, ct);
return;
}
try
{
var (originalUrl, thumbnailUrl) = await ProcessAndUploadImage(request, ct);
// Get the next order number
var nextOrder = await context
.AlbumPhotos
.Where(p => p.AlbumId == request.AlbumId)
.MaxAsync(p => (int?)p.Order, ct) ?? 0;
// Create the album photo
var photo = new AlbumPhoto
{
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)
{
var originalFileName = Path.GetFileName(request.File.FileName);
var nameWithoutExt = Path.GetFileNameWithoutExtension(originalFileName);
var extension = Path.GetExtension(originalFileName);
var filenameOriginal = $"{nameWithoutExt}{extension}";
var filenameThumbnail = $"{nameWithoutExt}.thumbnail{extension}";
var blobOriginal = $"{SubDirectoryNames.Albums}/{request.AlbumId}/{filenameOriginal}";
var blobThumbnail = $"{SubDirectoryNames.Albums}/{request.AlbumId}/{filenameThumbnail}";
// Process the original image
await using var originalStream = request.File.OpenReadStream();
using var image = await Image.LoadAsync(originalStream, ct);
// Calculate target size while preserving the original aspect ratio
var originalWidth = image.Width;
var 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 var thumbnailStream = new MemoryStream();
image.Mutate(x => x.Resize(newWidth, newHeight));
await image.SaveAsync(thumbnailStream, image.Metadata.DecodedImageFormat!, ct);
thumbnailStream.Position = 0;
// Upload both versions
var originalUrl = await blobStorage.UploadFileAsync(
ContainerNames.Creators,
blobOriginal,
request.File.OpenReadStream(),
request.File.ContentType,
ct);
var thumbnailUrl = await blobStorage.UploadFileAsync(
ContainerNames.Creators,
blobThumbnail,
thumbnailStream,
request.File.ContentType,
ct);
return (originalUrl, thumbnailUrl);
}
}