diff --git a/Carbon.Application/Contracts/IFacialUseCase.cs b/Carbon.Application/Contracts/IFacialUseCase.cs new file mode 100644 index 0000000..b7e3b77 --- /dev/null +++ b/Carbon.Application/Contracts/IFacialUseCase.cs @@ -0,0 +1,9 @@ +using Carbon.Application.Dtos.Facial; + +namespace Carbon.Application.Contracts; + +public interface IFacialUseCase +{ + Task Register(Guid userId, FaceImageRequestDto request, CancellationToken ct); + Task Verify(Guid userId, FaceImageRequestDto request, CancellationToken ct); +} \ No newline at end of file diff --git a/Carbon.Application/Contracts/IUsersUseCase.cs b/Carbon.Application/Contracts/IUsersUseCase.cs new file mode 100644 index 0000000..59ee04a --- /dev/null +++ b/Carbon.Application/Contracts/IUsersUseCase.cs @@ -0,0 +1,9 @@ +using Carbon.Application.Dtos.Users; + +namespace Carbon.Application.Contracts; + +public interface IUsersUseCase +{ + Task> GetAll(CancellationToken ct); + Task ChangeRole(Guid currentUserId, Guid userId, ChangeRoleRequestDto request, CancellationToken ct); +} diff --git a/Carbon.Application/Dtos/Facial/FaceImageRequestDto.cs b/Carbon.Application/Dtos/Facial/FaceImageRequestDto.cs new file mode 100644 index 0000000..8bda51f --- /dev/null +++ b/Carbon.Application/Dtos/Facial/FaceImageRequestDto.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; + +namespace Carbon.Application.Dtos.Facial; + +// Foto em base64. Aceita base64 puro ou "data:image/jpeg;base64,..." (formato da webcam) +public record FaceImageRequestDto( + [Required(ErrorMessage = "ImageBase64 field is required!", AllowEmptyStrings = false)] + string ImageBase64); \ No newline at end of file diff --git a/Carbon.Application/Dtos/Facial/FaceResponseDtos.cs b/Carbon.Application/Dtos/Facial/FaceResponseDtos.cs new file mode 100644 index 0000000..aaa1942 --- /dev/null +++ b/Carbon.Application/Dtos/Facial/FaceResponseDtos.cs @@ -0,0 +1,5 @@ +namespace Carbon.Application.Dtos.Facial; + +public record FaceRegisterResponseDto(Guid UserId, DateTime RegisteredAt); + +public record FaceVerificationResponseDto(Guid UserId, bool IsMatch, float Similarity, DateTime VerifiedAt); \ No newline at end of file diff --git a/Carbon.Application/Dtos/Users/UserDtos.cs b/Carbon.Application/Dtos/Users/UserDtos.cs new file mode 100644 index 0000000..4466970 --- /dev/null +++ b/Carbon.Application/Dtos/Users/UserDtos.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Carbon.Application.Dtos.Users; + +public record UserSummaryDto(Guid Id, string Username, string Email, string Role, bool HasFace, DateTime CreatedAt); + +public record ChangeRoleRequestDto( + [Required(ErrorMessage = "Role field is required!", AllowEmptyStrings = false)] + string Role); diff --git a/Carbon.Application/UseCases/FacialUseCase.cs b/Carbon.Application/UseCases/FacialUseCase.cs new file mode 100644 index 0000000..c5a9d8e --- /dev/null +++ b/Carbon.Application/UseCases/FacialUseCase.cs @@ -0,0 +1,55 @@ +using Carbon.Application.Contracts; +using Carbon.Application.Dtos.Facial; +using Carbon.Domain.Contracts.Services.Facial; +using Carbon.Domain.Exceptions; + +namespace Carbon.Application.UseCases; + +public class FacialUseCase : IFacialUseCase +{ + private readonly IFacialService _facialService; + + public FacialUseCase(IFacialService facialService) + { + _facialService = facialService; + } + + public async Task Register(Guid userId, FaceImageRequestDto request, CancellationToken ct = default) + { + var image = DecodeImage(request.ImageBase64); + var result = await _facialService.RegisterFaceAsync(userId, image, ct); + + return new FaceRegisterResponseDto(result.UserId, result.RegisteredAt); + } + + public async Task Verify(Guid userId, FaceImageRequestDto request, CancellationToken ct = default) + { + var image = DecodeImage(request.ImageBase64); + var result = await _facialService.VerifyFaceAsync(userId, image, ct); + + return new FaceVerificationResponseDto(result.UserId, result.IsMatch, result.Similarity, result.VerifiedAt); + } + + private static byte[] DecodeImage(string imageBase64) + { + if (string.IsNullOrWhiteSpace(imageBase64)) + { + throw new BadRequestException("Image is required."); + } + + // Remove o prefixo "data:image/...;base64," se vier da webcam + var commaIndex = imageBase64.IndexOf(','); + var base64 = imageBase64.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && commaIndex >= 0 + ? imageBase64[(commaIndex + 1)..] + : imageBase64; + + try + { + return Convert.FromBase64String(base64.Trim()); + } + catch (FormatException) + { + throw new BadRequestException("Image must be a valid base64 string."); + } + } +} \ No newline at end of file diff --git a/Carbon.Application/UseCases/UsersUseCase.cs b/Carbon.Application/UseCases/UsersUseCase.cs new file mode 100644 index 0000000..dbd5653 --- /dev/null +++ b/Carbon.Application/UseCases/UsersUseCase.cs @@ -0,0 +1,27 @@ +using Carbon.Application.Contracts; +using Carbon.Application.Dtos.Users; +using Carbon.Domain.Contracts.Services.Users; + +namespace Carbon.Application.UseCases; + +public class UsersUseCase : IUsersUseCase +{ + private readonly IUsersService _usersService; + + public UsersUseCase(IUsersService usersService) + { + _usersService = usersService; + } + + public async Task> GetAll(CancellationToken ct = default) + { + var users = await _usersService.GetAllAsync(ct); + return users.Select(ToDto).ToList(); + } + + public async Task ChangeRole(Guid currentUserId, Guid userId, ChangeRoleRequestDto request, CancellationToken ct = default) + => ToDto(await _usersService.SetRoleAsync(currentUserId, userId, request.Role, ct)); + + private static UserSummaryDto ToDto(UserSummary u) + => new(u.Id, u.Username, u.Email, u.Role, u.HasFace, u.CreatedAt); +} diff --git a/Carbon.Azure.Facial.Provider/Carbon.Azure.Facial.Provider.csproj b/Carbon.Azure.Facial.Provider/Carbon.Azure.Facial.Provider.csproj index 72d7137..2d64bc1 100644 --- a/Carbon.Azure.Facial.Provider/Carbon.Azure.Facial.Provider.csproj +++ b/Carbon.Azure.Facial.Provider/Carbon.Azure.Facial.Provider.csproj @@ -7,4 +7,9 @@ Carbon.Azure.Facial + + + + + diff --git a/Carbon.Azure.Facial.Provider/Class1.cs b/Carbon.Azure.Facial.Provider/Class1.cs deleted file mode 100644 index 74ec65a..0000000 --- a/Carbon.Azure.Facial.Provider/Class1.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Carbon.Azure.Facial; - -public class Class1 -{ -} \ No newline at end of file diff --git a/Carbon.Azure.Facial.Provider/Clients/FaceRecognitionClient.cs b/Carbon.Azure.Facial.Provider/Clients/FaceRecognitionClient.cs new file mode 100644 index 0000000..00395a7 --- /dev/null +++ b/Carbon.Azure.Facial.Provider/Clients/FaceRecognitionClient.cs @@ -0,0 +1,66 @@ +using FaceAiSharp; +using FaceAiSharp.Extensions; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Carbon.Azure.Facial.Clients; + +// Reconhecimento facial local com a FaceAiSharp. Registrar como Singleton (modelos pesados). +public sealed class FaceRecognitionClient : IFaceRecognitionClient, IDisposable +{ + private readonly IFaceDetectorWithLandmarks _detector; + private readonly IFaceEmbeddingsGenerator _generator; + private readonly object _lock = new(); + + public FaceRecognitionClient() + { + _detector = FaceAiSharpBundleFactory.CreateFaceDetectorWithLandmarks(); + _generator = FaceAiSharpBundleFactory.CreateFaceEmbeddingsGenerator(); + } + + public FaceEmbeddingResult GetEmbedding(byte[] image) + { + Image img; + + try + { + img = Image.Load(image); + } + catch (ImageFormatException) + { + return new FaceEmbeddingResult(FaceEmbeddingStatus.InvalidImage, null); + } + + using (img) + { + lock (_lock) + { + var faces = _detector.DetectFaces(img); + + if (faces.Count == 0) + return new FaceEmbeddingResult(FaceEmbeddingStatus.NoFaceFound, null); + + if (faces.Count > 1) + return new FaceEmbeddingResult(FaceEmbeddingStatus.MultipleFacesFound, null); + + var face = faces.First(); + + if (face.Landmarks is null) + return new FaceEmbeddingResult(FaceEmbeddingStatus.NoFaceFound, null); + + _generator.AlignFaceUsingLandmarks(img, face.Landmarks); + var embedding = _generator.GenerateEmbedding(img); + + return new FaceEmbeddingResult(FaceEmbeddingStatus.Success, embedding); + } + } + } + + public float Compare(float[] first, float[] second) => first.Dot(second); + + public void Dispose() + { + (_detector as IDisposable)?.Dispose(); + (_generator as IDisposable)?.Dispose(); + } +} \ No newline at end of file diff --git a/Carbon.Azure.Facial.Provider/Clients/IFaceRecognitionClient.cs b/Carbon.Azure.Facial.Provider/Clients/IFaceRecognitionClient.cs new file mode 100644 index 0000000..f32b465 --- /dev/null +++ b/Carbon.Azure.Facial.Provider/Clients/IFaceRecognitionClient.cs @@ -0,0 +1,17 @@ +namespace Carbon.Azure.Facial.Clients; + +public interface IFaceRecognitionClient +{ + FaceEmbeddingResult GetEmbedding(byte[] image); + float Compare(float[] first, float[] second); +} + +public enum FaceEmbeddingStatus +{ + Success, + InvalidImage, + NoFaceFound, + MultipleFacesFound +} + +public record FaceEmbeddingResult(FaceEmbeddingStatus Status, float[]? Embedding); \ No newline at end of file diff --git a/Carbon.Core/Controllers/FacialController.cs b/Carbon.Core/Controllers/FacialController.cs new file mode 100644 index 0000000..90fd4a8 --- /dev/null +++ b/Carbon.Core/Controllers/FacialController.cs @@ -0,0 +1,40 @@ +using Carbon.Application.Contracts; +using Carbon.Application.Dtos.Facial; +using Carbon.Domain.Exceptions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Carbon.Core.Controllers; + +[ApiController] +[Authorize] +[Route("Carbon/[controller]")] +public class FacialController : ControllerBase +{ + private readonly IFacialUseCase _useCase; + + public FacialController(IFacialUseCase useCase) + { + _useCase = useCase; + } + + // Cadastra (ou substitui) o rosto do usuário logado + [HttpPost("Register")] + public async Task Register(FaceImageRequestDto request, CancellationToken ct = default) + => await _useCase.Register(GetUserId(), request, ct); + + // Compara a foto com o rosto cadastrado do usuário logado + [HttpPost("Verify")] + public async Task Verify(FaceImageRequestDto request, CancellationToken ct = default) + => await _useCase.Verify(GetUserId(), request, ct); + + // O id do usuário vem do token JWT (claim "sub") + private Guid GetUserId() + { + var sub = User.FindFirst("sub")?.Value; + + return Guid.TryParse(sub, out var userId) + ? userId + : throw new UnauthorizedException("Invalid token: user id not found."); + } +} \ No newline at end of file diff --git a/Carbon.Core/Controllers/UsersController.cs b/Carbon.Core/Controllers/UsersController.cs new file mode 100644 index 0000000..dcbef8e --- /dev/null +++ b/Carbon.Core/Controllers/UsersController.cs @@ -0,0 +1,38 @@ +using Carbon.Application.Contracts; +using Carbon.Application.Dtos.Users; +using Carbon.Domain.Exceptions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Carbon.Core.Controllers; + +// Área do Admin: ver os usuários cadastrados e trocar o nível de acesso +[ApiController] +[Authorize(Roles = "Admin")] +[Route("Carbon/[controller]")] +public class UsersController : ControllerBase +{ + private readonly IUsersUseCase _useCase; + + public UsersController(IUsersUseCase useCase) + { + _useCase = useCase; + } + + [HttpGet] + public async Task> GetAll(CancellationToken ct = default) + => await _useCase.GetAll(ct); + + [HttpPut("{id:guid}/Role")] + public async Task ChangeRole(Guid id, ChangeRoleRequestDto request, CancellationToken ct = default) + => await _useCase.ChangeRole(GetUserId(), id, request, ct); + + private Guid GetUserId() + { + var sub = User.FindFirst("sub")?.Value; + + return Guid.TryParse(sub, out var userId) + ? userId + : throw new UnauthorizedException("Invalid token: user id not found."); + } +} diff --git a/Carbon.Core/Extensions/AdminSeedExtensions.cs b/Carbon.Core/Extensions/AdminSeedExtensions.cs new file mode 100644 index 0000000..84c43bf --- /dev/null +++ b/Carbon.Core/Extensions/AdminSeedExtensions.cs @@ -0,0 +1,30 @@ +using Carbon.Domain.Contracts.Services.Authentication; +using Carbon.Domain.Contracts.Services.Users; + +namespace Carbon.Core.Extensions; + +public static class AdminSeedExtensions +{ + // Se "Seed:AdminEmail" estiver configurado, esse usuário vira Admin ao iniciar a API. + // Serve para criar o primeiro Admin; depois ele troca os perfis pela tela de usuários. + public static async Task PromoteConfiguredAdminAsync(this WebApplication app) + { + var email = app.Configuration["Seed:AdminEmail"]; + if (string.IsNullOrWhiteSpace(email)) return; + + try + { + using var scope = app.Services.CreateScope(); + var users = scope.ServiceProvider.GetRequiredService(); + + if (await users.SetRoleByEmailAsync(email.Trim(), nameof(Roles.Admin))) + app.Logger.LogInformation("Seed: {Email} agora é Admin.", email); + else + app.Logger.LogWarning("Seed: usuário {Email} não encontrado. Cadastre a conta e reinicie a API.", email); + } + catch (Exception e) + { + app.Logger.LogWarning(e, "Seed: não foi possível promover {Email} a Admin.", email); + } + } +} diff --git a/Carbon.Core/Extensions/ServiceCollectionExtensions.cs b/Carbon.Core/Extensions/ServiceCollectionExtensions.cs index e935c8a..ddb8717 100644 --- a/Carbon.Core/Extensions/ServiceCollectionExtensions.cs +++ b/Carbon.Core/Extensions/ServiceCollectionExtensions.cs @@ -1,8 +1,10 @@ +using Carbon.Domain.Contracts.Services.Facial; using Carbon.Application.Contracts; using Carbon.Application.UseCases; using Carbon.Core.Contracts; using Carbon.Domain.Contracts.Services.Authentication; using Carbon.Domain.Contracts.Services.Emissions; +using Carbon.Domain.Contracts.Services.Users; using Carbon.Domain.Services; using Microsoft.AspNetCore.RateLimiting; using Microsoft.OpenApi.Models; @@ -15,12 +17,16 @@ public static void AddCarbonServices(this IServiceCollection services) { services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); } public static void AddCarbonUseCases(this IServiceCollection services) { services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); } public static void AddCarbonRateLimiter(this IServiceCollection services) diff --git a/Carbon.Core/Program.cs b/Carbon.Core/Program.cs index 7d0e115..ba3434d 100644 --- a/Carbon.Core/Program.cs +++ b/Carbon.Core/Program.cs @@ -14,10 +14,12 @@ builder.Services.AddCarbonUseCases(); builder.Services.AddDataLayer(builder.Configuration); builder.Services.AddTImpact(builder.Configuration); +builder.Services.AddFacialRecognition(); builder.Services.AddExceptionHandler(); builder.Services.AddJwtAuthentication(builder.Configuration); var app = builder.Build(); +await app.PromoteConfiguredAdminAsync(); app.UseExceptionHandler("/Error"); if (app.Environment.IsDevelopment()) diff --git a/Carbon.Domain/Contracts/Data/Repositories/IUserRepository.cs b/Carbon.Domain/Contracts/Data/Repositories/IUserRepository.cs index 6644493..b6209d8 100644 --- a/Carbon.Domain/Contracts/Data/Repositories/IUserRepository.cs +++ b/Carbon.Domain/Contracts/Data/Repositories/IUserRepository.cs @@ -5,4 +5,7 @@ namespace Carbon.Domain.Contracts.Data.Repositories; public interface IUserRepository : ICarbonRepository { Task GetByEmailAsync(string email, CancellationToken ct); + Task> GetAllWithRolesAsync(CancellationToken ct); + Task GetByIdForUpdateAsync(Guid id, CancellationToken ct); + Task UpdateFaceEmbeddingAsync(Guid userId, byte[] faceEmbedding, CancellationToken ct); } \ No newline at end of file diff --git a/Carbon.Domain/Contracts/Providers/Facial/IFacialRecognitionProvider.cs b/Carbon.Domain/Contracts/Providers/Facial/IFacialRecognitionProvider.cs new file mode 100644 index 0000000..e68fc31 --- /dev/null +++ b/Carbon.Domain/Contracts/Providers/Facial/IFacialRecognitionProvider.cs @@ -0,0 +1,7 @@ +namespace Carbon.Domain.Contracts.Providers.Facial; + +public interface IFacialRecognitionProvider +{ + float[] GetFaceEmbedding(byte[] image); + float Compare(float[] first, float[] second); +} \ No newline at end of file diff --git a/Carbon.Domain/Contracts/Services/Facial/FacialContracts.cs b/Carbon.Domain/Contracts/Services/Facial/FacialContracts.cs new file mode 100644 index 0000000..601c85f --- /dev/null +++ b/Carbon.Domain/Contracts/Services/Facial/FacialContracts.cs @@ -0,0 +1,15 @@ +namespace Carbon.Domain.Contracts.Services.Facial +{ + public record FaceRegisterResponse(Guid UserId, DateTime RegisteredAt); + + public record FaceVerificationResponse(Guid UserId, bool IsMatch, float Similarity, DateTime VerifiedAt); + + public static class FacialRules + { + // Similaridade mínima para considerar a mesma pessoa (recomendação da FaceAiSharp) + public const float MatchThreshold = 0.42f; + + // Tamanho máximo da foto (5 MB) + public const int MaxImageBytes = 5 * 1024 * 1024; + } +} \ No newline at end of file diff --git a/Carbon.Domain/Contracts/Services/Facial/IFacialService.cs b/Carbon.Domain/Contracts/Services/Facial/IFacialService.cs new file mode 100644 index 0000000..b5ba29f --- /dev/null +++ b/Carbon.Domain/Contracts/Services/Facial/IFacialService.cs @@ -0,0 +1,7 @@ +namespace Carbon.Domain.Contracts.Services.Facial; + +public interface IFacialService +{ + Task RegisterFaceAsync(Guid userId, byte[] image, CancellationToken ct = default); + Task VerifyFaceAsync(Guid userId, byte[] image, CancellationToken ct = default); +} \ No newline at end of file diff --git a/Carbon.Domain/Contracts/Services/Users/IUsersService.cs b/Carbon.Domain/Contracts/Services/Users/IUsersService.cs new file mode 100644 index 0000000..06d6291 --- /dev/null +++ b/Carbon.Domain/Contracts/Services/Users/IUsersService.cs @@ -0,0 +1,10 @@ +namespace Carbon.Domain.Contracts.Services.Users; + +public interface IUsersService +{ + Task> GetAllAsync(CancellationToken ct = default); + Task SetRoleAsync(Guid currentUserId, Guid userId, string role, CancellationToken ct = default); + Task SetRoleByEmailAsync(string email, string role, CancellationToken ct = default); +} + +public record UserSummary(Guid Id, string Username, string Email, string Role, bool HasFace, DateTime CreatedAt); diff --git a/Carbon.Domain/Exceptions/DomainExceptions.cs b/Carbon.Domain/Exceptions/DomainExceptions.cs index 41255fc..159f2e3 100644 --- a/Carbon.Domain/Exceptions/DomainExceptions.cs +++ b/Carbon.Domain/Exceptions/DomainExceptions.cs @@ -7,6 +7,12 @@ public class UnauthorizedException(string message) : DomainException(message) public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.Unauthorized; } + // Usuário logado, mas o perfil dele não pode fazer isso (HTTP 403) + public class ForbiddenException(string message) : DomainException(message) + { + public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.Forbidden; + } + public class NotFoundException(string message) : DomainException(message) { public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.NotFound; diff --git a/Carbon.Domain/Models/User.cs b/Carbon.Domain/Models/User.cs index 4d16ea0..fe561dc 100644 --- a/Carbon.Domain/Models/User.cs +++ b/Carbon.Domain/Models/User.cs @@ -12,6 +12,7 @@ public class User public required string Email { get; set; } public required string Password { get; set; } public bool IsActive { get; set; } + public byte[]? FaceEmbedding { get; set; } public DateTime CreatedAt { get; set; } public ICollection Roles { get; set; } = []; public ICollection RefreshTokens = []; diff --git a/Carbon.Domain/Services/EmissionsService.cs b/Carbon.Domain/Services/EmissionsService.cs index 8a55e22..d6408a7 100644 --- a/Carbon.Domain/Services/EmissionsService.cs +++ b/Carbon.Domain/Services/EmissionsService.cs @@ -70,7 +70,7 @@ public async Task CalculateAsync(EmissionsCalculat .ToList(); if (roles.Count == 0) - throw new UnauthorizedException + throw new ForbiddenException ($"{nameof(Roles.General)} can't view international flights!"); } diff --git a/Carbon.Domain/Services/FacialService.cs b/Carbon.Domain/Services/FacialService.cs new file mode 100644 index 0000000..0288464 --- /dev/null +++ b/Carbon.Domain/Services/FacialService.cs @@ -0,0 +1,75 @@ +using System.Runtime.InteropServices; +using Carbon.Domain.Contracts.Data; +using Carbon.Domain.Contracts.Providers.Facial; +using Carbon.Domain.Contracts.Services.Facial; +using Carbon.Domain.Exceptions; +using Carbon.Domain.Models; + +namespace Carbon.Domain.Services; + +public class FacialService : IFacialService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IFacialRecognitionProvider _provider; + + public FacialService(IUnitOfWork unitOfWork, IFacialRecognitionProvider provider) + { + _unitOfWork = unitOfWork; + _provider = provider; + } + + public async Task RegisterFaceAsync(Guid userId, byte[] image, CancellationToken ct = default) + { + ValidateImage(image); + await GetUserAsync(userId, ct); + + var embedding = _provider.GetFaceEmbedding(image); + + await _unitOfWork.UserRepository.UpdateFaceEmbeddingAsync(userId, ToBytes(embedding), ct); + + return new FaceRegisterResponse(userId, DateTime.UtcNow); + } + + public async Task VerifyFaceAsync(Guid userId, byte[] image, CancellationToken ct = default) + { + ValidateImage(image); + var user = await GetUserAsync(userId, ct); + + if (user.FaceEmbedding is null || user.FaceEmbedding.Length == 0) + { + throw new BadRequestException("User has no registered face. Register a face before verifying."); + } + + var storedEmbedding = ToFloats(user.FaceEmbedding); + var currentEmbedding = _provider.GetFaceEmbedding(image); + + var similarity = _provider.Compare(storedEmbedding, currentEmbedding); + var isMatch = similarity >= FacialRules.MatchThreshold; + + return new FaceVerificationResponse(userId, isMatch, MathF.Round(similarity, 4), DateTime.UtcNow); + } + + private async Task GetUserAsync(Guid userId, CancellationToken ct) + => await _unitOfWork.UserRepository.GetByIdAsync(userId, ct) + ?? throw new NotFoundException($"User with id: {userId} not found!"); + + private static void ValidateImage(byte[] image) + { + if (image is null || image.Length == 0) + { + throw new BadRequestException("Image is required."); + } + + if (image.Length > FacialRules.MaxImageBytes) + { + throw new BadRequestException($"Image must have at most {FacialRules.MaxImageBytes / (1024 * 1024)} MB."); + } + } + + // O embedding (512 floats) é guardado no banco como bytes + private static byte[] ToBytes(float[] embedding) + => MemoryMarshal.AsBytes(embedding.AsSpan()).ToArray(); + + private static float[] ToFloats(byte[] bytes) + => MemoryMarshal.Cast(bytes.AsSpan()).ToArray(); +} \ No newline at end of file diff --git a/Carbon.Domain/Services/UsersService.cs b/Carbon.Domain/Services/UsersService.cs new file mode 100644 index 0000000..235fe4a --- /dev/null +++ b/Carbon.Domain/Services/UsersService.cs @@ -0,0 +1,74 @@ +using Carbon.Domain.Contracts.Data; +using Carbon.Domain.Contracts.Services.Authentication; +using Carbon.Domain.Contracts.Services.Users; +using Carbon.Domain.Exceptions; +using Carbon.Domain.Models; + +namespace Carbon.Domain.Services; + +// Gestão de usuários pelo Admin: listar usuários e trocar o nível de acesso +public class UsersService : IUsersService +{ + // Do maior para o menor nível de acesso + private static readonly string[] RoleOrder = [nameof(Roles.Admin), nameof(Roles.Director), nameof(Roles.General)]; + + private readonly IUnitOfWork _unitOfWork; + + public UsersService(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task> GetAllAsync(CancellationToken ct = default) + { + var users = await _unitOfWork.UserRepository.GetAllWithRolesAsync(ct); + return users.Select(ToSummary).ToList(); + } + + public async Task SetRoleAsync(Guid currentUserId, Guid userId, string role, CancellationToken ct = default) + { + var roleName = NormalizeRole(role); + + if (userId == currentUserId && roleName != nameof(Roles.Admin)) + throw new BadRequestException("You can't remove your own Admin role."); + + var user = await _unitOfWork.UserRepository.GetByIdForUpdateAsync(userId, ct) + ?? throw new NotFoundException($"User with id: {userId} not found!"); + + await ApplyRoleAsync(user, roleName, ct); + return ToSummary(user); + } + + public async Task SetRoleByEmailAsync(string email, string role, CancellationToken ct = default) + { + var user = await _unitOfWork.UserRepository.GetByEmailAsync(email, ct); + if (user is null) return false; + + await ApplyRoleAsync(user, NormalizeRole(role), ct); + return true; + } + + private async Task ApplyRoleAsync(User user, string roleName, CancellationToken ct) + { + if (user.Roles.Count == 1 && user.Roles.First().Name == roleName) + return; + + var role = await _unitOfWork.RoleRepository.GetByNameAsync(roleName, ct); + user.Roles.Clear(); + user.Roles.Add(role); + await _unitOfWork.SaveChangesAsync(ct); + } + + private static string NormalizeRole(string role) + => Enum.GetNames().FirstOrDefault(e => string.Equals(e, role?.Trim(), StringComparison.OrdinalIgnoreCase)) + ?? throw new BadRequestException("Role must be General, Director or Admin."); + + private static UserSummary ToSummary(User user) + => new( + user.Id, + user.Username, + user.Email, + RoleOrder.FirstOrDefault(r => user.Roles.Any(e => e.Name == r)) ?? nameof(Roles.General), + user.FaceEmbedding is { Length: > 0 }, + user.CreatedAt); +} diff --git a/Carbon.Front/Components/App.razor b/Carbon.Front/Components/App.razor index b0f546e..7ab0a72 100644 --- a/Carbon.Front/Components/App.razor +++ b/Carbon.Front/Components/App.razor @@ -15,6 +15,7 @@ + \ No newline at end of file diff --git a/Carbon.Front/Components/Layout/NavMenu.razor b/Carbon.Front/Components/Layout/NavMenu.razor index 2bc7f2d..8881e14 100644 --- a/Carbon.Front/Components/Layout/NavMenu.razor +++ b/Carbon.Front/Components/Layout/NavMenu.razor @@ -28,6 +28,14 @@ + + + + @if (!string.IsNullOrEmpty(errorMessage)) @@ -93,7 +101,7 @@
- +