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

@@ -56,3 +56,12 @@ SMTP_FROM_EMAIL=noreply@thebiergarten.app
SMTP_FROM_NAME=The Biergarten App
WEBSITE_BASE_URL=http://localhost:3000
# ======================
# SeaweedFS (S3) Configuration
# ======================
# Credentials and default bucket for the local SeaweedFS S3-compatible storage
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
SEAWEEDFS_ACCESS_KEY_ID=admin
SEAWEEDFS_SECRET_ACCESS_KEY=secret
SEAWEEDFS_BUCKET=my-bucket

View File

@@ -20,10 +20,12 @@
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>

View File

@@ -30,11 +30,14 @@ COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emai
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"]
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Features.PhotoUpload/"]
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj", "Infrastructure/Infrastructure.FileUpload/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]

View File

@@ -7,13 +7,19 @@ using Features.Breweries.Controllers;
using Features.Breweries.DependencyInjection;
using Features.Emails.DependencyInjection;
using Features.Emails.Services;
using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.DependencyInjection;
using Features.UserManagement.Controllers;
using Features.UserManagement.DependencyInjection;
using FluentValidation;
using FluentValidation.AspNetCore;
using Infrastructure.FileUpload;
using Infrastructure.Jwt;
using MediatR;
using Microsoft.OpenApi.Models;
using Shared.Application.Behaviors;
using System.Security.Claims;
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -65,6 +71,7 @@ builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly);
builder.Services.AddFluentValidationAutoValidation();
// Add MediatR.
@@ -75,6 +82,7 @@ builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UserController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
@@ -93,10 +101,13 @@ builder.Services.AddFeaturesBreweries();
builder.Services.AddFeaturesUserManagement();
builder.Services.AddFeaturesAuth();
builder.Services.AddFeaturesEmails();
builder.Services.AddFeaturesPhotoUpload();
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
builder.Services.AddSingleton<IFileStorageProvider, S3FileStorageProvider>();
builder.Services.AddScoped<GlobalExceptionFilter>();
builder
@@ -115,11 +126,24 @@ app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
// Health check endpoint (used by Docker health checks and orchestrators)
app.MapHealthChecks("/health");
app.MapControllers();
app.MapPost(
"/upload-test",
async (IFormFile file, ClaimsPrincipal user, IMediator mediator) =>
{
Guid uploadedById = Guid.Parse(user.FindFirst(JwtRegisteredClaimNames.Sub)!.Value);
Guid photoId = await mediator.Send(
new UploadPhotoCommand(uploadedById, "upload-test", file)
);
return Results.Ok(photoId);
}
)
.RequireAuthorization()
.DisableAntiforgery();
app.MapFallbackToController("Handle404", "NotFound");
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();

View File

@@ -20,11 +20,14 @@ COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emai
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"]
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Features.PhotoUpload/"]
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj", "Infrastructure/Infrastructure.FileUpload/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]

View File

@@ -16,6 +16,7 @@
<Project Path="Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj" />
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
<Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
<Project Path="Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj" />
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
</Folder>
@@ -30,6 +31,8 @@
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" />
<Project Path="Features/Features.Locations/Features.Locations.csproj" />
<Project Path="Features/Features.Locations.Tests/Features.Locations.Tests.csproj" />
<Project Path="Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<Project Path="Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj" />
</Folder>
<Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />

View File

@@ -18,4 +18,21 @@ public abstract class DapperRepository(ISqlConnectionFactory connectionFactory)
await connection.OpenAsync();
return connection;
}
/// <summary>
/// Rolls back <paramref name="transaction" />, swallowing any exception the rollback itself
/// raises (for example when the provider has already completed the transaction after a
/// connection failure) so the exception that triggered the rollback is what propagates.
/// </summary>
protected static async Task RollbackQuietlyAsync(DbTransaction transaction)
{
try
{
await transaction.RollbackAsync();
}
catch
{
// Ignore: the original exception (rethrown by the caller) is what matters here.
}
}
}

View File

