diff --git a/web/.env.example b/web/.env.example
index 96513c87..8bcfbc07 100644
--- a/web/.env.example
+++ b/web/.env.example
@@ -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
diff --git a/web/backend/API/API.Core/API.Core.csproj b/web/backend/API/API.Core/API.Core.csproj
index 953b4f3a..36e47d4c 100644
--- a/web/backend/API/API.Core/API.Core.csproj
+++ b/web/backend/API/API.Core/API.Core.csproj
@@ -20,10 +20,12 @@
+
+
diff --git a/web/backend/API/API.Core/Dockerfile b/web/backend/API/API.Core/Dockerfile
index d9f9c738..21132363 100644
--- a/web/backend/API/API.Core/Dockerfile
+++ b/web/backend/API/API.Core/Dockerfile
@@ -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/"]
diff --git a/web/backend/API/API.Core/Program.cs b/web/backend/API/API.Core/Program.cs
index 6a5b1720..80912563 100644
--- a/web/backend/API/API.Core/Program.cs
+++ b/web/backend/API/API.Core/Program.cs
@@ -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();
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();
+builder.Services.AddSingleton();
+
builder.Services.AddScoped();
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();
diff --git a/web/backend/API/API.Specs/Dockerfile b/web/backend/API/API.Specs/Dockerfile
index d2cbdcdb..4ac4114d 100644
--- a/web/backend/API/API.Specs/Dockerfile
+++ b/web/backend/API/API.Specs/Dockerfile
@@ -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/"]
diff --git a/web/backend/Biergarten.API.slnx b/web/backend/Biergarten.API.slnx
index f4ce55b6..e62246bc 100644
--- a/web/backend/Biergarten.API.slnx
+++ b/web/backend/Biergarten.API.slnx
@@ -16,6 +16,7 @@
+
@@ -30,6 +31,8 @@
+
+
diff --git a/web/backend/Database/Database.Connection/Repository.cs b/web/backend/Database/Database.Connection/Repository.cs
index b4a9f08f..4bce5bb3 100644
--- a/web/backend/Database/Database.Connection/Repository.cs
+++ b/web/backend/Database/Database.Connection/Repository.cs
@@ -18,4 +18,21 @@ public abstract class DapperRepository(ISqlConnectionFactory connectionFactory)
await connection.OpenAsync();
return connection;
}
+
+ ///
+ /// Rolls back , 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.
+ ///
+ protected static async Task RollbackQuietlyAsync(DbTransaction transaction)
+ {
+ try
+ {
+ await transaction.RollbackAsync();
+ }
+ catch
+ {
+ // Ignore: the original exception (rethrown by the caller) is what matters here.
+ }
+ }
}
diff --git a/web/backend/Domain/Domain.Entities/Entities/Photo.cs b/web/backend/Domain/Domain.Entities/Entities/Photo.cs
new file mode 100644
index 00000000..44a25546
--- /dev/null
+++ b/web/backend/Domain/Domain.Entities/Entities/Photo.cs
@@ -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; }
+}
diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs
index 437a0f54..ca796ff1 100644
--- a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs
+++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs
@@ -537,22 +537,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
}
}
- ///
- /// Rolls back , 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.
- ///
- private static async Task RollbackQuietlyAsync(DbTransaction transaction)
- {
- try
- {
- await transaction.RollbackAsync();
- }
- catch
- {
- // Ignore: the original exception (rethrown by the caller) is what matters here.
- }
- }
///
/// Composes a joined row's , ,
diff --git a/web/backend/Features/Features.PhotoUpload.Tests/Commands/UploadPhotoHandlerTests.cs b/web/backend/Features/Features.PhotoUpload.Tests/Commands/UploadPhotoHandlerTests.cs
new file mode 100644
index 00000000..ad2d69a3
--- /dev/null
+++ b/web/backend/Features/Features.PhotoUpload.Tests/Commands/UploadPhotoHandlerTests.cs
@@ -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 _fileStorageProviderMock = new();
+ private readonly UploadPhotoHandler _handler;
+ private readonly Mock _repoMock = new();
+
+ public UploadPhotoHandlerTests()
+ {
+ _handler = new UploadPhotoHandler(_fileStorageProviderMock.Object, _repoMock.Object);
+ }
+
+ private static Mock ValidFile(
+ string content = "fake-image-bytes",
+ string contentType = "image/png"
+ )
+ {
+ Mock 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 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(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .Callback(
+ (key, _, contentType, _) =>
+ {
+ uploadedKey = key;
+ uploadedContentType = contentType;
+ }
+ )
+ .Returns(Task.CompletedTask);
+
+ Photo? persisted = null;
+ _repoMock
+ .Setup(r => r.CreateAsync(It.IsAny(), It.IsAny()))
+ .Callback((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(),
+ "image/png",
+ It.IsAny()
+ ),
+ Times.Once
+ );
+ _repoMock.Verify(
+ r => r.CreateAsync(It.IsAny(), It.IsAny()),
+ Times.Once
+ );
+ }
+
+
+ [Fact]
+ public async Task Handle_UploadOrderedBeforePersist_DoesNotPersistWhenUploadFails()
+ {
+ Mock file = ValidFile();
+ UploadPhotoCommand command = new(Guid.NewGuid(), "avatars", file.Object);
+
+ _fileStorageProviderMock
+ .Setup(p =>
+ p.UploadAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .ThrowsAsync(new InvalidOperationException("storage unavailable"));
+
+ Func act = () => _handler.Handle(command, CancellationToken.None);
+
+ await act.Should().ThrowAsync();
+ _repoMock.Verify(
+ r => r.CreateAsync(It.IsAny(), It.IsAny()),
+ Times.Never
+ );
+ }
+}
diff --git a/web/backend/Features/Features.PhotoUpload.Tests/Commands/UploadPhotoValidatorTests.cs b/web/backend/Features/Features.PhotoUpload.Tests/Commands/UploadPhotoValidatorTests.cs
new file mode 100644
index 00000000..034b9dc3
--- /dev/null
+++ b/web/backend/Features/Features.PhotoUpload.Tests/Commands/UploadPhotoValidatorTests.cs
@@ -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 FileOfSize(long sizeInBytes)
+ {
+ byte[] bytes = new byte[Math.Max(sizeInBytes, PngSignature.Length)];
+ PngSignature.CopyTo(bytes, 0);
+
+ Mock 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 FileWithHeader(byte[] header, long sizeInBytes = 0)
+ {
+ byte[] bytes = new byte[Math.Max(sizeInBytes, Math.Max(header.Length, MinSizeBytes))];
+ header.CopyTo(bytes, 0);
+
+ Mock 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 ValidSignatures => new()
+ {
+ { "PNG", PngSignature },
+ { "JPEG", JpegSignature },
+ { "WebP", WebPSignature }
+ };
+
+ public static TheoryData InvalidSignatures => new()
+ {
+ { "GIF", GifSignature },
+ { "BMP", BmpSignature },
+ { "PDF", PdfSignature },
+ { "plain text", PlainTextBytes }
+ };
+
+ [Fact]
+ public async Task Validate_FileBelowMinSize_FailsWithSizeError()
+ {
+ Mock 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 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 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 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 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 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);
+ }
+}
diff --git a/web/backend/Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj b/web/backend/Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj
new file mode 100644
index 00000000..a0af8703
--- /dev/null
+++ b/web/backend/Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ Features.PhotoUpload.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoCommand.cs b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoCommand.cs
new file mode 100644
index 00000000..851cfdef
--- /dev/null
+++ b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoCommand.cs
@@ -0,0 +1,11 @@
+using MediatR;
+using Microsoft.AspNetCore.Http;
+
+namespace Features.ImageUploads.Commands.UploadPhoto;
+
+///
+/// Uploads a new photo to storage under {Key}/{generated photo id} 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.
+///
+public record UploadPhotoCommand(Guid UploadedById, string Key, IFormFile File) : IRequest;
diff --git a/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoHandler.cs b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoHandler.cs
new file mode 100644
index 00000000..959a85fe
--- /dev/null
+++ b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoHandler.cs
@@ -0,0 +1,34 @@
+using Domain.Entities;
+using Features.ImageUploads.Repository;
+using Infrastructure.FileUpload;
+using MediatR;
+
+namespace Features.ImageUploads.Commands.UploadPhoto;
+
+/// Handles .
+public class UploadPhotoHandler(
+ IFileStorageProvider fileStorageProvider,
+ IPhotoUploadRepository photoUploadRepository
+) : IRequestHandler
+{
+ public async Task 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;
+ }
+}
diff --git a/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoValidator.cs b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoValidator.cs
new file mode 100644
index 00000000..9c241cb5
--- /dev/null
+++ b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoValidator.cs
@@ -0,0 +1,82 @@
+using FluentValidation;
+using Microsoft.AspNetCore.Http;
+
+namespace Features.ImageUploads.Commands.UploadPhoto;
+
+/// Validates .
+public class UploadPhotoValidator : AbstractValidator
+{
+ 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 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);
+}
diff --git a/web/backend/Features/Features.PhotoUpload/DependencyInjection/FeaturesPhotoUploadServiceCollectionExtensions.cs b/web/backend/Features/Features.PhotoUpload/DependencyInjection/FeaturesPhotoUploadServiceCollectionExtensions.cs
new file mode 100644
index 00000000..6e0a698d
--- /dev/null
+++ b/web/backend/Features/Features.PhotoUpload/DependencyInjection/FeaturesPhotoUploadServiceCollectionExtensions.cs
@@ -0,0 +1,16 @@
+using Features.ImageUploads.Repository;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Features.ImageUploads.DependencyInjection;
+
+/// Registers the services required by the PhotoUpload feature.
+public static class FeaturesPhotoUploadServiceCollectionExtensions
+{
+ /// Registers and its implementation.
+ /// , for chaining.
+ public static IServiceCollection AddFeaturesPhotoUpload(this IServiceCollection services)
+ {
+ services.AddScoped();
+ return services;
+ }
+}
diff --git a/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj b/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj
new file mode 100644
index 00000000..64b3c805
--- /dev/null
+++ b/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj
@@ -0,0 +1,21 @@
+
+
+ net10.0
+ enable
+ enable
+ Features.ImageUploads
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/backend/Features/Features.PhotoUpload/Repository/IPhotoUploadRepository.cs b/web/backend/Features/Features.PhotoUpload/Repository/IPhotoUploadRepository.cs
new file mode 100644
index 00000000..e69de29b
diff --git a/web/backend/Features/Features.PhotoUpload/Repository/PhotoUploadRepository.cs b/web/backend/Features/Features.PhotoUpload/Repository/PhotoUploadRepository.cs
new file mode 100644
index 00000000..76a0d475
--- /dev/null
+++ b/web/backend/Features/Features.PhotoUpload/Repository/PhotoUploadRepository.cs
@@ -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(
+ 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;
+ }
+ }
+}
diff --git a/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationKeys.cs b/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationKeys.cs
index 0238734e..400a9742 100644
--- a/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationKeys.cs
+++ b/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationKeys.cs
@@ -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";
diff --git a/web/backend/Infrastructure/Infrastructure.FileUpload/IFileStorageProvider.cs b/web/backend/Infrastructure/Infrastructure.FileUpload/IFileStorageProvider.cs
new file mode 100644
index 00000000..106e3a60
--- /dev/null
+++ b/web/backend/Infrastructure/Infrastructure.FileUpload/IFileStorageProvider.cs
@@ -0,0 +1,29 @@
+namespace Infrastructure.FileUpload;
+
+///
+/// Service for storing and retrieving uploaded files in S3-compatible object storage.
+///
+public interface IFileStorageProvider
+{
+ /// Uploads a file to the configured bucket.
+ /// The object key (path) to store the file under.
+ /// The file content.
+ /// The MIME type of the file.
+ /// A token to cancel the upload.
+ Task UploadAsync(
+ string key,
+ Stream content,
+ string contentType,
+ CancellationToken cancellationToken = default
+ );
+
+ /// Deletes a file from the configured bucket.
+ /// The object key of the file to delete.
+ /// A token to cancel the deletion.
+ Task DeleteAsync(string key, CancellationToken cancellationToken = default);
+
+ /// Generates a time-limited URL for retrieving a file directly from storage.
+ /// The object key of the file.
+ /// How long the URL remains valid.
+ string GetPresignedUrl(string key, TimeSpan expiresIn);
+}
diff --git a/web/backend/Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj b/web/backend/Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj
new file mode 100644
index 00000000..9308d863
--- /dev/null
+++ b/web/backend/Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj
@@ -0,0 +1,20 @@
+
+
+ net10.0
+ enable
+ enable
+ Infrastructure.FileUpload
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/backend/Infrastructure/Infrastructure.FileUpload/S3FileStorageProvider.cs b/web/backend/Infrastructure/Infrastructure.FileUpload/S3FileStorageProvider.cs
new file mode 100644
index 00000000..74d4a3ea
--- /dev/null
+++ b/web/backend/Infrastructure/Infrastructure.FileUpload/S3FileStorageProvider.cs
@@ -0,0 +1,96 @@
+using Amazon.S3;
+using Amazon.S3.Model;
+using Infrastructure.Configuration;
+using Microsoft.Extensions.Configuration;
+
+namespace Infrastructure.FileUpload;
+
+///
+/// File storage service backed by an S3-compatible object store (e.g. SeaweedFS, AWS S3).
+/// Configured via application configuration (backed by environment variables).
+///
+public class S3FileStorageProvider : IFileStorageProvider, IDisposable
+{
+ private readonly string _bucket;
+ private readonly IAmazonS3 _client;
+
+ ///
+ /// Initializes a new instance of the class, reading storage
+ /// configuration (SEAWEEDFS_SERVICE_URL, SEAWEEDFS_ACCESS_KEY_ID,
+ /// SEAWEEDFS_SECRET_ACCESS_KEY, SEAWEEDFS_BUCKET) from .
+ ///
+ ///
+ /// Thrown when any of the required configuration values is not set.
+ ///
+ 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);
+ }
+
+ ///
+ 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);
+ }
+
+ ///
+ public async Task DeleteAsync(string key, CancellationToken cancellationToken = default)
+ {
+ DeleteObjectRequest request = new() { BucketName = _bucket, Key = key };
+
+ await _client.DeleteObjectAsync(request, cancellationToken);
+ }
+
+ ///
+ public string GetPresignedUrl(string key, TimeSpan expiresIn)
+ {
+ GetPreSignedUrlRequest request = new()
+ {
+ BucketName = _bucket,
+ Key = key,
+ Expires = DateTime.UtcNow.Add(expiresIn),
+ };
+
+ return _client.GetPreSignedURL(request);
+ }
+
+ ///
+ public void Dispose()
+ {
+ _client.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/web/docker-compose.dev.yaml b/web/docker-compose.dev.yaml
index 760386e9..aa8a56a9 100644
--- a/web/docker-compose.dev.yaml
+++ b/web/docker-compose.dev.yaml
@@ -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:
diff --git a/web/generate-env.sh b/web/generate-env.sh
index 89200b0e..b0c8a7e5 100755
--- a/web/generate-env.sh
+++ b/web/generate-env.sh
@@ -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"