195 lines
6.0 KiB
C#
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);
|
|
}
|
|
}
|