feat: Implement file management and authorization features
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 3m9s

- Add RequiresPermissionAttribute for gRPC method access control.
- Create IFileManagementService interface for file upload and management.
- Implement AddProductImageCommand and handler for adding product images.
- Implement CreateNewProductsCommand and handler for creating new products with image uploads.
- Implement DeleteProductsCommand and handler for deleting products and their associations.
- Implement RemoveProductImageCommand and handler for removing product images from galleries.
- Implement UpdateProductsCommand and handler for updating product details and images.
- Create GetProductGalleryQuery and handler for retrieving product galleries.
- Implement PermissionService for role-based access control using JWT claims.
- Implement FileManagementService for handling file uploads and image optimization.
- Define gRPC service and messages for file management in fms.proto.
- Add FluentValidation for request validation in various commands.
- Create PermissionInterceptor for enforcing permissions on gRPC methods.
This commit is contained in:
masoodafar-web
2026-02-10 22:04:54 +03:30
parent f64b6be7da
commit b42d9e141d
65 changed files with 3082 additions and 2384 deletions
@@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Grpc.Net.Client" Version="2.54.0" />
<PackageReference Include="Kavenegar" Version="1.2.5" />
<PackageReference Include="MailKit" Version="4.14.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.11" />
@@ -18,10 +19,12 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.11" />
<PackageReference Include="Polly" Version="8.5.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CMSMicroservice.Application\CMSMicroservice.Application.csproj" />
<ProjectReference Include="..\CMSMicroservice.Protobuf\CMSMicroservice.Protobuf.csproj" />
</ItemGroup>
<ItemGroup>
@@ -1,9 +1,11 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Authorization;
using CMSMicroservice.Application.DayaLoanCQ.Services;
using CMSMicroservice.Infrastructure.Persistence;
using CMSMicroservice.Infrastructure.Persistence.Interceptors;
using CMSMicroservice.Infrastructure.BackgroundJobs;
using CMSMicroservice.Infrastructure.Services.Monitoring;
using CMSMicroservice.Infrastructure.Services.Authorization;
using CMSMicroservice.Infrastructure.Configuration;
using CMSMicroservice.Infrastructure.Services.Payment;
using CMSMicroservice.Infrastructure.Services.Commission;
@@ -35,6 +37,8 @@ public static class ConfigureServices
services.AddScoped<IAlertService, AlertService>();
services.AddScoped<IUserNotificationService, UserNotificationService>();
services.AddScoped<IKavenegarService, KavenegarService>();
services.AddScoped<IFileManagementService, FileManagementService>();
services.AddScoped<IPermissionService, PermissionService>();
// Daya Loan API Service - قابل تغییر بین Mock و Real
var useMockDayaApi = configuration.GetValue<bool>("DayaApi:UseMock", false);
@@ -0,0 +1,52 @@
using System.Collections.Generic;
using System.Security.Claims;
using CMSMicroservice.Application.Common.Authorization;
using Microsoft.AspNetCore.Http;
namespace CMSMicroservice.Infrastructure.Services.Authorization;
/// <summary>
/// پیاده‌سازی سرویس مجوز — نقش‌ها از JWT Claims خوانده میشن
/// </summary>
public class PermissionService : IPermissionService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public PermissionService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public Task<IReadOnlyList<string>> GetUserRolesAsync(CancellationToken cancellationToken)
{
var user = _httpContextAccessor.HttpContext?.User;
if (user?.Identity is not { IsAuthenticated: true })
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
var roles = user.Claims
.Where(c => c.Type == ClaimTypes.Role
|| string.Equals(c.Type, "role", StringComparison.OrdinalIgnoreCase))
.Select(c => c.Value)
.Where(v => !string.IsNullOrWhiteSpace(v))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<string>>(roles);
}
public async Task<bool> HasPermissionAsync(string permission, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(permission)) return true;
var roles = await GetUserRolesAsync(cancellationToken);
if (roles.Count == 0) return false;
foreach (var role in roles)
{
if (RolePermissionConfig.HasPermission(role, permission))
return true;
}
return false;
}
}
@@ -0,0 +1,139 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Protobuf.Protos.FMS;
using Google.Protobuf;
using Grpc.Net.Client;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
using System.IO;
namespace CMSMicroservice.Infrastructure.Services;
public class FileManagementService : IFileManagementService, IDisposable
{
private readonly ILogger<FileManagementService> _logger;
private readonly FileInfoContract.FileInfoContractClient _client;
private readonly GrpcChannel _channel;
private const int MainImageMaxWidth = 1200;
private const int MainImageMaxHeight = 1200;
private const int ThumbnailMaxWidth = 300;
private const int ThumbnailMaxHeight = 300;
private const int JpegQuality = 75;
public FileManagementService(IConfiguration configuration, ILogger<FileManagementService> logger)
{
_logger = logger;
var fmsAddress = configuration["FMS:Address"] ?? "https://dl.afrino.co";
_channel = GrpcChannel.ForAddress(fmsAddress, new GrpcChannelOptions
{
MaxReceiveMessageSize = 100 * 1024 * 1024, // 100 MB
MaxSendMessageSize = 100 * 1024 * 1024
});
_client = new FileInfoContract.FileInfoContractClient(_channel);
}
public async Task<string?> UploadFileAsync(string directory, byte[] fileBytes, string mime, string? fileName,
CancellationToken cancellationToken = default)
{
try
{
var request = new CreateNewFileInfoRequest
{
Directory = directory,
File = ByteString.CopyFrom(fileBytes),
Mime = mime,
IsBase64 = false
};
if (!string.IsNullOrWhiteSpace(fileName))
request.FileName = fileName;
var response = await _client.CreateNewFileInfoAsync(request, cancellationToken: cancellationToken);
if (response != null && !string.IsNullOrWhiteSpace(response.File))
{
_logger.LogInformation("File uploaded to FMS successfully. Id: {Id}, Path: {Path}", response.Id, response.File);
return response.File;
}
_logger.LogWarning("FMS upload returned null or empty path for file: {FileName}", fileName);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading file to FMS. Directory: {Directory}, FileName: {FileName}", directory, fileName);
return null;
}
}
public async Task<(string? MainImagePath, string? ThumbnailPath)> UploadImageWithThumbnailAsync(
string directory, byte[] fileBytes, string mime, string? fileName,
CancellationToken cancellationToken = default)
{
string? mainImagePath = null;
string? thumbnailPath = null;
try
{
// Optimize main image
var mainImageBytes = await OptimizeImageAsync(fileBytes, MainImageMaxWidth, MainImageMaxHeight);
mainImagePath = await UploadFileAsync(directory, mainImageBytes, "image/jpeg", fileName, cancellationToken);
// Create and upload thumbnail
var thumbnailBytes = await OptimizeImageAsync(fileBytes, ThumbnailMaxWidth, ThumbnailMaxHeight);
var thumbFileName = fileName != null ? $"thumb_{fileName}" : null;
thumbnailPath = await UploadFileAsync($"{directory}/Thumbnails", thumbnailBytes, "image/jpeg", thumbFileName, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing and uploading image with thumbnail. Directory: {Directory}", directory);
}
return (mainImagePath, thumbnailPath);
}
public async Task<bool> DeleteFileAsync(long fileId, CancellationToken cancellationToken = default)
{
try
{
var request = new DeleteFileInfoRequest { Id = fileId };
var response = await _client.DeleteFileInfoAsync(request, cancellationToken: cancellationToken);
return response?.Success ?? false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting file from FMS. FileId: {FileId}", fileId);
return false;
}
}
private static async Task<byte[]> OptimizeImageAsync(byte[] imageBytes, int maxWidth, int maxHeight)
{
using var image = Image.Load(imageBytes);
// Only resize if larger than max dimensions
if (image.Width > maxWidth || image.Height > maxHeight)
{
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(maxWidth, maxHeight),
Mode = ResizeMode.Max
}));
}
using var ms = new MemoryStream();
await image.SaveAsJpegAsync(ms, new JpegEncoder { Quality = JpegQuality });
return ms.ToArray();
}
public void Dispose()
{
_channel?.Dispose();
}
}