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 7e6e3299..80912563 100644 --- a/web/backend/API/API.Core/Program.cs +++ b/web/backend/API/API.Core/Program.cs @@ -15,8 +15,11 @@ 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); @@ -123,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/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoValidator.cs b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoValidator.cs index 98236168..e2f59367 100644 --- a/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoValidator.cs +++ b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoValidator.cs @@ -1,10 +1,33 @@ 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 MaxFileSizeInBytes = 0x100000 * 20; // 20 MB + public UploadPhotoValidator() { RuleFor(x => x.UploadedById).NotEmpty().WithMessage("UploadedById is required."); @@ -13,16 +36,36 @@ public class UploadPhotoValidator : AbstractValidator RuleFor(x => x.Key).NotEmpty().WithMessage("Key is required."); - RuleFor(x => x.File.Length) - .GreaterThan(0) - .When(x => x.File is not null) - .WithMessage("File cannot be empty."); - RuleFor(x => x.File.ContentType) - .Must(contentType => - contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) - ) - .When(x => x.File is not null) + .Must(contentType => contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) .WithMessage("File must be an image."); + + // RuleFor(x => x.File.Length) + // .InclusiveBetween(1, MaxFileSizeInBytes) + // .WithMessage($"The file must be between 1 byte and {FileSizeConverter.MiBToMB(MaxFileSizeInBytes )} MB."); + + 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); +} \ No newline at end of file diff --git a/web/backend/Features/Features.PhotoUpload/FileSizeConverter.cs b/web/backend/Features/Features.PhotoUpload/FileSizeConverter.cs new file mode 100644 index 00000000..d5d61ac1 --- /dev/null +++ b/web/backend/Features/Features.PhotoUpload/FileSizeConverter.cs @@ -0,0 +1,203 @@ +#pragma warning disable CA1707 // Identifiers should not contain underscores +#pragma warning disable IDE1006 // Naming rule violation +// ReSharper disable InconsistentNaming +/// Strongly-typed file size units (bytes through gibibytes) that convert and compare by underlying byte value. +public static class FileSizes +{ + /// Represents a file size that can be expressed as a number of bytes. + public interface IFileSize : IComparable, IEquatable + { + /// Gets the size in bytes. + ulong Value { get; } + } + + /// + /// Base implementation shared by all file size units. Handles comparison, equality + /// and hashing on the underlying byte value so that any two + /// instances are comparable regardless of the unit they were constructed with. + /// + public abstract class FileSize : IFileSize + { + /// Initializes a new instance of the class from a byte count. + protected FileSize(ulong bytes) => Value = bytes; + + /// Gets the size in bytes. + public ulong Value { get; } + + /// Gets the unit suffix used by , e.g. "KiB". + protected abstract string UnitSuffix { get; } + + /// Gets the size expressed in this instance's own unit, for display purposes. + protected abstract decimal UnitValue { get; } + + /// + public int CompareTo(IFileSize? other) => + other is null ? 1 : Value.CompareTo(other.Value); + + /// + public bool Equals(IFileSize? other) => + other is not null && Value == other.Value; + + /// + public override bool Equals(object? obj) => + obj is IFileSize other && Equals(other); + + /// + public override int GetHashCode() => Value.GetHashCode(); + + /// + /// Formats using this instance's own unit, e.g. "4.52 GiB" — not the smallest common unit. + public override string ToString() => $"{UnitValue} {UnitSuffix}"; + + /// Determines whether two file sizes represent the same number of bytes. + public static bool operator ==(FileSize? left, IFileSize? right) => + left is null ? right is null : left.Equals(right); + + /// Determines whether two file sizes represent a different number of bytes. + public static bool operator !=(FileSize? left, IFileSize? right) => !(left == right); + + /// Determines whether the left file size is smaller than the right. + public static bool operator <(FileSize left, IFileSize right) => left.CompareTo(right) < 0; + + /// Determines whether the left file size is larger than the right. + public static bool operator >(FileSize left, IFileSize right) => left.CompareTo(right) > 0; + + /// Determines whether the left file size is smaller than or equal to the right. + public static bool operator <=(FileSize left, IFileSize right) => left.CompareTo(right) <= 0; + + /// Determines whether the left file size is larger than or equal to the right. + public static bool operator >=(FileSize left, IFileSize right) => left.CompareTo(right) >= 0; + + /// Adds two file sizes, returning the result in bytes. + public static Bytes operator +(FileSize left, IFileSize right) => new(left.Value + right.Value); + + /// Subtracts one file size from another, returning the result in bytes. + public static Bytes operator -(FileSize left, IFileSize right) => new(left.Value - right.Value); + + /// Converts a fractional count of a unit (e.g. 4.52 GB) into whole bytes. + protected static ulong ToBytes(decimal unitValue, decimal bytesPerUnit) => + (ulong)Math.Round(unitValue * bytesPerUnit, MidpointRounding.AwayFromZero); + + } + + /// Represents a size in bytes. Bytes are atomic, so this is the only unit that takes a whole number. + public sealed class Bytes(ulong value) : FileSize(value) + { + /// Converts a size expressed in any unit into bytes. + public Bytes(IFileSize other) : this(other.Value) + { + } + + /// + protected override string UnitSuffix => "bytes"; + + /// + protected override decimal UnitValue => Value; + + + } + + /// Represents a size in kilobytes (decimal: 1,000 bytes). + public sealed class KB(decimal value) : FileSize(ToBytes(value, BytesPerUnit)) + { + private const decimal BytesPerUnit = 1_000; + + /// Converts a size expressed in any unit into kilobytes. + public KB(IFileSize other) : this(other.Value / BytesPerUnit) + { + } + + /// + protected override string UnitSuffix => "KB"; + + /// + protected override decimal UnitValue => Value / BytesPerUnit; + } + + /// Represents a size in kibibytes (binary: 1,024 bytes). + public sealed class KiB(decimal value) : FileSize(ToBytes(value, BytesPerUnit)) + { + private const decimal BytesPerUnit = 1_024; + + /// Converts a size expressed in any unit into kibibytes. + public KiB(IFileSize other) : this(other.Value / BytesPerUnit) + { + } + + /// + protected override string UnitSuffix => "KiB"; + + /// + protected override decimal UnitValue => Value / BytesPerUnit; + } + + /// Represents a size in megabytes (decimal: 1,000,000 bytes). + public sealed class MB(decimal value) : FileSize(ToBytes(value, BytesPerUnit)) + { + private const decimal BytesPerUnit = 1_000_000; + + /// Converts a size expressed in any unit into megabytes. + public MB(IFileSize other) : this(other.Value / BytesPerUnit) + { + } + + /// + protected override string UnitSuffix => "MB"; + + /// + protected override decimal UnitValue => Value / BytesPerUnit; + } + + /// Represents a size in mebibytes (binary: 1,048,576 bytes). + public sealed class MiB(decimal value) : FileSize(ToBytes(value, BytesPerUnit)) + { + private const decimal BytesPerUnit = 1_048_576; + + /// Converts a size expressed in any unit into mebibytes. + public MiB(IFileSize other) : this(other.Value / BytesPerUnit) + { + } + + /// + protected override string UnitSuffix => "MiB"; + + /// + protected override decimal UnitValue => Value / BytesPerUnit; + } + + /// Represents a size in gigabytes (decimal: 1,000,000,000 bytes). + public sealed class GB(decimal value) : FileSize(ToBytes(value, BytesPerUnit)) + { + private const decimal BytesPerUnit = 1_000_000_000; + + /// Converts a size expressed in any unit into gigabytes. + public GB(IFileSize other) : this(other.Value / BytesPerUnit) + { + } + + /// + protected override string UnitSuffix => "GB"; + + /// + protected override decimal UnitValue => Value / BytesPerUnit; + } + + /// Represents a size in gibibytes (binary: 1,073,741,824 bytes). + public sealed class GiB(decimal value) : FileSize(ToBytes(value, BytesPerUnit)) + { + private const decimal BytesPerUnit = 1_073_741_824; + + /// Converts a size expressed in any unit into gibibytes. + public GiB(IFileSize other) : this(other.Value / BytesPerUnit) + { + } + + /// + protected override string UnitSuffix => "GiB"; + + /// + protected override decimal UnitValue => Value / BytesPerUnit; + } +} +#pragma warning restore CA1707 +#pragma warning restore IDE1006 \ No newline at end of file