From 158eb5b7215cbf7049ff133e718c5311fb22143e Mon Sep 17 00:00:00 2001 From: haik Date: Mon, 15 Sep 2025 22:05:21 +0400 Subject: [PATCH 1/2] Little fixes on maintenance --- src/SharedKernel/Maintenance/MaintenanceCachePoller.cs | 2 ++ src/SharedKernel/Maintenance/MaintenanceMode.cs | 9 +-------- src/SharedKernel/Maintenance/MaintenanceState.cs | 6 +++++- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/SharedKernel/Maintenance/MaintenanceCachePoller.cs b/src/SharedKernel/Maintenance/MaintenanceCachePoller.cs index e3b1105..2ce6014 100644 --- a/src/SharedKernel/Maintenance/MaintenanceCachePoller.cs +++ b/src/SharedKernel/Maintenance/MaintenanceCachePoller.cs @@ -3,6 +3,8 @@ namespace SharedKernel.Maintenance; +//This is for local cache entity to poll the maintenance mode from distributed cache +//This should be removed then L1 + L2 cache is implemented in hybrid cache internal class MaintenanceCachePoller(HybridCache hybridCache, MaintenanceState state) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) diff --git a/src/SharedKernel/Maintenance/MaintenanceMode.cs b/src/SharedKernel/Maintenance/MaintenanceMode.cs index c6cefed..98a93d0 100644 --- a/src/SharedKernel/Maintenance/MaintenanceMode.cs +++ b/src/SharedKernel/Maintenance/MaintenanceMode.cs @@ -5,11 +5,4 @@ public enum MaintenanceMode Disabled = 0, EnabledForClients = 1, EnabledForAll = 2 -} - -//This is for local cache entity to poll the maintenance mode from distributed cache -//This should be removed then L1 + L2 cache is implemented in hybrid cache - -// This is a local cache entity to hold the maintenance mode in memory -// This should be removed then L1 + L2 cache is implemented in hybrid cache -// thread-safe local snapshot \ No newline at end of file +} \ No newline at end of file diff --git a/src/SharedKernel/Maintenance/MaintenanceState.cs b/src/SharedKernel/Maintenance/MaintenanceState.cs index ab02313..bffb218 100644 --- a/src/SharedKernel/Maintenance/MaintenanceState.cs +++ b/src/SharedKernel/Maintenance/MaintenanceState.cs @@ -2,6 +2,9 @@ namespace SharedKernel.Maintenance; +// This is a local cache entity to hold the maintenance mode in memory +// This should be removed then L1 + L2 cache is implemented in hybrid cache +// thread-safe local snapshot public sealed class MaintenanceState(HybridCache cache) { private const string Key = "maintenance-mode"; @@ -16,7 +19,6 @@ public MaintenanceMode Mode // for admin/API to change mode (updates local immediately, then L2) public async Task SetModeAsync(MaintenanceMode mode, CancellationToken ct = default) { - Mode = mode; await cache.SetAsync( Key, new MaintenanceCacheEntity @@ -30,6 +32,8 @@ await cache.SetAsync( Flags = null }, cancellationToken: ct); + + Mode = mode; } // used by the poller only From e5b33b868b53d661d90e5fcc74214709ae290343 Mon Sep 17 00:00:00 2001 From: haik Date: Thu, 18 Sep 2025 01:05:33 +0400 Subject: [PATCH 2/2] Validator for files has breaking changes, but very easy to migrate (just name change) --- Readme.md | 56 ++++++++++++---- SharedKernel.Demo/VerticalFeature.cs | 54 +++++++++++++++ src/SharedKernel/SharedKernel.csproj | 8 +-- .../Behaviors/ValidationAggregation.cs | 65 +++++++++++++++++++ .../ValidationBehaviorWithResponse.cs | 13 ++-- .../ValidationBehaviorWithoutResponse.cs | 13 ++-- .../Validators/FileSizeValidator.cs | 31 --------- .../Validators/FileTypeValidator.cs | 40 ------------ .../Validators/Files/CommonFileSets.cs | 17 +++++ .../Validators/Files/Exts.cs | 16 +++++ .../Files/FileExtensionValidator.cs | 36 ++++++++++ .../Files/FileMaxSizeMbValidator.cs | 30 +++++++++ .../Files/FilesEachExtensionValidator.cs | 44 +++++++++++++ .../Files/FilesEachMaxSizeMbValidator.cs | 38 +++++++++++ .../Files/FilesMaxCountValidator.cs | 24 +++++++ .../Files/FilesTotalMaxSizeMbValidator.cs | 36 ++++++++++ .../Validators/Files/FormFileFilter.cs | 47 ++++++++++++++ .../Validators/ValidatorExtensions.cs | 49 +++++++++++--- 18 files changed, 504 insertions(+), 113 deletions(-) create mode 100644 SharedKernel.Demo/VerticalFeature.cs create mode 100644 src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationAggregation.cs delete mode 100644 src/SharedKernel/ValidatorAndMediatR/Validators/FileSizeValidator.cs delete mode 100644 src/SharedKernel/ValidatorAndMediatR/Validators/FileTypeValidator.cs create mode 100644 src/SharedKernel/ValidatorAndMediatR/Validators/Files/CommonFileSets.cs create mode 100644 src/SharedKernel/ValidatorAndMediatR/Validators/Files/Exts.cs create mode 100644 src/SharedKernel/ValidatorAndMediatR/Validators/Files/FileExtensionValidator.cs create mode 100644 src/SharedKernel/ValidatorAndMediatR/Validators/Files/FileMaxSizeMbValidator.cs create mode 100644 src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesEachExtensionValidator.cs create mode 100644 src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesEachMaxSizeMbValidator.cs create mode 100644 src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesMaxCountValidator.cs create mode 100644 src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesTotalMaxSizeMbValidator.cs create mode 100644 src/SharedKernel/ValidatorAndMediatR/Validators/Files/FormFileFilter.cs diff --git a/Readme.md b/Readme.md index 0889bce..0e9e889 100644 --- a/Readme.md +++ b/Readme.md @@ -473,18 +473,50 @@ handler. If validation fails, a `BadRequestException` is thrown with the validat ### FluentValidation Extensions -The package includes extension methods to simplify common validation scenarios: - -- File Validations: - - HasMaxFileSize(maxFileSizeInMb): Validates that an uploaded file does not exceed the specified maximum size. - - FileTypeIsOneOf(allowedFileExtensions): Validates that the uploaded file has one of the allowed file - extensions. -- String Validations: - - IsValidJson(): Validates that a string is a valid JSON. - - IsXssSanitized(): Validates that a string is sanitized against XSS attacks. - - IsEmail(): Validates that a string is a valid email address. Native one is not working correctly. - - IsPhoneNumber(): Validates that a string is a valid phone number. Format requires area code to be in `()`. - - IsEmailOrPhoneNumber(): Validates that a string is either a valid email address or a valid phone number. +We ship lightweight validators and presets for common scenarios, including file uploads and string checks. +All file rules use name/extension checks only (simple + fast). Deep validation still happens inside your storage layer. + +**File upload validators** + +**Single file (`IFormFile`)** + +```csharp +RuleFor(x => x.Avatar) + .HasMaxSizeMb(6) // size cap in MB + .ExtensionIn(".jpg", ".jpeg", ".png"); // or use a preset set below + +``` + +**File collection (`IFormFileCollection`)** + +```csharp +RuleFor(x => x.Docs) + .MaxCount(10) // number of files + .EachHasMaxSizeMb(10) // per-file size cap (MB) + .EachExtensionIn(CommonFileSets.Documents) + .TotalSizeMaxMb(50); // sum of all files (MB) +``` + +**Presets** + +```csharp +using SharedKernel.ValidatorAndMediatR.Validators.Files; + +CommonFileSets.Images // .jpg, .jpeg, .png, .webp, .heic, .heif, .svg, .avif +CommonFileSets.Documents // .pdf, .txt, .csv, .json, .xml, .yaml, .yml, .md, .docx, .xlsx, .pptx, .odt, .ods, .odp +CommonFileSets.ImagesAndAnimations // Images + .gif +CommonFileSets.ImagesAndDocuments // Images + Documents +``` + +**String validators** + +```csharp +RuleFor(x => x.Email).IsEmail(); +RuleFor(x => x.Phone).IsPhoneNumber(); +RuleFor(x => x.Contact).IsEmailOrPhoneNumber(); // alias: IsPhoneNumberOrEmail +RuleFor(x => x.PayloadJson).IsValidJson(); +RuleFor(x => x.Content).IsXssSanitized(); +``` ## Cors diff --git a/SharedKernel.Demo/VerticalFeature.cs b/SharedKernel.Demo/VerticalFeature.cs new file mode 100644 index 0000000..5654763 --- /dev/null +++ b/SharedKernel.Demo/VerticalFeature.cs @@ -0,0 +1,54 @@ +using FluentMinimalApiMapper; +using FluentValidation; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using SharedKernel.ValidatorAndMediatR.Validators; +using SharedKernel.ValidatorAndMediatR.Validators.Files; +using ICommand = SharedKernel.ValidatorAndMediatR.ICommand; + +namespace SharedKernel.Demo; + +public class VerticalFeature : IEndpoint +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + app.MapPost("vertical", + async ([AsParameters] VerticalCommand command, [FromServices] ISender sender) => + { + await sender.Send(command); + return Results.Ok(); + }) + .WithTags("vertical") + .DisableAntiforgery(); + } +} + +public class VerticalCommand : ICommand +{ + public IFormFile? Avatar { get; init; } + public IFormFileCollection? Docs { get; init; } +} + +public class VerticalCommandHandler : IRequestHandler +{ + public Task Handle(VerticalCommand request, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} + +public class VerticalCommandValidator : AbstractValidator +{ + public VerticalCommandValidator() + { + RuleFor(x => x.Avatar) + .HasMaxSizeMb(3) + .ExtensionIn(".jpg", ".png"); + + RuleFor(x => x.Docs) + .MaxCount(4) + .EachHasMaxSizeMb(4) + .EachExtensionIn(CommonFileSets.Documents) + .TotalSizeMaxMb(10); + } +} \ No newline at end of file diff --git a/src/SharedKernel/SharedKernel.csproj b/src/SharedKernel/SharedKernel.csproj index 29f594c..5e4e997 100644 --- a/src/SharedKernel/SharedKernel.csproj +++ b/src/SharedKernel/SharedKernel.csproj @@ -8,13 +8,13 @@ Readme.md Pandatech MIT - 1.7.0 + 1.8.0 Pandatech.SharedKernel Pandatech Shared Kernel Library Pandatech, shared kernel, library, OpenAPI, Swagger, utilities, scalar Pandatech.SharedKernel provides centralized configurations, utilities, and extensions for ASP.NET Core projects. For more information refere to readme.md document. https://github.com/PandaTechAM/be-lib-sharedkernel - Maintenance mode has been added + Validator for files has breaking changes, but very easy to migrate (just name change) @@ -46,14 +46,14 @@ - + - + diff --git a/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationAggregation.cs b/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationAggregation.cs new file mode 100644 index 0000000..e7adc5e --- /dev/null +++ b/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationAggregation.cs @@ -0,0 +1,65 @@ +using FluentValidation.Results; +using ResponseCrafter.HttpExceptions; + +namespace SharedKernel.ValidatorAndMediatR.Behaviors; + +internal static class ValidationAggregation +{ + public static (string? Message, Dictionary? Errors) ToMessageAndErrors( + IEnumerable failures) + { + var globalMessages = new List(); + var errors = new Dictionary(StringComparer.Ordinal); + + foreach (var f in failures) + { + var prop = (f.PropertyName ?? string.Empty).Trim(); + + // Global messages (no property name) -> headline message + if (string.IsNullOrEmpty(prop)) + { + if (!string.IsNullOrWhiteSpace(f.ErrorMessage)) + { + globalMessages.Add(f.ErrorMessage); + } + + continue; + } + + // Per-property errors: keep the first message per property for brevity + if (!errors.ContainsKey(prop)) + { + errors[prop] = f.ErrorMessage; + } + } + + var message = globalMessages.Count > 0 + ? string.Join(" | ", globalMessages.Distinct()) + : null; + + return (message, errors.Count > 0 ? errors : null); + } + + public static BadRequestException ToBadRequestException(IEnumerable failures) + { + var (message, errors) = ToMessageAndErrors(failures); + + if (!string.IsNullOrWhiteSpace(message) && errors is not null) + { + return new BadRequestException(message, errors); + } + + if (!string.IsNullOrWhiteSpace(message)) + { + return new BadRequestException(message); + } + + if (errors is not null) + { + return new BadRequestException(errors); + } + + // Should not happen if we had failures, but just in case: + return new BadRequestException("validation_failed"); + } +} \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithResponse.cs b/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithResponse.cs index 44c7f85..fb0de6d 100644 --- a/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithResponse.cs +++ b/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithResponse.cs @@ -18,20 +18,17 @@ public async Task Handle(TRequest request, } var context = new ValidationContext(request); + var results = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken))); - var validationResults = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken))); - var failures = validationResults.SelectMany(r => r.Errors) - .Where(f => f != null) - .ToList(); + var failures = results.SelectMany(r => r.Errors) + .Where(f => f is not null) + .ToList(); if (failures.Count == 0) { return await next(cancellationToken); } - var errors = failures.GroupBy(e => e.PropertyName, e => e.ErrorMessage) - .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.First()); - - throw new BadRequestException(errors); + throw ValidationAggregation.ToBadRequestException(failures); } } \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithoutResponse.cs b/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithoutResponse.cs index 1e83092..d7a6547 100644 --- a/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithoutResponse.cs +++ b/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithoutResponse.cs @@ -18,20 +18,17 @@ public async Task Handle(TRequest request, } var context = new ValidationContext(request); + var results = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken))); - var validationResults = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken))); - var failures = validationResults.SelectMany(r => r.Errors) - .Where(f => f != null) - .ToList(); + var failures = results.SelectMany(r => r.Errors) + .Where(f => f is not null) + .ToList(); if (failures.Count == 0) { return await next(cancellationToken); } - var errors = failures.GroupBy(e => e.PropertyName, e => e.ErrorMessage) - .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.First()); - - throw new BadRequestException(errors); + throw ValidationAggregation.ToBadRequestException(failures); } } \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Validators/FileSizeValidator.cs b/src/SharedKernel/ValidatorAndMediatR/Validators/FileSizeValidator.cs deleted file mode 100644 index 7cbbd84..0000000 --- a/src/SharedKernel/ValidatorAndMediatR/Validators/FileSizeValidator.cs +++ /dev/null @@ -1,31 +0,0 @@ -using FluentValidation; -using FluentValidation.Validators; -using Microsoft.AspNetCore.Http; - -namespace SharedKernel.ValidatorAndMediatR.Validators; - -public class FileSizeValidator(int maxFileSizeInMb) : PropertyValidator -{ - public override string Name => "FileSizeValidator"; - - public override bool IsValid(ValidationContext context, IFormFile? value) - { - if (value == null) - { - return true; - } - - if (value.Length <= maxFileSizeInMb * 1024 * 1024) - { - return true; - } - - context.AddFailure($"File size exceeds the maximum allowed size of {maxFileSizeInMb} MB."); - return false; - } - - protected override string GetDefaultMessageTemplate(string errorCode) - { - return $"File size must not exceed {maxFileSizeInMb} MB."; - } -} \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Validators/FileTypeValidator.cs b/src/SharedKernel/ValidatorAndMediatR/Validators/FileTypeValidator.cs deleted file mode 100644 index f94d9cb..0000000 --- a/src/SharedKernel/ValidatorAndMediatR/Validators/FileTypeValidator.cs +++ /dev/null @@ -1,40 +0,0 @@ -using FluentValidation; -using FluentValidation.Validators; -using Microsoft.AspNetCore.Http; - -namespace SharedKernel.ValidatorAndMediatR.Validators; - -public class FileTypeValidator(params string[] allowedExtensions) : PropertyValidator -{ - public override string Name => "FileTypeValidator"; - - public override bool IsValid(ValidationContext context, IFormFile? value) - { - if (value is null) - { - return true; - } - - if (string.IsNullOrWhiteSpace(value.FileName)) - { - context.AddFailure("File has no name."); - return false; - } - - var fileExtension = Path.GetExtension(value.FileName) - .ToLowerInvariant(); - - if (allowedExtensions.Contains(fileExtension, StringComparer.OrdinalIgnoreCase)) - { - return true; - } - - context.AddFailure($"Invalid file type. Allowed file types are: {string.Join(", ", allowedExtensions)}"); - return false; - } - - protected override string GetDefaultMessageTemplate(string errorCode) - { - return $"Invalid file type. Allowed file types are: {string.Join(", ", allowedExtensions)}"; - } -} \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Validators/Files/CommonFileSets.cs b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/CommonFileSets.cs new file mode 100644 index 0000000..f3b9f5f --- /dev/null +++ b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/CommonFileSets.cs @@ -0,0 +1,17 @@ +namespace SharedKernel.ValidatorAndMediatR.Validators.Files; + +public static class CommonFileSets +{ + public static readonly string[] ImagesAndAnimations = + [".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif", ".svg", ".avif"]; + + public static readonly string[] Documents = + [ + ".pdf", ".txt", ".csv", ".json", ".xml", ".yaml", ".yml", ".md", ".docx", ".xlsx", ".pptx", ".odt", ".ods", ".odp" + ]; + + public static readonly string[] Images = [".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif", ".svg", ".avif"]; + + public static readonly string[] ImagesAndDocuments = Images.Concat(Documents) + .ToArray(); +} \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Validators/Files/Exts.cs b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/Exts.cs new file mode 100644 index 0000000..7596960 --- /dev/null +++ b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/Exts.cs @@ -0,0 +1,16 @@ +namespace SharedKernel.ValidatorAndMediatR.Validators.Files; + +internal static class Exts +{ + public static string Norm(string ext) + { + if (string.IsNullOrWhiteSpace(ext)) + { + return ""; + } + + return ext.StartsWith('.') ? ext.ToLowerInvariant() : "." + ext.ToLowerInvariant(); + } + + public static string GetExt(string fileName) => Norm(Path.GetExtension(fileName)); +} \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FileExtensionValidator.cs b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FileExtensionValidator.cs new file mode 100644 index 0000000..1ff4b6d --- /dev/null +++ b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FileExtensionValidator.cs @@ -0,0 +1,36 @@ +using FluentValidation; +using FluentValidation.Validators; +using Microsoft.AspNetCore.Http; + +namespace SharedKernel.ValidatorAndMediatR.Validators.Files; + +public sealed class FileExtensionValidator(params string[] allowedExts) : PropertyValidator +{ + private readonly HashSet _allowed = new(allowedExts.Select(Exts.Norm), StringComparer.OrdinalIgnoreCase); + public override string Name => "FileExtension"; + + public override bool IsValid(ValidationContext context, IFormFile? value) + { + if (value is null) + { + return true; + } + + var ext = Exts.GetExt(value.FileName); + if (string.IsNullOrEmpty(ext)) + { + context.AddFailure("File has no extension."); + return false; + } + + if (_allowed.Count == 0 || _allowed.Contains(ext)) + { + return true; + } + + context.AddFailure($"File '{value.FileName}' type is not allowed. Allowed: {string.Join(", ", _allowed)}"); + return false; + } + + protected override string GetDefaultMessageTemplate(string errorCode) => "file_type_not_allowed"; +} \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FileMaxSizeMbValidator.cs b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FileMaxSizeMbValidator.cs new file mode 100644 index 0000000..962b7ec --- /dev/null +++ b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FileMaxSizeMbValidator.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using FluentValidation; +using FluentValidation.Validators; +using Microsoft.AspNetCore.Http; + +namespace SharedKernel.ValidatorAndMediatR.Validators.Files; + +public sealed class FileMaxSizeMbValidator(int maxMb) : PropertyValidator +{ + private readonly long _maxBytes = maxMb * 1024L * 1024L; + public override string Name => "FileMaxSizeMb"; + + public override bool IsValid(ValidationContext context, IFormFile? value) + { + if (value is null) + { + return true; + } + + if (value.Length <= _maxBytes) + { + return true; + } + + context.AddFailure($"File '{value.FileName}' exceeds {maxMb.ToString(CultureInfo.InvariantCulture)} MB."); + return false; + } + + protected override string GetDefaultMessageTemplate(string errorCode) => "file_too_large"; +} \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesEachExtensionValidator.cs b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesEachExtensionValidator.cs new file mode 100644 index 0000000..587444e --- /dev/null +++ b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesEachExtensionValidator.cs @@ -0,0 +1,44 @@ +using FluentValidation; +using FluentValidation.Validators; +using Microsoft.AspNetCore.Http; + +namespace SharedKernel.ValidatorAndMediatR.Validators.Files; + +public sealed class FilesEachExtensionValidator(params string[] allowedExts) + : PropertyValidator +{ + private readonly HashSet _allowed = new(allowedExts.Select(Exts.Norm), StringComparer.OrdinalIgnoreCase); + public override string Name => "FilesEachExtension"; + + public override bool IsValid(ValidationContext context, IFormFileCollection? value) + { + if (_allowed.Count == 0) + { + return true; + } + + var files = FormFileFilter.ForProperty(value, context.PropertyPath); + if (files.Count == 0) + { + return true; + } + + var ok = true; + for (var i = 0; i < files.Count; i++) + { + var f = files[i]; + var ext = Exts.GetExt(f.FileName); + if (_allowed.Contains(ext)) + { + continue; + } + + context.AddFailure($"File #{i + 1} '{f.FileName}' type is not allowed. Allowed: {string.Join(", ", _allowed)}"); + ok = false; + } + + return ok; + } + + protected override string GetDefaultMessageTemplate(string errorCode) => "file_type_not_allowed"; +} \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesEachMaxSizeMbValidator.cs b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesEachMaxSizeMbValidator.cs new file mode 100644 index 0000000..c15324f --- /dev/null +++ b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesEachMaxSizeMbValidator.cs @@ -0,0 +1,38 @@ +using System.Globalization; +using FluentValidation; +using FluentValidation.Validators; +using Microsoft.AspNetCore.Http; + +namespace SharedKernel.ValidatorAndMediatR.Validators.Files; + +public sealed class FilesEachMaxSizeMbValidator(int maxMb) : PropertyValidator +{ + private readonly long _maxBytes = maxMb * 1024L * 1024L; + public override string Name => "FilesEachMaxSizeMb"; + + public override bool IsValid(ValidationContext context, IFormFileCollection? value) + { + var files = FormFileFilter.ForProperty(value, context.PropertyPath); + if (files.Count == 0) + { + return true; + } + + var ok = true; + for (var i = 0; i < files.Count; i++) + { + var f = files[i]; + if (f.Length <= _maxBytes) + { + continue; + } + + context.AddFailure($"File #{i + 1} '{f.FileName}' exceeds {maxMb.ToString(CultureInfo.InvariantCulture)} MB."); + ok = false; + } + + return ok; + } + + protected override string GetDefaultMessageTemplate(string errorCode) => "file_too_large"; +} \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesMaxCountValidator.cs b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesMaxCountValidator.cs new file mode 100644 index 0000000..71f8705 --- /dev/null +++ b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesMaxCountValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using FluentValidation.Validators; +using Microsoft.AspNetCore.Http; + +namespace SharedKernel.ValidatorAndMediatR.Validators.Files; + +public sealed class FilesMaxCountValidator(int maxCount) : PropertyValidator +{ + public override string Name => "FilesMaxCount"; + + public override bool IsValid(ValidationContext context, IFormFileCollection? value) + { + var files = FormFileFilter.ForProperty(value, context.PropertyPath); + if (files.Count <= maxCount) + { + return true; + } + + context.AddFailure($"No more than {maxCount} files are allowed."); + return false; + } + + protected override string GetDefaultMessageTemplate(string errorCode) => "too_many_files"; +} \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesTotalMaxSizeMbValidator.cs b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesTotalMaxSizeMbValidator.cs new file mode 100644 index 0000000..c5cc90a --- /dev/null +++ b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FilesTotalMaxSizeMbValidator.cs @@ -0,0 +1,36 @@ +using System.Globalization; +using FluentValidation; +using FluentValidation.Validators; +using Microsoft.AspNetCore.Http; + +namespace SharedKernel.ValidatorAndMediatR.Validators.Files; + +public sealed class FilesTotalMaxSizeMbValidator(int maxMb) : PropertyValidator +{ + private readonly long _maxBytes = maxMb * 1024L * 1024L; + public override string Name => "FilesTotalMaxSizeMb"; + + public override bool IsValid(ValidationContext context, IFormFileCollection? value) + { + var files = FormFileFilter.ForProperty(value, context.PropertyPath); + if (files.Count == 0) + { + return true; + } + + long sum = 0; + foreach (var f in files) + { + sum += f.Length; + if (sum > _maxBytes) + { + context.AddFailure($"Total upload size exceeds {maxMb.ToString(CultureInfo.InvariantCulture)} MB."); + return false; + } + } + + return true; + } + + protected override string GetDefaultMessageTemplate(string errorCode) => "total_upload_too_large"; +} \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FormFileFilter.cs b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FormFileFilter.cs new file mode 100644 index 0000000..c03d927 --- /dev/null +++ b/src/SharedKernel/ValidatorAndMediatR/Validators/Files/FormFileFilter.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Http; + +namespace SharedKernel.ValidatorAndMediatR.Validators.Files; + +// IFormFileCollection is bound from the entire Request.Form.Files, not filtered by the property name. Without this code validations fail if one request has multiple file upload properties. +internal static class FormFileFilter +{ + public static IReadOnlyList ForProperty(IFormFileCollection? files, string propertyName) + { + if (files is null || files.Count == 0) + { + return []; + } + + // Match exact field name (e.g., "Docs") OR indexed/nested names (Docs[0], Docs.0, command.Docs..., etc.) + var prefix = propertyName + "["; + var dot = propertyName + "."; + var list = new List(files.Count); + + foreach (var f in files) + { + var name = f.Name; + if (name.Equals(propertyName, StringComparison.OrdinalIgnoreCase) + || name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + || name.StartsWith(dot, StringComparison.OrdinalIgnoreCase) + || EndsWithSegment(name, propertyName)) + { + list.Add(f); + } + } + + return list; + + // Handles cases like "command.Docs[0]" or "request.Docs" + static bool EndsWithSegment(string name, string segment) + { + if (name.Length < segment.Length) return false; + var i = name.LastIndexOf(segment, StringComparison.OrdinalIgnoreCase); + if (i < 0) return false; + + var startOk = i == 0 || name[i - 1] is '.'; // previous is a dot boundary + var endIdx = i + segment.Length; + var endOk = endIdx == name.Length || name[endIdx] is '.' or '['; + return startOk && endOk; + } + } +} \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Validators/ValidatorExtensions.cs b/src/SharedKernel/ValidatorAndMediatR/Validators/ValidatorExtensions.cs index dbfdf9a..0c2634d 100644 --- a/src/SharedKernel/ValidatorAndMediatR/Validators/ValidatorExtensions.cs +++ b/src/SharedKernel/ValidatorAndMediatR/Validators/ValidatorExtensions.cs @@ -1,6 +1,7 @@ using FluentValidation; using Microsoft.AspNetCore.Http; using SharedKernel.Helpers; +using SharedKernel.ValidatorAndMediatR.Validators.Files; namespace SharedKernel.ValidatorAndMediatR.Validators; @@ -30,25 +31,53 @@ public static class ValidatorExtensions return ruleBuilder.IsEmailOrPhoneNumber(); } - public static IRuleBuilderOptions HasMaxFileSize(this IRuleBuilder ruleBuilder, - int maxFileSizeInMb) + public static IRuleBuilderOptions IsValidJson(this IRuleBuilder ruleBuilder) { - return ruleBuilder.SetValidator(new FileSizeValidator(maxFileSizeInMb)); + return ruleBuilder.SetValidator(new JsonValidator()); } - public static IRuleBuilderOptions FileTypeIsOneOf(this IRuleBuilder ruleBuilder, - params string[] allowedFileExtensions) + public static IRuleBuilderOptions IsXssSanitized(this IRuleBuilder ruleBuilder) { - return ruleBuilder.SetValidator(new FileTypeValidator(allowedFileExtensions)); + return ruleBuilder.SetValidator(new XssSanitizationValidator()); } - public static IRuleBuilderOptions IsValidJson(this IRuleBuilder ruleBuilder) + // Single file + public static IRuleBuilderOptions HasMaxSizeMb(this IRuleBuilder rb, int maxMb) { - return ruleBuilder.SetValidator(new JsonValidator()); + return rb.SetValidator(new FileMaxSizeMbValidator(maxMb)); } - public static IRuleBuilderOptions IsXssSanitized(this IRuleBuilder ruleBuilder) + public static IRuleBuilderOptions ExtensionIn(this IRuleBuilder rb, + params string[] allowedExts) { - return ruleBuilder.SetValidator(new XssSanitizationValidator()); + return rb.SetValidator(new FileExtensionValidator(allowedExts)); + } + + // Collection files + public static IRuleBuilderOptions EachHasMaxSizeMb( + this IRuleBuilder rb, + int maxMb) + { + return rb.SetValidator(new FilesEachMaxSizeMbValidator(maxMb)); + } + + public static IRuleBuilderOptions EachExtensionIn( + this IRuleBuilder rb, + params string[] allowedExts) + { + return rb.SetValidator(new FilesEachExtensionValidator(allowedExts)); + } + + public static IRuleBuilderOptions TotalSizeMaxMb( + this IRuleBuilder rb, + int maxMb) + { + return rb.SetValidator(new FilesTotalMaxSizeMbValidator(maxMb)); + } + + public static IRuleBuilderOptions MaxCount(this IRuleBuilder rb, + int maxCount) + { + return rb.SetValidator(new FilesMaxCountValidator(maxCount)); } } \ No newline at end of file