Integrate SeaweedFS for S3-compatible file storage and photo upload (#254)

This commit is contained in:
Aaron Po
2026-08-29 23:37:11 -04:00
committed by GitHub
parent a580314583
commit be672b6ca9
25 changed files with 790 additions and 19 deletions

View File

@@ -0,0 +1,11 @@
using MediatR;
using Microsoft.AspNetCore.Http;
namespace Features.ImageUploads.Commands.UploadPhoto;
/// <summary>
/// Uploads a new photo to storage under <c>{Key}/{generated photo id}</c> and records it. Sent by
/// other features' handlers (e.g. a brewery photo upload command) rather than bound from a request
/// directly, since this feature exposes no controller of its own.
/// </summary>
public record UploadPhotoCommand(Guid UploadedById, string Key, IFormFile File) : IRequest<Guid>;

View File

@@ -0,0 +1,34 @@
using Domain.Entities;
using Features.ImageUploads.Repository;
using Infrastructure.FileUpload;
using MediatR;
namespace Features.ImageUploads.Commands.UploadPhoto;
/// <summary>Handles <see cref="UploadPhotoCommand" />.</summary>
public class UploadPhotoHandler(
IFileStorageProvider fileStorageProvider,
IPhotoUploadRepository photoUploadRepository
) : IRequestHandler<UploadPhotoCommand, Guid>
{
public async Task<Guid> Handle(UploadPhotoCommand request, CancellationToken cancellationToken)
{
Guid photoId = Guid.NewGuid();
string key = $"{request.Key}/{photoId}";
await using Stream stream = request.File.OpenReadStream();
await fileStorageProvider.UploadAsync(
key,
stream,
request.File.ContentType,
cancellationToken
);
Photo photo = new() { Hyperlink = key, UploadedById = request.UploadedById };
await photoUploadRepository.CreateAsync(photo, cancellationToken);
return photoId;
}
}

View File

@@ -0,0 +1,82 @@
using FluentValidation;
using Microsoft.AspNetCore.Http;
namespace Features.ImageUploads.Commands.UploadPhoto;
/// <summary>Validates <see cref="UploadPhotoCommand" />.</summary>
public class UploadPhotoValidator : AbstractValidator<UploadPhotoCommand>
{
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
private static readonly byte[] JpegSignature = [0xFF, 0xD8, 0xFF];
private static readonly byte[] RiffSignature =
[
0x52,
0x49,
0x46,
0x46, // "RIFF"
];
private static readonly byte[] WebPMarker =
[
0x57,
0x45,
0x42,
0x50, // "WEBP"
];
private const long MinSizeBytes = 1024; // 1 KiB
private const long MaxSizeBytes = 24 * 1024 * 1024; // 24 MiB
public UploadPhotoValidator()
{
RuleFor(x => x.UploadedById).NotEmpty().WithMessage("UploadedById is required.");
RuleFor(x => x.File).NotNull().WithMessage("File is required.");
RuleFor(x => x.Key).NotEmpty().WithMessage("Key is required.");
RuleFor(x => x.File.ContentType)
.Must(contentType =>
contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
)
.WithMessage("File must be an image.");
RuleFor(x => x.File.Length)
.InclusiveBetween(MinSizeBytes, MaxSizeBytes)
.WithMessage("The file must be between 1 KiB and 24 MiB.");
RuleFor(x => x.File)
.MustAsync(HaveValidImageSignatureAsync)
.WithMessage("The file is not a valid PNG, JPEG, or WebP image.");
}
private static async Task<bool> HaveValidImageSignatureAsync(
IFormFile file,
CancellationToken cancellationToken
)
{
var buffer = new byte[12];
await using var stream = file.OpenReadStream();
var bytesRead = await stream.ReadAsync(
buffer.AsMemory(0, buffer.Length),
cancellationToken
);
return IsPng(buffer, bytesRead) || IsJpeg(buffer, bytesRead) || IsWebP(buffer, bytesRead);
}
private static bool IsPng(byte[] buffer, int bytesRead) =>
bytesRead >= PngSignature.Length
&& buffer.AsSpan(0, PngSignature.Length).SequenceEqual(PngSignature);
private static bool IsJpeg(byte[] buffer, int bytesRead) =>
bytesRead >= JpegSignature.Length
&& buffer.AsSpan(0, JpegSignature.Length).SequenceEqual(JpegSignature);
private static bool IsWebP(byte[] buffer, int bytesRead) =>
bytesRead >= 12
&& buffer.AsSpan(0, 4).SequenceEqual(RiffSignature)
&& buffer.AsSpan(8, 4).SequenceEqual(WebPMarker);
}

View File

@@ -0,0 +1,16 @@
using Features.ImageUploads.Repository;
using Microsoft.Extensions.DependencyInjection;
namespace Features.ImageUploads.DependencyInjection;
/// <summary>Registers the services required by the PhotoUpload feature.</summary>
public static class FeaturesPhotoUploadServiceCollectionExtensions
{
/// <summary>Registers <see cref="IPhotoUploadRepository" /> and its implementation.</summary>
/// <returns><paramref name="services" />, for chaining.</returns>
public static IServiceCollection AddFeaturesPhotoUpload(this IServiceCollection services)
{
services.AddScoped<IPhotoUploadRepository, PhotoUploadRepository>();
return services;
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Features.ImageUploads</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,63 @@
using System.Data.Common;
using Dapper;
using Database.Connection;
using Domain.Entities;
using Domain.Exceptions;
namespace Features.ImageUploads.Repository;
public interface IPhotoUploadRepository
{
Task CreateAsync(Photo photo, CancellationToken cancellationToken);
}
public class PhotoUploadRepository(ISqlConnectionFactory connectionFactory)
: DapperRepository(connectionFactory),
IPhotoUploadRepository
{
public async Task CreateAsync(Photo photo, CancellationToken cancellationToken)
{
await using DbConnection connection = await CreateConnection();
await using DbTransaction transaction = await connection.BeginTransactionAsync();
try
{
bool userExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"""
SELECT 1
FROM
dbo.UserAccount
WHERE
UserAccountID = @UploadedById
""",
new { photo.UploadedById },
transaction
)
)
is not null;
if (!userExists)
throw new NotFoundException("User not found.");
await connection.ExecuteAsync(
new CommandDefinition(
"""
INSERT INTO dbo.Photo (Hyperlink, UploadedByID)
VALUES (@Hyperlink, @UploadedById)
""",
new { photo.Hyperlink, photo.UploadedById },
transaction
)
);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await RollbackQuietlyAsync(transaction);
throw;
}
}
}