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"