@@ -0,0 +1,17 @@
namespace Domain.Entities;
public sealed class Photo
{
// PK
public Guid PhotoId { get; set; }
// FK References
public Guid UploadedById { get; set; }
// Attributes
public string? Hyperlink { get; set; }
// Audit Fields
public DateTime UploadedAt { get; set; }
public byte[]? RowVersion { get; set; }
}

View File

@@ -537,22 +537,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
}
}
/// <summary>
/// Rolls back <paramref name="transaction" />, swallowing any exception the rollback itself
/// raises (for example when the provider has already completed the transaction after a
/// connection failure) so the exception that triggered the rollback is what propagates.
/// </summary>
private static async Task RollbackQuietlyAsync(DbTransaction transaction)
{
try
{
await transaction.RollbackAsync();
}
catch
{
// Ignore: the original exception (rethrown by the caller) is what matters here.
}
}
/// <summary>
/// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />,

View File

@@ -0,0 +1,120 @@
using Domain.Entities;
using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.Repository;
using FluentAssertions;
using Infrastructure.FileUpload;
using Microsoft.AspNetCore.Http;
using Moq;
namespace Features.PhotoUpload.Tests.Commands;
public class UploadPhotoHandlerTests
{
private readonly Mock<IFileStorageProvider> _fileStorageProviderMock = new();
private readonly UploadPhotoHandler _handler;
private readonly Mock<IPhotoUploadRepository> _repoMock = new();
public UploadPhotoHandlerTests()
{
_handler = new UploadPhotoHandler(_fileStorageProviderMock.Object, _repoMock.Object);
}
private static Mock<IFormFile> ValidFile(
string content = "fake-image-bytes",
string contentType = "image/png"
)
{
Mock<IFormFile> file = new();
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(content);
file.Setup(f => f.OpenReadStream()).Returns(() => new MemoryStream(bytes));
file.Setup(f => f.ContentType).Returns(contentType);
file.Setup(f => f.Length).Returns(bytes.Length);
return file;
}
[Fact]
public async Task Handle_UploadsFileUnderRequestedKey_AndPersistsPhoto_ReturnsNewPhotoId()
{
Mock<IFormFile> file = ValidFile();
UploadPhotoCommand command = new(Guid.NewGuid(), "breweries/some-brewery-id", file.Object);
string? uploadedKey = null;
string? uploadedContentType = null;
_fileStorageProviderMock
.Setup(p =>
p.UploadAsync(
It.IsAny<string>(),
It.IsAny<Stream>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()
)
)
.Callback<string, Stream, string, CancellationToken>(
(key, _, contentType, _) =>
{
uploadedKey = key;
uploadedContentType = contentType;
}
)
.Returns(Task.CompletedTask);
Photo? persisted = null;
_repoMock
.Setup(r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()))
.Callback<Photo, CancellationToken>((photo, _) => persisted = photo)
.Returns(Task.CompletedTask);
Guid photoId = await _handler.Handle(command, CancellationToken.None);
photoId.Should().NotBe(Guid.Empty);
uploadedKey.Should().Be($"breweries/some-brewery-id/{photoId}");
uploadedContentType.Should().Be("image/png");
persisted.Should().NotBeNull();
persisted!.Hyperlink.Should().Be(uploadedKey);
persisted.UploadedById.Should().Be(command.UploadedById);
_fileStorageProviderMock.Verify(
p =>
p.UploadAsync(
uploadedKey!,
It.IsAny<Stream>(),
"image/png",
It.IsAny<CancellationToken>()
),
Times.Once
);
_repoMock.Verify(
r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()),
Times.Once
);
}
[Fact]
public async Task Handle_UploadOrderedBeforePersist_DoesNotPersistWhenUploadFails()
{
Mock<IFormFile> file = ValidFile();
UploadPhotoCommand command = new(Guid.NewGuid(), "avatars", file.Object);
_fileStorageProviderMock
.Setup(p =>
p.UploadAsync(
It.IsAny<string>(),
It.IsAny<Stream>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()
)
)
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
_repoMock.Verify(
r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()),
Times.Never
);
}
}

