From c1dea07576ccdca84be21e553924c0d1bcd189bb Mon Sep 17 00:00:00 2001 From: Raffaello Damgaard Date: Wed, 25 Dec 2024 18:57:49 -0300 Subject: [PATCH 1/2] Novo endpoint unsubscribe. --- .../ServiceRepositoryCollectionExtensions.cs | 4 +- .../Controllers/AccountController.cs | 415 +++++++++--------- .../ShareBook.Service/Authorization/Crypto.cs | 85 ++++ .../Authorization/ICrypto.cs | 7 + .../Authorization/Permission.cs | 23 +- .../ShareBook.Service/User/IUserService.cs | 4 +- .../ShareBook.Service/User/UserService.cs | 69 ++- 7 files changed, 389 insertions(+), 218 deletions(-) create mode 100644 ShareBook/ShareBook.Service/Authorization/Crypto.cs create mode 100644 ShareBook/ShareBook.Service/Authorization/ICrypto.cs diff --git a/ShareBook/ShareBook.Api/Configuration/ServiceRepositoryCollectionExtensions.cs b/ShareBook/ShareBook.Api/Configuration/ServiceRepositoryCollectionExtensions.cs index 6406196f..1ca7f3fe 100644 --- a/ShareBook/ShareBook.Api/Configuration/ServiceRepositoryCollectionExtensions.cs +++ b/ShareBook/ShareBook.Api/Configuration/ServiceRepositoryCollectionExtensions.cs @@ -8,6 +8,7 @@ using ShareBook.Repository; using ShareBook.Repository.UoW; using ShareBook.Service; +using ShareBook.Service.Authorization; using ShareBook.Service.AwsSqs; using ShareBook.Service.Lgpd; using ShareBook.Service.Muambator; @@ -36,7 +37,8 @@ public static IServiceCollection RegisterRepositoryServices( services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); //repositories services.AddScoped(); diff --git a/ShareBook/ShareBook.Api/Controllers/AccountController.cs b/ShareBook/ShareBook.Api/Controllers/AccountController.cs index c8dee7eb..f4d14550 100644 --- a/ShareBook/ShareBook.Api/Controllers/AccountController.cs +++ b/ShareBook/ShareBook.Api/Controllers/AccountController.cs @@ -4,273 +4,292 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using ShareBook.Api.Filters; using ShareBook.Api.ViewModels; using ShareBook.Domain; using ShareBook.Domain.Common; using ShareBook.Domain.DTOs; -using ShareBook.Domain.Exceptions; +using ShareBook.Domain.Exceptions; using ShareBook.Infra.CrossCutting.Identity; using ShareBook.Infra.CrossCutting.Identity.Interfaces; using ShareBook.Repository; using ShareBook.Service; +using ShareBook.Service.Authorization; using ShareBook.Service.Lgpd; using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -namespace ShareBook.Api.Controllers +namespace ShareBook.Api.Controllers; + +[Route("api/[controller]")] +[EnableCors("AllowAllHeaders")] +[GetClaimsFilter] +public class AccountController : ControllerBase { - [Route("api/[controller]")] - [EnableCors("AllowAllHeaders")] - [GetClaimsFilter] - public class AccountController : ControllerBase + private readonly IUserService _userService; + private readonly IApplicationSignInManager _signManager; + private readonly IMapper _mapper; + private readonly IConfiguration _configuration; + private readonly IAccessHistoryRepository _historyRepository; + private readonly ILgpdService _lgpdService; + private readonly ICrypto _crypto; + + public AccountController(IUserService userService, + IApplicationSignInManager signManager, + IMapper mapper, + IConfiguration configuration, + IAccessHistoryRepository historyRepository, + ILgpdService lgpdService, + ICrypto crypto) { - private readonly IUserService _userService; - private readonly IApplicationSignInManager _signManager; - private readonly IMapper _mapper; - private readonly IConfiguration _configuration; - private readonly IAccessHistoryRepository _historyRepository; - private readonly ILgpdService _lgpdService; - - public AccountController(IUserService userService, - IApplicationSignInManager signManager, - IMapper mapper, - IConfiguration configuration, - IAccessHistoryRepository historyRepository, - ILgpdService lgpdService) - { - _userService = userService; - _signManager = signManager; - _mapper = mapper; - _configuration = configuration; - _historyRepository = historyRepository; - _lgpdService = lgpdService; - } + _userService = userService; + _signManager = signManager; + _mapper = mapper; + _configuration = configuration; + _historyRepository = historyRepository; + _lgpdService = lgpdService; + _crypto = crypto; + } - #region GET + #region GET - [HttpGet] - [Authorize("Bearer")] - public async Task GetAsync() - { - var id = new Guid(Thread.CurrentPrincipal?.Identity?.Name); - var user = await _userService.FindAsync(id); + [HttpGet] + [Authorize("Bearer")] + public async Task GetAsync() + { + var id = new Guid(Thread.CurrentPrincipal?.Identity?.Name); + var user = await _userService.FindAsync(id); - var userVM = _mapper.Map(user); - return userVM; - } + var userVM = _mapper.Map(user); + return userVM; + } - [Authorize("Bearer")] - [HttpGet("Profile")] - public async Task ProfileAsync() - { - var id = new Guid(Thread.CurrentPrincipal?.Identity?.Name); - return new { profile = (await _userService.FindAsync(id)).Profile.ToString() }; - } + [Authorize("Bearer")] + [HttpGet("Profile")] + public async Task ProfileAsync() + { + var id = new Guid(Thread.CurrentPrincipal?.Identity?.Name); + return new { profile = (await _userService.FindAsync(id)).Profile.ToString() }; + } - [Authorize("Bearer")] - [HttpGet("ListFacilitators/{userIdDonator}")] - public IActionResult ListFacilitators(Guid userIdDonator) - { - var facilitators = _userService.GetFacilitators(userIdDonator); + [Authorize("Bearer")] + [HttpGet("ListFacilitators/{userIdDonator}")] + public IActionResult ListFacilitators(Guid userIdDonator) + { + var facilitators = _userService.GetFacilitators(userIdDonator); - var facilitatorsClean = _mapper.Map>(facilitators); + var facilitatorsClean = _mapper.Map>(facilitators); - return Ok(facilitatorsClean); - } + return Ok(facilitatorsClean); + } - [Authorize("Bearer")] - [HttpGet("WhoAccessed/{userId:Guid}")] - [ProducesResponseType(typeof(AccessHistoryVM), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task WhoAccessedMyProfile(Guid userId) - { - if (!ModelState.IsValid) return BadRequest(ModelState); + [Authorize("Bearer")] + [HttpGet("WhoAccessed/{userId:Guid}")] + [ProducesResponseType(typeof(AccessHistoryVM), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task WhoAccessedMyProfile(Guid userId) + { + if (!ModelState.IsValid) return BadRequest(ModelState); - if (userId.Equals(null) || userId.Equals(Guid.Empty)) return BadRequest(ModelState); + if (userId.Equals(null) || userId.Equals(Guid.Empty)) return BadRequest(ModelState); - var whoAccessHistory = _mapper.Map, IEnumerable>( - await _historyRepository.GetWhoAccessedMyProfileAsync(userId)); + var whoAccessHistory = _mapper.Map, IEnumerable>( + await _historyRepository.GetWhoAccessedMyProfileAsync(userId)); - if (whoAccessHistory is null) return NotFound(userId); + if (whoAccessHistory is null) return NotFound(userId); - return Ok(whoAccessHistory); + return Ok(whoAccessHistory); + } + + [Throttle(Name = "unsubscribe", Seconds = 5, VaryByIp = false)] + [HttpGet("unsubscribe")] + public async Task unsubscribe([FromQuery] string unsubToken) + { + try + { + await _userService.Unsubscribe(unsubToken); + return Ok("Você se desinscreveu com sucesso."); + } + catch (Exception ex) + { + return BadRequest(ex.Message); } + } - #endregion GET + #endregion GET - #region POST + #region POST - [HttpPost("Register")] - [ProducesResponseType(typeof(object), 200)] - [ProducesResponseType(409)] - public async Task Post([FromBody] RegisterUserDTO registerUserDto, [FromServices] SigningConfigurations signingConfigurations, [FromServices] TokenConfigurations tokenConfigurations) - { - var result = await _userService.InsertAsync(registerUserDto); - - if (result.Success) - { - if (registerUserDto.Age > 12) - return Ok(_signManager.GenerateTokenAndSetIdentity(result.Value, signingConfigurations, tokenConfigurations)); - else - return Ok(new Result(SuccessMessage: "Seu cadastro foi realizado com sucesso. Foi enviado um email para os pais solicitando o consentimento. Vamos te avisar por email quando seu acesso for liberado. Obrigado. =)")); - } - + [HttpPost("Register")] + [ProducesResponseType(typeof(object), 200)] + [ProducesResponseType(409)] + public async Task Post([FromBody] RegisterUserDTO registerUserDto, [FromServices] SigningConfigurations signingConfigurations, [FromServices] TokenConfigurations tokenConfigurations) + { + var result = await _userService.InsertAsync(registerUserDto); - return Conflict(result); + if (result.Success) + { + if (registerUserDto.Age > 12) + return Ok(_signManager.GenerateTokenAndSetIdentity(result.Value, signingConfigurations, tokenConfigurations)); + else + return Ok(new Result(SuccessMessage: "Seu cadastro foi realizado com sucesso. Foi enviado um email para os pais solicitando o consentimento. Vamos te avisar por email quando seu acesso for liberado. Obrigado. =)")); } + - [HttpPost("Login")] - [ProducesResponseType(typeof(object), 200)] - [ProducesResponseType(404)] - public async Task LoginAsync( - [FromBody] LoginUserVM loginUserVM, - [FromServices] SigningConfigurations signingConfigurations, - [FromServices] TokenConfigurations tokenConfigurations, - [FromHeader(Name = "x-requested-with")] string client, - [FromHeader(Name = "client-version")] string clientVersion) - { + return Conflict(result); + } - if (!ModelState.IsValid) - return BadRequest(ModelState); + [HttpPost("Login")] + [ProducesResponseType(typeof(object), 200)] + [ProducesResponseType(404)] + public async Task LoginAsync( + [FromBody] LoginUserVM loginUserVM, + [FromServices] SigningConfigurations signingConfigurations, + [FromServices] TokenConfigurations tokenConfigurations, + [FromHeader(Name = "x-requested-with")] string client, + [FromHeader(Name = "client-version")] string clientVersion) + { - // mensagem amigável para usuários mobile antigos - if (!IsValidClientVersion(client, clientVersion)) - throw new ShareBookException("Não é possível fazer login porque seu app está desatualizado. Por favor atualize seu app na loja do Google Play."); + if (!ModelState.IsValid) + return BadRequest(ModelState); - var user = _mapper.Map(loginUserVM); - var result = await _userService.AuthenticationByEmailAndPasswordAsync(user); + // mensagem amigável para usuários mobile antigos + if (!IsValidClientVersion(client, clientVersion)) + throw new ShareBookException("Não é possível fazer login porque seu app está desatualizado. Por favor atualize seu app na loja do Google Play."); - if (result.Success) - { - var response = new Result - { - Value = _signManager.GenerateTokenAndSetIdentity(result.Value, signingConfigurations, tokenConfigurations) - }; + var user = _mapper.Map(loginUserVM); + var result = await _userService.AuthenticationByEmailAndPasswordAsync(user); - return Ok(response); - } + if (result.Success) + { + var response = new Result + { + Value = _signManager.GenerateTokenAndSetIdentity(result.Value, signingConfigurations, tokenConfigurations) + }; - return NotFound(result); + return Ok(response); } - [HttpPost("ForgotMyPassword")] - [ProducesResponseType(typeof(Result), 200)] - [ProducesResponseType(404)] - public async Task ForgotMyPasswordAsync([FromBody] ForgotMyPasswordVM forgotMyPasswordVM) - { - var result = await _userService.GenerateHashCodePasswordAndSendEmailToUserAsync(forgotMyPasswordVM.Email); + return NotFound(result); + } - if (result.Success) - return Ok(result); + [HttpPost("ForgotMyPassword")] + [ProducesResponseType(typeof(Result), 200)] + [ProducesResponseType(404)] + public async Task ForgotMyPasswordAsync([FromBody] ForgotMyPasswordVM forgotMyPasswordVM) + { + var result = await _userService.GenerateHashCodePasswordAndSendEmailToUserAsync(forgotMyPasswordVM.Email); - return NotFound(result); - } + if (result.Success) + return Ok(result); - [HttpPost("Anonymize")] - [Authorize("Bearer")] - public async Task AnonymizeAsync([FromBody] UserAnonymizeDTO dto) - { - var userIdFromSession = new Guid(Thread.CurrentPrincipal?.Identity?.Name); - if(dto.UserId != userIdFromSession) - throw new ShareBookException(ShareBookException.Error.Forbidden, "Você não tem permissão para remover esse conta."); + return NotFound(result); + } - await _lgpdService.AnonymizeAsync(dto); - return Ok(new Result("Sua conta foi removida com sucesso.")); - } + [HttpPost("Anonymize")] + [Authorize("Bearer")] + public async Task AnonymizeAsync([FromBody] UserAnonymizeDTO dto) + { + var userIdFromSession = new Guid(Thread.CurrentPrincipal?.Identity?.Name); + if(dto.UserId != userIdFromSession) + throw new ShareBookException(ShareBookException.Error.Forbidden, "Você não tem permissão para remover esse conta."); - #endregion POST + await _lgpdService.AnonymizeAsync(dto); + return Ok(new Result("Sua conta foi removida com sucesso.")); + } - #region PUT + #endregion POST - [HttpPut] - [Authorize("Bearer")] - [ProducesResponseType(typeof(Result), 200)] - [ProducesResponseType(409)] - public async Task UpdateAsync([FromBody] UpdateUserVM updateUserVM, [FromServices] SigningConfigurations signingConfigurations, [FromServices] TokenConfigurations tokenConfigurations) - { - if (!ModelState.IsValid) - return BadRequest(ModelState); + #region PUT - var user = _mapper.Map(updateUserVM); + [HttpPut] + [Authorize("Bearer")] + [ProducesResponseType(typeof(Result), 200)] + [ProducesResponseType(409)] + public async Task UpdateAsync([FromBody] UpdateUserVM updateUserVM, [FromServices] SigningConfigurations signingConfigurations, [FromServices] TokenConfigurations tokenConfigurations) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); - user.Id = new Guid(Thread.CurrentPrincipal?.Identity?.Name); + var user = _mapper.Map(updateUserVM); - var result = await _userService.UpdateAsync(user); + user.Id = new Guid(Thread.CurrentPrincipal?.Identity?.Name); - if (!result.Success) - return Conflict(result); + var result = await _userService.UpdateAsync(user); - return Ok(_signManager.GenerateTokenAndSetIdentity(result.Value, signingConfigurations, tokenConfigurations)); - } + if (!result.Success) + return Conflict(result); - [Authorize("Bearer")] - [HttpPut("ChangePassword")] - public async Task> ChangePasswordAsync([FromBody] ChangePasswordUserVM changePasswordUserVM) - { - var user = new User() { Password = changePasswordUserVM.OldPassword }; - user.Id = new Guid(Thread.CurrentPrincipal?.Identity?.Name); - return await _userService.ValidOldPasswordAndChangeUserPasswordAsync(user, changePasswordUserVM.NewPassword); - } + return Ok(_signManager.GenerateTokenAndSetIdentity(result.Value, signingConfigurations, tokenConfigurations)); + } - [HttpPut("ChangeUserPasswordByHashCode")] - [ProducesResponseType(typeof(Result), 200)] - [ProducesResponseType(404)] - public async Task ChangeUserPasswordByHashCodeAsync([FromBody] ChangeUserPasswordByHashCodeVM changeUserPasswordByHashCodeVM) - { - var result = await _userService.ConfirmHashCodePasswordAsync(changeUserPasswordByHashCodeVM.HashCodePassword); - if (!result.Success) - return NotFound(result); - var newPassword = changeUserPasswordByHashCodeVM.NewPassword; - var user = await _userService.FindAsync((result.Value as User).Id); - user.Password = newPassword; + [Authorize("Bearer")] + [HttpPut("ChangePassword")] + public async Task> ChangePasswordAsync([FromBody] ChangePasswordUserVM changePasswordUserVM) + { + var user = new User() { Password = changePasswordUserVM.OldPassword }; + user.Id = new Guid(Thread.CurrentPrincipal?.Identity?.Name); + return await _userService.ValidOldPasswordAndChangeUserPasswordAsync(user, changePasswordUserVM.NewPassword); + } - var resultChangePasswordUser = await _userService.ChangeUserPasswordAsync(user, newPassword); + [HttpPut("ChangeUserPasswordByHashCode")] + [ProducesResponseType(typeof(Result), 200)] + [ProducesResponseType(404)] + public async Task ChangeUserPasswordByHashCodeAsync([FromBody] ChangeUserPasswordByHashCodeVM changeUserPasswordByHashCodeVM) + { + var result = await _userService.ConfirmHashCodePasswordAsync(changeUserPasswordByHashCodeVM.HashCodePassword); + if (!result.Success) + return NotFound(result); + var newPassword = changeUserPasswordByHashCodeVM.NewPassword; + var user = await _userService.FindAsync((result.Value as User).Id); + user.Password = newPassword; - if (!resultChangePasswordUser.Success) - return BadRequest(resultChangePasswordUser); + var resultChangePasswordUser = await _userService.ChangeUserPasswordAsync(user, newPassword); - return Ok(resultChangePasswordUser); - } + if (!resultChangePasswordUser.Success) + return BadRequest(resultChangePasswordUser); - [HttpPut("ParentAproval")] - public async Task ParentAprovalAsync([FromBody] ParentAprovalVM parentAprovalVM) - { - var ParentHashCodeAproval = parentAprovalVM.ParentHashCodeAproval; + return Ok(resultChangePasswordUser); + } - if (string.IsNullOrEmpty(ParentHashCodeAproval) || !Guid.TryParse(ParentHashCodeAproval, out _)) - throw new ShareBookException("Código inválido."); - - await _userService.ParentAprovalAsync(ParentHashCodeAproval); - return Ok(); - } + [HttpPut("ParentAproval")] + public async Task ParentAprovalAsync([FromBody] ParentAprovalVM parentAprovalVM) + { + var ParentHashCodeAproval = parentAprovalVM.ParentHashCodeAproval; - #endregion PUT + if (string.IsNullOrEmpty(ParentHashCodeAproval) || !Guid.TryParse(ParentHashCodeAproval, out _)) + throw new ShareBookException("Código inválido."); + + await _userService.ParentAprovalAsync(ParentHashCodeAproval); + return Ok(); + } + + #endregion PUT - private bool IsValidClientVersion(string client, string clientVersion) + private bool IsValidClientVersion(string client, string clientVersion) + { + switch (client) { - switch (client) - { - case "web": - return true; + case "web": + return true; - // mobile android - case "com.makeztec.sharebook": - var minVersion = _configuration["ClientSettings:AndroidMinVersion"]; - return Helper.ClientVersionValidation.IsValidVersion(clientVersion, minVersion); + // mobile android + case "com.makeztec.sharebook": + var minVersion = _configuration["ClientSettings:AndroidMinVersion"]; + return Helper.ClientVersionValidation.IsValidVersion(clientVersion, minVersion); - default: - return false; - } + default: + return false; } + } - private async Task GetSessionUserAsync() - { - var userId = new Guid(Thread.CurrentPrincipal?.Identity?.Name); - return await _userService.FindAsync(userId); - } + private async Task GetSessionUserAsync() + { + var userId = new Guid(Thread.CurrentPrincipal?.Identity?.Name); + return await _userService.FindAsync(userId); } } \ No newline at end of file diff --git a/ShareBook/ShareBook.Service/Authorization/Crypto.cs b/ShareBook/ShareBook.Service/Authorization/Crypto.cs new file mode 100644 index 00000000..3b73087b --- /dev/null +++ b/ShareBook/ShareBook.Service/Authorization/Crypto.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.Configuration; +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace ShareBook.Service.Authorization; + +public class Crypto : ICrypto +{ + + private readonly IConfiguration _configuration; + public string _secret { get; set; } + + public Crypto(IConfiguration configuration) + { + _configuration = configuration; + _secret = _configuration["TokenConfigurations:SecretJwtKey"]; + } + + public string Encrypt(string input) + { + // Converte a chave secreta para bytes + byte[] key = GetKey(_secret); + + using (Aes aes = Aes.Create()) + { + aes.Key = key; + aes.GenerateIV(); // Gera um IV aleatório + + using (MemoryStream ms = new MemoryStream()) + { + // Escreve o IV no início do stream + ms.Write(aes.IV, 0, aes.IV.Length); + + using (CryptoStream cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write)) + { + byte[] inputBytes = Encoding.UTF8.GetBytes(input); + cs.Write(inputBytes, 0, inputBytes.Length); + cs.FlushFinalBlock(); + } + + // Retorna a string criptografada (Base64) + return Convert.ToBase64String(ms.ToArray()); + } + } + } + + public string Decrypt(string input) + { + byte[] key = GetKey(_secret); + byte[] inputBytes = Convert.FromBase64String(input); + + using (Aes aes = Aes.Create()) + { + aes.Key = key; + + using (MemoryStream ms = new MemoryStream(inputBytes)) + { + // Lê o IV do início do stream + byte[] iv = new byte[16]; // O tamanho do IV para AES é 16 bytes + ms.Read(iv, 0, iv.Length); + aes.IV = iv; + + using (CryptoStream cs = new CryptoStream(ms, aes.CreateDecryptor(), CryptoStreamMode.Read)) + { + using (StreamReader sr = new StreamReader(cs)) + { + // Retorna o texto descriptografado + return sr.ReadToEnd(); + } + } + } + } + } + + private byte[] GetKey(string secret) + { + // Garante que a chave terá 32 bytes (256 bits) para AES + using (SHA256 sha256 = SHA256.Create()) + { + return sha256.ComputeHash(Encoding.UTF8.GetBytes(secret)); + } + } +} diff --git a/ShareBook/ShareBook.Service/Authorization/ICrypto.cs b/ShareBook/ShareBook.Service/Authorization/ICrypto.cs new file mode 100644 index 00000000..6f2fe583 --- /dev/null +++ b/ShareBook/ShareBook.Service/Authorization/ICrypto.cs @@ -0,0 +1,7 @@ +namespace ShareBook.Service.Authorization; + +public interface ICrypto +{ + public string Encrypt(string input); + public string Decrypt(string input); +} diff --git a/ShareBook/ShareBook.Service/Authorization/Permission.cs b/ShareBook/ShareBook.Service/Authorization/Permission.cs index 370de9cd..b3b2be50 100644 --- a/ShareBook/ShareBook.Service/Authorization/Permission.cs +++ b/ShareBook/ShareBook.Service/Authorization/Permission.cs @@ -1,18 +1,17 @@ using System.Collections.Generic; -namespace ShareBook.Service.Authorization +namespace ShareBook.Service.Authorization; + +public class Permissions { - public class Permissions + public enum Permission { - public enum Permission - { - CreateBook, - UpdateBook, - DeleteBook, - ApproveBook, - DonateBook - } - - public static List AdminPermissions { get; } = new List() { Permission.ApproveBook, Permission.DonateBook }; + CreateBook, + UpdateBook, + DeleteBook, + ApproveBook, + DonateBook } + + public static List AdminPermissions { get; } = new List() { Permission.ApproveBook, Permission.DonateBook }; } diff --git a/ShareBook/ShareBook.Service/User/IUserService.cs b/ShareBook/ShareBook.Service/User/IUserService.cs index 2f84c37a..014b6901 100644 --- a/ShareBook/ShareBook.Service/User/IUserService.cs +++ b/ShareBook/ShareBook.Service/User/IUserService.cs @@ -22,6 +22,8 @@ public interface IUserService : IBaseService Task> GetBySolicitedBookCategoryAsync(Guid bookCategoryId); Task GetStatsAsync(Guid? userId); Task> InsertAsync(RegisterUserDTO userDto); - Task ParentAprovalAsync(string parentHashCodeAproval); + Task ParentAprovalAsync(string parentHashCodeAproval); + Task GenerateUnsubscriptionToken(string email); + Task Unsubscribe(string unsubToken); } } diff --git a/ShareBook/ShareBook.Service/User/UserService.cs b/ShareBook/ShareBook.Service/User/UserService.cs index af52d35d..a1323bbc 100644 --- a/ShareBook/ShareBook.Service/User/UserService.cs +++ b/ShareBook/ShareBook.Service/User/UserService.cs @@ -6,6 +6,7 @@ using AutoMapper; using FluentValidation; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using ShareBook.Domain; using ShareBook.Domain.Common; using ShareBook.Domain.DTOs; @@ -15,6 +16,7 @@ using ShareBook.Repository; using ShareBook.Repository.Repository; using ShareBook.Repository.UoW; +using ShareBook.Service.Authorization; using ShareBook.Service.Generic; using ShareBook.Service.Recaptcha; @@ -26,8 +28,10 @@ public class UserService : BaseService, IUserService private readonly IBookRepository _bookRepository; private readonly IUserEmailService _userEmailService; private readonly IRecaptchaService _recaptchaService; - - private readonly IMapper _mapper; + private readonly IMapper _mapper; + private readonly ICrypto _crypto; + private readonly ILogger _logger; + private readonly ApplicationDbContext _context; #region Public @@ -37,13 +41,16 @@ public UserService(IUserRepository userRepository, IBookRepository bookRepositor IValidator validator, IMapper mapper, IUserEmailService userEmailService, - IRecaptchaService recaptchaService) : base(userRepository, unitOfWork, validator) + IRecaptchaService recaptchaService, ICrypto crypto, ILogger logger, ApplicationDbContext context) : base(userRepository, unitOfWork, validator) { _userRepository = userRepository; _userEmailService = userEmailService; _bookRepository = bookRepository; _mapper = mapper; - _recaptchaService = recaptchaService; + _recaptchaService = recaptchaService; + _crypto = crypto; + _logger = logger; + _context = context; } public async Task> AuthenticationByEmailAndPasswordAsync(User user) @@ -344,9 +351,59 @@ public async Task ParentAprovalAsync(string parentHashCodeAproval) await _userRepository.UpdateAsync(user); await _userEmailService.SendEmailParentAprovedNotifyUserAsync(user); - } + } + + public async Task GenerateUnsubscriptionToken(string email) + { + var user = await _repository.Get() + .Where(u => u.Email == email) + .FirstOrDefaultAsync(); - + if (user == null) + throw new ShareBookException(ShareBookException.Error.NotFound, "Nenhum usuário encontrado."); + + DateTime expires = DateTime.Now.AddDays(5); + var unsubToken = user.Id.ToString() + "__" + expires.ToString("yyyy-MM-dd"); + unsubToken = _crypto.Encrypt(unsubToken); + return unsubToken; + } + + public async Task Unsubscribe(string unsubtoken) + { + try + { + string userId; + DateOnly expires; + + var tokenData = _crypto.Decrypt(unsubtoken).Split("__"); + if (tokenData.Length != 2) throw new Exception("Erro ao desinscrever. Token inválido: " + unsubtoken); + + userId = tokenData[0]; + expires = DateOnly.ParseExact(tokenData[1], "yyyy-MM-dd"); + + var user = await _repository.Get() + .Where(u => u.Id == new Guid(userId)) + .FirstOrDefaultAsync(); + if (user == null) throw new Exception("Erro ao desinscrever. Nenhum usuário encontrado com id: " + userId); + + DateOnly today = DateOnly.FromDateTime(DateTime.Now); + if(expires < today) throw new Exception("Erro ao desinscrever. Token expirado em: " + expires.ToString("yyyy-MM-dd")); + + + // enfim, estando tudo certo, desisncreve o usuário + user.AllowSendingEmail = false; + _context.SaveChanges(); + } + catch (Exception ex) + { + // loga o erro, mas não mostra pro usuário por questão de segurança. + _logger.LogError(ex.Message); + throw new Exception("Ocorreu um erro ao desinscrever. Tente novamente mais tarde."); + } + } + + + #endregion Private } } \ No newline at end of file From 075318cc5587f1eb23f183e32e902baea40b7b0e Mon Sep 17 00:00:00 2001 From: Raffaello Damgaard Date: Wed, 25 Dec 2024 20:14:57 -0300 Subject: [PATCH 2/2] Incluindo o link para se desinscrever no email promocional de novo livro. --- .../Email/Templates/NewBookNotifyTemplate.html | 6 ++++-- .../Jobs/5 - NewBookGetInterestedUsers.cs | 3 ++- ShareBook/Sharebook.Jobs/Jobs/7 - MailSender.cs | 17 ++++++++++++----- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/ShareBook/ShareBook.Service/Email/Templates/NewBookNotifyTemplate.html b/ShareBook/ShareBook.Service/Email/Templates/NewBookNotifyTemplate.html index 17317529..15dcb86a 100644 --- a/ShareBook/ShareBook.Service/Email/Templates/NewBookNotifyTemplate.html +++ b/ShareBook/ShareBook.Service/Email/Templates/NewBookNotifyTemplate.html @@ -777,9 +777,11 @@

