mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Add photo upload wiring and image validation
This commit is contained in:
@@ -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/"]
|
||||
|
||||
@@ -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<IHostApplicationLifetime>();
|
||||
|
||||
@@ -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/"]
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
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 MaxFileSizeInBytes = 0x100000 * 20; // 20 MB
|
||||
|
||||
public UploadPhotoValidator()
|
||||
{
|
||||
RuleFor(x => x.UploadedById).NotEmpty().WithMessage("UploadedById is required.");
|
||||
@@ -13,16 +36,36 @@ public class UploadPhotoValidator : AbstractValidator<UploadPhotoCommand>
|
||||
|
||||
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<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);
|
||||
}
|
||||
203
web/backend/Features/Features.PhotoUpload/FileSizeConverter.cs
Normal file
203
web/backend/Features/Features.PhotoUpload/FileSizeConverter.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
#pragma warning disable CA1707 // Identifiers should not contain underscores
|
||||
#pragma warning disable IDE1006 // Naming rule violation
|
||||
// ReSharper disable InconsistentNaming
|
||||
/// <summary>Strongly-typed file size units (bytes through gibibytes) that convert and compare by underlying byte value.</summary>
|
||||
public static class FileSizes
|
||||
{
|
||||
/// <summary>Represents a file size that can be expressed as a number of bytes.</summary>
|
||||
public interface IFileSize : IComparable<IFileSize>, IEquatable<IFileSize>
|
||||
{
|
||||
/// <summary>Gets the size in bytes.</summary>
|
||||
ulong Value { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base implementation shared by all file size units. Handles comparison, equality
|
||||
/// and hashing on the underlying byte value so that any two <see cref="IFileSize"/>
|
||||
/// instances are comparable regardless of the unit they were constructed with.
|
||||
/// </summary>
|
||||
public abstract class FileSize : IFileSize
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="FileSize"/> class from a byte count.</summary>
|
||||
protected FileSize(ulong bytes) => Value = bytes;
|
||||
|
||||
/// <summary>Gets the size in bytes.</summary>
|
||||
public ulong Value { get; }
|
||||
|
||||
/// <summary>Gets the unit suffix used by <see cref="ToString"/>, e.g. "KiB".</summary>
|
||||
protected abstract string UnitSuffix { get; }
|
||||
|
||||
/// <summary>Gets the size expressed in this instance's own unit, for display purposes.</summary>
|
||||
protected abstract decimal UnitValue { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int CompareTo(IFileSize? other) =>
|
||||
other is null ? 1 : Value.CompareTo(other.Value);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(IFileSize? other) =>
|
||||
other is not null && Value == other.Value;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj) =>
|
||||
obj is IFileSize other && Equals(other);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode() => Value.GetHashCode();
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Formats using this instance's own unit, e.g. "4.52 GiB" — not the smallest common unit.</remarks>
|
||||
public override string ToString() => $"{UnitValue} {UnitSuffix}";
|
||||
|
||||
/// <summary>Determines whether two file sizes represent the same number of bytes.</summary>
|
||||
public static bool operator ==(FileSize? left, IFileSize? right) =>
|
||||
left is null ? right is null : left.Equals(right);
|
||||
|
||||
/// <summary>Determines whether two file sizes represent a different number of bytes.</summary>
|
||||
public static bool operator !=(FileSize? left, IFileSize? right) => !(left == right);
|
||||
|
||||
/// <summary>Determines whether the left file size is smaller than the right.</summary>
|
||||
public static bool operator <(FileSize left, IFileSize right) => left.CompareTo(right) < 0;
|
||||
|
||||
/// <summary>Determines whether the left file size is larger than the right.</summary>
|
||||
public static bool operator >(FileSize left, IFileSize right) => left.CompareTo(right) > 0;
|
||||
|
||||
/// <summary>Determines whether the left file size is smaller than or equal to the right.</summary>
|
||||
public static bool operator <=(FileSize left, IFileSize right) => left.CompareTo(right) <= 0;
|
||||
|
||||
/// <summary>Determines whether the left file size is larger than or equal to the right.</summary>
|
||||
public static bool operator >=(FileSize left, IFileSize right) => left.CompareTo(right) >= 0;
|
||||
|
||||
/// <summary>Adds two file sizes, returning the result in bytes.</summary>
|
||||
public static Bytes operator +(FileSize left, IFileSize right) => new(left.Value + right.Value);
|
||||
|
||||
/// <summary>Subtracts one file size from another, returning the result in bytes.</summary>
|
||||
public static Bytes operator -(FileSize left, IFileSize right) => new(left.Value - right.Value);
|
||||
|
||||
/// <summary>Converts a fractional count of a unit (e.g. 4.52 GB) into whole bytes.</summary>
|
||||
protected static ulong ToBytes(decimal unitValue, decimal bytesPerUnit) =>
|
||||
(ulong)Math.Round(unitValue * bytesPerUnit, MidpointRounding.AwayFromZero);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>Represents a size in bytes. Bytes are atomic, so this is the only unit that takes a whole number.</summary>
|
||||
public sealed class Bytes(ulong value) : FileSize(value)
|
||||
{
|
||||
/// <summary>Converts a size expressed in any unit into bytes.</summary>
|
||||
public Bytes(IFileSize other) : this(other.Value)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override string UnitSuffix => "bytes";
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override decimal UnitValue => Value;
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// <summary>Represents a size in kilobytes (decimal: 1,000 bytes).</summary>
|
||||
public sealed class KB(decimal value) : FileSize(ToBytes(value, BytesPerUnit))
|
||||
{
|
||||
private const decimal BytesPerUnit = 1_000;
|
||||
|
||||
/// <summary>Converts a size expressed in any unit into kilobytes.</summary>
|
||||
public KB(IFileSize other) : this(other.Value / BytesPerUnit)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override string UnitSuffix => "KB";
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override decimal UnitValue => Value / BytesPerUnit;
|
||||
}
|
||||
|
||||
/// <summary>Represents a size in kibibytes (binary: 1,024 bytes).</summary>
|
||||
public sealed class KiB(decimal value) : FileSize(ToBytes(value, BytesPerUnit))
|
||||
{
|
||||
private const decimal BytesPerUnit = 1_024;
|
||||
|
||||
/// <summary>Converts a size expressed in any unit into kibibytes.</summary>
|
||||
public KiB(IFileSize other) : this(other.Value / BytesPerUnit)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override string UnitSuffix => "KiB";
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override decimal UnitValue => Value / BytesPerUnit;
|
||||
}
|
||||
|
||||
/// <summary>Represents a size in megabytes (decimal: 1,000,000 bytes).</summary>
|
||||
public sealed class MB(decimal value) : FileSize(ToBytes(value, BytesPerUnit))
|
||||
{
|
||||
private const decimal BytesPerUnit = 1_000_000;
|
||||
|
||||
/// <summary>Converts a size expressed in any unit into megabytes.</summary>
|
||||
public MB(IFileSize other) : this(other.Value / BytesPerUnit)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override string UnitSuffix => "MB";
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override decimal UnitValue => Value / BytesPerUnit;
|
||||
}
|
||||
|
||||
/// <summary>Represents a size in mebibytes (binary: 1,048,576 bytes).</summary>
|
||||
public sealed class MiB(decimal value) : FileSize(ToBytes(value, BytesPerUnit))
|
||||
{
|
||||
private const decimal BytesPerUnit = 1_048_576;
|
||||
|
||||
/// <summary>Converts a size expressed in any unit into mebibytes.</summary>
|
||||
public MiB(IFileSize other) : this(other.Value / BytesPerUnit)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override string UnitSuffix => "MiB";
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override decimal UnitValue => Value / BytesPerUnit;
|
||||
}
|
||||
|
||||
/// <summary>Represents a size in gigabytes (decimal: 1,000,000,000 bytes).</summary>
|
||||
public sealed class GB(decimal value) : FileSize(ToBytes(value, BytesPerUnit))
|
||||
{
|
||||
private const decimal BytesPerUnit = 1_000_000_000;
|
||||
|
||||
/// <summary>Converts a size expressed in any unit into gigabytes.</summary>
|
||||
public GB(IFileSize other) : this(other.Value / BytesPerUnit)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override string UnitSuffix => "GB";
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override decimal UnitValue => Value / BytesPerUnit;
|
||||
}
|
||||
|
||||
/// <summary>Represents a size in gibibytes (binary: 1,073,741,824 bytes).</summary>
|
||||
public sealed class GiB(decimal value) : FileSize(ToBytes(value, BytesPerUnit))
|
||||
{
|
||||
private const decimal BytesPerUnit = 1_073_741_824;
|
||||
|
||||
/// <summary>Converts a size expressed in any unit into gibibytes.</summary>
|
||||
public GiB(IFileSize other) : this(other.Value / BytesPerUnit)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override string UnitSuffix => "GiB";
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override decimal UnitValue => Value / BytesPerUnit;
|
||||
}
|
||||
}
|
||||
#pragma warning restore CA1707
|
||||
#pragma warning restore IDE1006
|
||||
Reference in New Issue
Block a user