View File

@@ -0,0 +1,154 @@
using Features.ImageUploads.Commands.UploadPhoto;
using FluentAssertions;
using FluentValidation.Results;
using Microsoft.AspNetCore.Http;
using Moq;
namespace Features.PhotoUpload.Tests.Commands;
public class UploadPhotoValidatorTests
{
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[] WebPSignature =
[
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50 // "RIFF____WEBP"
];
private static readonly byte[] GifSignature =
[
0x47, 0x49, 0x46, 0x38, 0x39, 0x61 // "GIF89a"
];
private static readonly byte[] BmpSignature =
[
0x42, 0x4D // "BM"
];
private static readonly byte[] PdfSignature =
[
0x25, 0x50, 0x44, 0x46 // "%PDF"
];
private static readonly byte[] PlainTextBytes = "not an image"u8.ToArray();
private const long MinSizeBytes = 1024; // 1 KiB
private const long MaxSizeBytes = 24 * 1024 * 1024; // 24 MiB
private readonly UploadPhotoValidator _validator = new();
private static Mock<IFormFile> FileOfSize(long sizeInBytes)
{
byte[] bytes = new byte[Math.Max(sizeInBytes, PngSignature.Length)];
PngSignature.CopyTo(bytes, 0);
Mock<IFormFile> file = new();
file.Setup(f => f.OpenReadStream()).Returns(() => new MemoryStream(bytes));
file.Setup(f => f.ContentType).Returns("image/png");
file.Setup(f => f.Length).Returns(sizeInBytes);
return file;
}
private static Mock<IFormFile> FileWithHeader(byte[] header, long sizeInBytes = 0)
{
byte[] bytes = new byte[Math.Max(sizeInBytes, Math.Max(header.Length, MinSizeBytes))];
header.CopyTo(bytes, 0);
Mock<IFormFile> file = new();
file.Setup(f => f.OpenReadStream()).Returns(() => new MemoryStream(bytes));
file.Setup(f => f.ContentType).Returns("image/png");
file.Setup(f => f.Length).Returns(bytes.Length);
return file;
}
private static UploadPhotoCommand CommandWithFile(IFormFile file) =>
new(Guid.NewGuid(), "breweries/some-brewery-id", file);
public static TheoryData<string, byte[]> ValidSignatures => new()
{
{ "PNG", PngSignature },
{ "JPEG", JpegSignature },
{ "WebP", WebPSignature }
};
public static TheoryData<string, byte[]> InvalidSignatures => new()
{
{ "GIF", GifSignature },
{ "BMP", BmpSignature },
{ "PDF", PdfSignature },
{ "plain text", PlainTextBytes }
};
[Fact]
public async Task Validate_FileBelowMinSize_FailsWithSizeError()
{
Mock<IFormFile> file = FileOfSize(MinSizeBytes - 1);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "File.Length");
}
[Fact]
public async Task Validate_FileAtMinSize_PassesSizeCheck()
{
Mock<IFormFile> file = FileOfSize(MinSizeBytes);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.Errors.Should().NotContain(e => e.PropertyName == "File.Length");
}
[Fact]
public async Task Validate_FileAtMaxSize_PassesSizeCheck()
{
Mock<IFormFile> file = FileOfSize(MaxSizeBytes);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.Errors.Should().NotContain(e => e.PropertyName == "File.Length");
}
[Fact]
public async Task Validate_FileAboveMaxSize_FailsWithSizeError()
{
Mock<IFormFile> file = FileOfSize(MaxSizeBytes + 1);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "File.Length");
}
[Theory]
[MemberData(nameof(ValidSignatures))]
public async Task Validate_ValidImageSignature_PassesSignatureCheck(string formatName, byte[] header)
{
Mock<IFormFile> file = FileWithHeader(header);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.Errors.Should().NotContain(e => e.PropertyName == "File", formatName);
}
[Theory]
[MemberData(nameof(InvalidSignatures))]
public async Task Validate_InvalidImageSignature_FailsWithSignatureError(string formatName, byte[] header)
{
Mock<IFormFile> file = FileWithHeader(header);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.IsValid.Should().BeFalse(formatName);
result.Errors.Should().Contain(e => e.PropertyName == "File", formatName);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Features.PhotoUpload.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Features.PhotoUpload\Features.PhotoUpload.csproj" />
</ItemGroup>
</Project>

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;
}
}
}

