Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Carbon.Application/Contracts/IFacialUseCase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Carbon.Application.Dtos.Facial;

namespace Carbon.Application.Contracts;

public interface IFacialUseCase
{
Task<FaceRegisterResponseDto> Register(Guid userId, FaceImageRequestDto request, CancellationToken ct);
Task<FaceVerificationResponseDto> Verify(Guid userId, FaceImageRequestDto request, CancellationToken ct);
}
9 changes: 9 additions & 0 deletions Carbon.Application/Contracts/IUsersUseCase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Carbon.Application.Dtos.Users;

namespace Carbon.Application.Contracts;

public interface IUsersUseCase
{
Task<IReadOnlyList<UserSummaryDto>> GetAll(CancellationToken ct);
Task<UserSummaryDto> ChangeRole(Guid currentUserId, Guid userId, ChangeRoleRequestDto request, CancellationToken ct);
}
8 changes: 8 additions & 0 deletions Carbon.Application/Dtos/Facial/FaceImageRequestDto.cs
Original file line number Diff line number Diff line change
@@ -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);
5 changes: 5 additions & 0 deletions Carbon.Application/Dtos/Facial/FaceResponseDtos.cs
Original file line number Diff line number Diff line change
@@ -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);
9 changes: 9 additions & 0 deletions Carbon.Application/Dtos/Users/UserDtos.cs
Original file line number Diff line number Diff line change
@@ -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);
55 changes: 55 additions & 0 deletions Carbon.Application/UseCases/FacialUseCase.cs
Original file line number Diff line number Diff line change
@@ -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<FaceRegisterResponseDto> 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<FaceVerificationResponseDto> 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.");
}
}
}
27 changes: 27 additions & 0 deletions Carbon.Application/UseCases/UsersUseCase.cs
Original file line number Diff line number Diff line change
@@ -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<IReadOnlyList<UserSummaryDto>> GetAll(CancellationToken ct = default)
{
var users = await _usersService.GetAllAsync(ct);
return users.Select(ToDto).ToList();
}

public async Task<UserSummaryDto> 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,9 @@
<RootNamespace>Carbon.Azure.Facial</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FaceAiSharp.Bundle" Version="0.6.35" />
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.26.0" />
</ItemGroup>

</Project>
5 changes: 0 additions & 5 deletions Carbon.Azure.Facial.Provider/Class1.cs

This file was deleted.

66 changes: 66 additions & 0 deletions Carbon.Azure.Facial.Provider/Clients/FaceRecognitionClient.cs
Original file line number Diff line number Diff line change
@@ -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<Rgb24> img;

try
{
img = Image.Load<Rgb24>(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();
}
}
17 changes: 17 additions & 0 deletions Carbon.Azure.Facial.Provider/Clients/IFaceRecognitionClient.cs
Original file line number Diff line number Diff line change
@@ -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);
40 changes: 40 additions & 0 deletions Carbon.Core/Controllers/FacialController.cs
Original file line number Diff line number Diff line change
@@ -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<FaceRegisterResponseDto> 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<FaceVerificationResponseDto> 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.");
}
}
38 changes: 38 additions & 0 deletions Carbon.Core/Controllers/UsersController.cs
Original file line number Diff line number Diff line change
@@ -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<IReadOnlyList<UserSummaryDto>> GetAll(CancellationToken ct = default)
=> await _useCase.GetAll(ct);

[HttpPut("{id:guid}/Role")]
public async Task<UserSummaryDto> 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.");
}
}
30 changes: 30 additions & 0 deletions Carbon.Core/Extensions/AdminSeedExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<IUsersService>();

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);
}
}
}
6 changes: 6 additions & 0 deletions Carbon.Core/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,12 +17,16 @@ public static void AddCarbonServices(this IServiceCollection services)
{
services.AddTransient<IAuthService, AuthService>();
services.AddTransient<IEmissionsService, EmissionsService>();
services.AddTransient<IFacialService, FacialService>();
services.AddTransient<IUsersService, UsersService>();
}

public static void AddCarbonUseCases(this IServiceCollection services)
{
services.AddTransient<IAuthUseCase, AuthUseCase>();
services.AddTransient<IEmissionsUseCase, EmissionsUseCase>();
services.AddTransient<IFacialUseCase, FacialUseCase>();
services.AddTransient<IUsersUseCase, UsersUseCase>();
}

public static void AddCarbonRateLimiter(this IServiceCollection services)
Expand Down
2 changes: 2 additions & 0 deletions Carbon.Core/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
builder.Services.AddCarbonUseCases();
builder.Services.AddDataLayer(builder.Configuration);
builder.Services.AddTImpact(builder.Configuration);
builder.Services.AddFacialRecognition();
builder.Services.AddExceptionHandler<ExceptionHandlerMiddleware>();
builder.Services.AddJwtAuthentication(builder.Configuration);

var app = builder.Build();
await app.PromoteConfiguredAdminAsync();
app.UseExceptionHandler("/Error");

if (app.Environment.IsDevelopment())
Expand Down
3 changes: 3 additions & 0 deletions Carbon.Domain/Contracts/Data/Repositories/IUserRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@ namespace Carbon.Domain.Contracts.Data.Repositories;
public interface IUserRepository : ICarbonRepository<User, Guid>
{
Task<User?> GetByEmailAsync(string email, CancellationToken ct);
Task<IEnumerable<User>> GetAllWithRolesAsync(CancellationToken ct);
Task<User?> GetByIdForUpdateAsync(Guid id, CancellationToken ct);
Task UpdateFaceEmbeddingAsync(Guid userId, byte[] faceEmbedding, CancellationToken ct);
}
Loading
Loading