- Copyleft © 2023 Sharebook, All rights open source. + Copyleft © 2025 Sharebook, All rights open source. -
+ +
+ Não deseja mais receber esse tipo de email? Tudo bem, use esse link para se desisncrever. diff --git a/ShareBook/Sharebook.Jobs/Jobs/5 - NewBookGetInterestedUsers.cs b/ShareBook/Sharebook.Jobs/Jobs/5 - NewBookGetInterestedUsers.cs index 65df25ad..31b6b070 100644 --- a/ShareBook/Sharebook.Jobs/Jobs/5 - NewBookGetInterestedUsers.cs +++ b/ShareBook/Sharebook.Jobs/Jobs/5 - NewBookGetInterestedUsers.cs @@ -124,7 +124,8 @@ private async Task GetEmailTemplateAsync(Guid bookId){ BookSlug = book.Slug, BookImageSlug = book.ImageSlug, SharebookBaseUrl = _configuration["ServerSettings:DefaultUrl"], - Name = "{Name}"// o MailSender vai trocar pelo nome do usuário. + Name = "{Name}", // o MailSender vai trocar pelo nome do usuário. + UnsubToken = "{UnsubToken}", // o MailSender vai trocar pelo unsub token. }; return await _emailTemplate.GenerateHtmlFromTemplateAsync("NewBookNotifyTemplate", vm); diff --git a/ShareBook/Sharebook.Jobs/Jobs/7 - MailSender.cs b/ShareBook/Sharebook.Jobs/Jobs/7 - MailSender.cs index 0d046bda..a44c730a 100644 --- a/ShareBook/Sharebook.Jobs/Jobs/7 - MailSender.cs +++ b/ShareBook/Sharebook.Jobs/Jobs/7 - MailSender.cs @@ -22,14 +22,16 @@ public class MailSender : GenericJob, IJob private readonly MailSenderLowPriorityQueue _sqsLowPriority; private readonly IConfiguration _configuration; private string _lastQueue; - private IList _log; + private IList _log; + private readonly IUserService _userService; public MailSender( IJobHistoryRepository jobHistoryRepo, IEmailService emailService, MailSenderLowPriorityQueue sqsLowPriority, MailSenderHighPriorityQueue sqsHighPriority, - IConfiguration configuration) : base(jobHistoryRepo) + IConfiguration configuration, + IUserService userService) : base(jobHistoryRepo) { JobName = "MailSender"; @@ -43,7 +45,8 @@ public MailSender( _sqsLowPriority = sqsLowPriority; _sqsHighPriority = sqsHighPriority; _configuration = configuration; - _log = new List(); + _log = new List(); + _userService = userService; } public override async Task WorkAsync() @@ -96,13 +99,17 @@ private async Task SendEmailAsync(SharebookMessage sqsMessa } string firstName = GetFirstName(destination.Name); - var bodyHtml2 = bodyHtml.Replace("{name}", firstName, StringComparison.OrdinalIgnoreCase); + var bodyHtml2 = bodyHtml.Replace("{name}", firstName, StringComparison.OrdinalIgnoreCase); + + var unsubToken = await _userService.GenerateUnsubscriptionToken(destination.Email); + bodyHtml2 = bodyHtml.Replace("{unsubToken}", unsubToken, StringComparison.OrdinalIgnoreCase); + await _emailService.SendSmtpAsync(destination.Email, destination.Name, bodyHtml2, subject, copyAdmins); _log.Add($"Enviei um email com SUCESSO para {destination.Email}."); } catch(Exception ex) { - RollbarLocator.RollbarInstance.Error(ex); + RollbarLocator.RollbarInstance.Error(ex); // deveria estar abstraído no uso do illoger _log.Add($"Ocorreu um ERRO ao enviar email para {destination.Email}. Erro: {ex.Message}"); }