View File

@@ -22,6 +22,11 @@ public static class ConfigurationKeys
public const string SmtpFromEmail = "SMTP_FROM_EMAIL";
public const string SmtpFromName = "SMTP_FROM_NAME";
public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL";
public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID";
public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY";
public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET";
public const string DbServer = "DB_SERVER";
public const string DbUser = "DB_USER";
public const string DbPassword = "DB_PASSWORD";

View File

@@ -0,0 +1,29 @@
namespace Infrastructure.FileUpload;
/// <summary>
/// Service for storing and retrieving uploaded files in S3-compatible object storage.
/// </summary>
public interface IFileStorageProvider
{
/// <summary>Uploads a file to the configured bucket.</summary>
/// <param name="key">The object key (path) to store the file under.</param>
/// <param name="content">The file content.</param>
/// <param name="contentType">The MIME type of the file.</param>
/// <param name="cancellationToken">A token to cancel the upload.</param>
Task UploadAsync(
string key,
Stream content,
string contentType,
CancellationToken cancellationToken = default
);
/// <summary>Deletes a file from the configured bucket.</summary>
/// <param name="key">The object key of the file to delete.</param>
/// <param name="cancellationToken">A token to cancel the deletion.</param>
Task DeleteAsync(string key, CancellationToken cancellationToken = default);
/// <summary>Generates a time-limited URL for retrieving a file directly from storage.</summary>
/// <param name="key">The object key of the file.</param>
/// <param name="expiresIn">How long the URL remains valid.</param>
string GetPresignedUrl(string key, TimeSpan expiresIn);
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Infrastructure.FileUpload</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.102.4" />
<PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="10.0.1"
/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,96 @@
using Amazon.S3;
using Amazon.S3.Model;
using Infrastructure.Configuration;
using Microsoft.Extensions.Configuration;
namespace Infrastructure.FileUpload;
/// <summary>
/// File storage service backed by an S3-compatible object store (e.g. SeaweedFS, AWS S3).
/// Configured via application configuration (backed by environment variables).
/// </summary>
public class S3FileStorageProvider : IFileStorageProvider, IDisposable
{
private readonly string _bucket;
private readonly IAmazonS3 _client;
/// <summary>
/// Initializes a new instance of the <see cref="S3FileStorageProvider" /> class, reading storage
/// configuration (<c>SEAWEEDFS_SERVICE_URL</c>, <c>SEAWEEDFS_ACCESS_KEY_ID</c>,
/// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>) from <paramref name="configuration" />.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when any of the required configuration values is not set.
/// </exception>
public S3FileStorageProvider(IConfiguration configuration)
{
string serviceUrl = ConfigurationHelpers.GetKeyOrThrow(
configuration,
ConfigurationKeys.SeaweedFsServiceUrl
);
string accessKeyId = ConfigurationHelpers.GetKeyOrThrow(
configuration,
ConfigurationKeys.SeaweedFsAccessKeyId
);
string secretAccessKey = ConfigurationHelpers.GetKeyOrThrow(
configuration,
ConfigurationKeys.SeaweedFsSecretAccessKey
);
_bucket = ConfigurationHelpers.GetKeyOrThrow(
configuration,
ConfigurationKeys.SeaweedFsBucket
);
AmazonS3Config config = new() { ServiceURL = serviceUrl, ForcePathStyle = true };
_client = new AmazonS3Client(accessKeyId, secretAccessKey, config);
}
/// <inheritdoc/>
public async Task UploadAsync(
string key,
Stream content,
string contentType,
CancellationToken cancellationToken = default
)
{
PutObjectRequest request = new()
{
BucketName = _bucket,
Key = key,
InputStream = content,
ContentType = contentType,
};
await _client.PutObjectAsync(request, cancellationToken);
}
/// <inheritdoc/>
public async Task DeleteAsync(string key, CancellationToken cancellationToken = default)
{
DeleteObjectRequest request = new() { BucketName = _bucket, Key = key };
await _client.DeleteObjectAsync(request, cancellationToken);
}
/// <inheritdoc/>
public string GetPresignedUrl(string key, TimeSpan expiresIn)
{
GetPreSignedUrlRequest request = new()
{
BucketName = _bucket,
Key = key,
Expires = DateTime.UtcNow.Add(expiresIn),
};
return _client.GetPreSignedURL(request);
}
/// <inheritdoc/>
public void Dispose()
{
_client.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -1,4 +1,5 @@
services:
sqlserver:
env_file: ".env.dev"
image: mcr.microsoft.com/mssql/server:2022-latest
@@ -24,6 +25,7 @@ services:
start_period: 30s
networks:
- devnet
database.migrations:
env_file: ".env.dev"
image: database.migrations
@@ -78,6 +80,8 @@ services:
depends_on:
database.seed:
condition: service_completed_successfully
seaweedfs:
condition: service_started
build:
context: ./backend
dockerfile: API/API.Core/Dockerfile
@@ -99,30 +103,55 @@ services:
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
restart: unless-stopped
networks:
- devnet
volumes:
- nuget-cache-dev:/root/.nuget/packages
seaweedfs:
env_file: ".env.dev"
image: chrislusf/seaweedfs
container_name: dev-env-seaweedfs
ports:
- "8333:8333" # S3 API
- "8888:8888" # Filer UI
- "9333:9333" # Master UI
- "23646:23646" # Admin UI
environment:
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
volumes:
- seaweedfsdata-dev:/data
restart: unless-stopped
networks:
- devnet
mailpit:
image: axllent/mailpit:latest
container_name: dev-env-mailpit
ports:
- "8025:8025" # Web UI
- "1025:1025" # SMTP server
restart: unless-stopped
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
networks:
- devnet
volumes:
sqlserverdata-dev:
driver: local
nuget-cache-dev:
driver: local
seaweedfsdata-dev:
driver: local
networks:
devnet:

View File

@@ -57,6 +57,8 @@ REFRESH_TOKEN_SECRET_VAL=$(gen_jwt_secret)
CONFIRMATION_TOKEN_SECRET_VAL=$(gen_jwt_secret)
SMTP_USERNAME_VAL="smtp_$(gen_username)"
SMTP_PASSWORD_VAL=$(gen_password)
SEAWEEDFS_ACCESS_KEY_ID_VAL=$(gen_username)
SEAWEEDFS_SECRET_ACCESS_KEY_VAL=$(gen_password)
cp "$TEMPLATE_FILE" "$OUTPUT_FILE"
@@ -68,6 +70,8 @@ sed \
-e "s|^CONFIRMATION_TOKEN_SECRET=.*|CONFIRMATION_TOKEN_SECRET=${CONFIRMATION_TOKEN_SECRET_VAL}|" \
-e "s|^SMTP_USERNAME=.*|SMTP_USERNAME=${SMTP_USERNAME_VAL}|" \
-e "s|^SMTP_PASSWORD=.*|SMTP_PASSWORD=${SMTP_PASSWORD_VAL}|" \
-e "s|^SEAWEEDFS_ACCESS_KEY_ID=.*|SEAWEEDFS_ACCESS_KEY_ID=${SEAWEEDFS_ACCESS_KEY_ID_VAL}|" \
-e "s|^SEAWEEDFS_SECRET_ACCESS_KEY=.*|SEAWEEDFS_SECRET_ACCESS_KEY=${SEAWEEDFS_SECRET_ACCESS_KEY_VAL}|" \
"$OUTPUT_FILE" > "$TMP_FILE"
mv "$TMP_FILE" "$OUTPUT_FILE"