From 0a43e35797e5085e736e1ab488d088d913746005 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Sun, 8 Feb 2026 22:30:29 +0700 Subject: [PATCH] test to make server --- .../IntegrationTests/Hubs/ChatsHubTests.cs | 4 +- .../ConfigurationProgramExtensions.cs | 9 +- Govor.API/Common/Mapping/MappingProfile.cs | 3 + ...serProfileToUserProfileDtoMappingAction.cs | 22 ++ Govor.API/Controllers/ChatLoadController.cs | 10 +- .../Controllers/PrivateChatController.cs | 95 ++++++ Govor.API/Controllers/ProfileController.cs | 8 - Govor.API/Hubs/ChatsHub.cs | 311 ++++++------------ Govor.API/Hubs/FriendsHub.cs | 14 +- .../Hubs/Infrastructure/ChatHubConstants.cs | 13 + .../Infrastructure/ChatNotificationService.cs | 73 ++++ .../Hubs/Infrastructure/ConnectionManager.cs | 46 +++ .../IChatNotificationService.cs | 10 + .../Hubs/Infrastructure/IConnectionManager.cs | 7 + Govor.API/Hubs/PresenceHub.cs | 54 ++- Govor.API/Hubs/ProfileHub.cs | 188 +++++------ Govor.API/Program.cs | 4 + Govor.API/Properties/launchSettings.json | 4 +- Govor.API/appsettings.json | 4 +- .../Services/SynchingServiceTests.cs | 105 ++++++ ...sts.cs => UserGroupsGetterServiceTests.cs} | 10 +- .../UserSessions/UserSessionRefresherTests.cs | 3 + .../Interfaces/Friends/IFriendshipService.cs | 1 + .../Interfaces/IMessagesLoader.cs | 2 +- .../Interfaces/ISynchingService.cs | 11 + ...Service.cs => IUserGroupsGetterService.cs} | 2 +- .../IUserPrivateChatsGetterService.cs | 8 + .../Interfaces/Medias/IMediaService.cs | 2 + .../UserOnlineStatus/IOnlineUserStore.cs | 5 +- .../Services/Authentication/JwtTokenHasher.cs | 20 +- .../Friends/FriendRequestCommandService.cs | 37 ++- .../Services/Friends/FriendshipService.cs | 18 + .../Services/Medias/MediaService.cs | 13 + .../Messages/MessageCommandService.cs | 20 +- .../Services/Messages/MessagesLoader.cs | 14 +- Govor.Application/Services/SynchingService.cs | 20 ++ ...sService.cs => UserGroupsGetterService.cs} | 4 +- .../UserOnlineStatus/OnlineUserStore.cs | 59 +++- .../UserNotificationScopeService.cs | 2 +- .../Services/UserPrivateChatsGetter.cs | 28 ++ .../UserSessions/UserSessionRefresher.cs | 12 +- .../Govor.ConsoleClient.Tests.csproj | 5 +- .../Commands/SendMessageCommand.cs | 4 +- Govor.Contracts/DTOs/AvatarUpdatePayload.cs | 7 + .../DTOs/DescriptionUpdatePayload.cs | 7 + Govor.Contracts/DTOs/UserProfileDto.cs | 1 + .../Responses/SignalR/UserMessageResponse.cs | 20 +- Govor.Core/Models/Users/User.cs | 14 +- .../Friendships/IFriendshipsExist.cs | 1 + .../Friendships/IFriendshipsReader.cs | 1 + .../PrivateChats/IPrivateChatsReader.cs | 1 + .../Configurations/UserConfiguration.cs | 6 +- .../Repositories/FriendshipsRepository.cs | 27 ++ .../Repositories/PrivateChatsRepository.cs | 8 + .../Repositories/UserSessionsRepository.cs | 2 - Govor.Data/Repositories/UsersRepository.cs | 12 +- 56 files changed, 949 insertions(+), 442 deletions(-) create mode 100644 Govor.API/Common/Mapping/UserProfileToUserProfileDtoMappingAction.cs create mode 100644 Govor.API/Controllers/PrivateChatController.cs create mode 100644 Govor.API/Hubs/Infrastructure/ChatHubConstants.cs create mode 100644 Govor.API/Hubs/Infrastructure/ChatNotificationService.cs create mode 100644 Govor.API/Hubs/Infrastructure/ConnectionManager.cs create mode 100644 Govor.API/Hubs/Infrastructure/IChatNotificationService.cs create mode 100644 Govor.API/Hubs/Infrastructure/IConnectionManager.cs create mode 100644 Govor.Application.Tests/Services/SynchingServiceTests.cs rename Govor.Application.Tests/Services/{UserGroupsServiceTests.cs => UserGroupsGetterServiceTests.cs} (84%) create mode 100644 Govor.Application/Interfaces/ISynchingService.cs rename Govor.Application/Interfaces/{IUserGroupsService.cs => IUserGroupsGetterService.cs} (74%) create mode 100644 Govor.Application/Interfaces/IUserPrivateChatsGetterService.cs create mode 100644 Govor.Application/Services/SynchingService.cs rename Govor.Application/Services/{UserGroupsService.cs => UserGroupsGetterService.cs} (80%) create mode 100644 Govor.Application/Services/UserPrivateChatsGetter.cs create mode 100644 Govor.Contracts/DTOs/AvatarUpdatePayload.cs create mode 100644 Govor.Contracts/DTOs/DescriptionUpdatePayload.cs diff --git a/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs index 01ce8af..eb81ab1 100644 --- a/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs +++ b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs @@ -14,7 +14,7 @@ public class ChatsHubTests { private Mock> _loggerMock; private Mock _messageServiceMock; - private Mock _userGroupsServiceMock; + private Mock _userGroupsServiceMock; private Mock _hubUserAccessorMock; private Fixture _fixture; private ChatsHub _chatsHub; @@ -27,7 +27,7 @@ public class ChatsHubTests _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); _messageServiceMock = new Mock(); - _userGroupsServiceMock = new Mock(); + _userGroupsServiceMock = new Mock(); _loggerMock = new Mock>(); _hubUserAccessorMock = new Mock(); diff --git a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs index 301cf24..b8b91e6 100644 --- a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs @@ -1,5 +1,6 @@ using Govor.API.Common.Mapping; using Govor.API.Common.SignalR.Helpers; +using Govor.API.Hubs.Infrastructure; using Govor.Application.Infrastructure.AdminsStuff; using Govor.Application.Infrastructure.Extensions; using Govor.Application.Infrastructure.Validators; @@ -52,6 +53,7 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Friends services services.AddScoped(); @@ -74,7 +76,8 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -87,6 +90,10 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddSingleton(); + // Hubs Infrastructure + services.AddScoped(); + services.AddScoped(); + // Auto Mapper services.AddAutoMapper(typeof(MappingProfile)); diff --git a/Govor.API/Common/Mapping/MappingProfile.cs b/Govor.API/Common/Mapping/MappingProfile.cs index dcf9435..4ad619d 100644 --- a/Govor.API/Common/Mapping/MappingProfile.cs +++ b/Govor.API/Common/Mapping/MappingProfile.cs @@ -20,6 +20,9 @@ public class MappingProfile : Profile CreateMap() .AfterMap(); + + CreateMap() + .AfterMap(); CreateMap(); diff --git a/Govor.API/Common/Mapping/UserProfileToUserProfileDtoMappingAction.cs b/Govor.API/Common/Mapping/UserProfileToUserProfileDtoMappingAction.cs new file mode 100644 index 0000000..2a7be87 --- /dev/null +++ b/Govor.API/Common/Mapping/UserProfileToUserProfileDtoMappingAction.cs @@ -0,0 +1,22 @@ +using AutoMapper; +using Govor.Application.Interfaces.UserOnlineStatus; +using Govor.Application.Profiles; +using Govor.Contracts.DTOs; +using Govor.Core.Models.Users; + +namespace Govor.API.Common.Mapping; + +public class UserProfileToUserProfileDtoMappingAction : IMappingAction +{ + private readonly IOnlineUserStore _onlineUserStore; + + public UserProfileToUserProfileDtoMappingAction(IOnlineUserStore onlineUserStore) + { + _onlineUserStore = onlineUserStore; + } + + public void Process(UserProfile source, UserProfileDto destination, ResolutionContext context) + { + destination.IsOnline = _onlineUserStore.IsOnline(source.Id); + } +} \ No newline at end of file diff --git a/Govor.API/Controllers/ChatLoadController.cs b/Govor.API/Controllers/ChatLoadController.cs index fcda9ca..e4be8b9 100644 --- a/Govor.API/Controllers/ChatLoadController.cs +++ b/Govor.API/Controllers/ChatLoadController.cs @@ -11,7 +11,7 @@ namespace Govor.API.Controllers; [ApiController] [Authorize(Roles = "Admin, User")] -[Route("api/chats")] +[Route("api")] public class ChatLoadController : Controller { private readonly ICurrentUserService _currentUser; @@ -31,9 +31,9 @@ public class ChatLoadController : Controller _mapper = mapper; } - [HttpGet("group-messages")] + [HttpGet("groups/{groupId:guid}/messages")] public async Task GetGroupMessages( - Guid chatId, + Guid groupId, [FromQuery] MessageQuery query) { try @@ -42,7 +42,7 @@ public class ChatLoadController : Controller return BadRequest("Values must be non-negative and total must not exceed 100."); var result = await _messagesLoader.LoadMessagesInChatGroup( - chatId, + groupId, _currentUser.GetCurrentUserId(), query.StartMessageId, query.Before, @@ -74,7 +74,7 @@ public class ChatLoadController : Controller } } - [HttpGet("user-messages")] + [HttpGet("user/{userId:guid}/messages")] public async Task GetUserMessages( Guid userId, [FromQuery] MessageQuery query) diff --git a/Govor.API/Controllers/PrivateChatController.cs b/Govor.API/Controllers/PrivateChatController.cs new file mode 100644 index 0000000..a22e22e --- /dev/null +++ b/Govor.API/Controllers/PrivateChatController.cs @@ -0,0 +1,95 @@ +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Core.Models; +using Govor.Core.Repositories.PrivateChats; +using Govor.Core.Repositories.Users; +using Govor.Data.Repositories.Exceptions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers; + +[ApiController] +[Authorize(Roles = "Admin, User")] +[Route("api")] +public class PrivateChatController : Controller +{ + private readonly ICurrentUserService _currentUser; + private readonly IUsersRepository _usersRepository; + private readonly IVerifyFriendship _verifyFriendship; + private readonly IPrivateChatsRepository _privateChats; + private readonly ILogger _logger; + + public PrivateChatController( + ICurrentUserService currentUser, + IUsersRepository usersRepository, + IVerifyFriendship verifyFriendship, + IPrivateChatsRepository privateChats, + ILogger logger) + { + _currentUser = currentUser; + _usersRepository = usersRepository; + _verifyFriendship = verifyFriendship; + _privateChats = privateChats; + _logger = logger; + } + + [HttpGet("user/{friendId:guid}/private-chat")] + public async Task GetChatByFriendId(Guid friendId) + { + try + { + var currentId = _currentUser.GetCurrentUserId(); + + if (!await _usersRepository.ExistsByIdAsync(friendId)) + { + _logger.LogWarning("User not exist {0}", friendId); + return NotFound("User not exist."); + } + + await _verifyFriendship.VerifyAsync(currentId, friendId); + + + var chatId = await GetPrivateChatsIdAsync(currentId, friendId); + return Ok(chatId); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex.Message); + return Forbid(ex.Message); + } + catch (NotFoundException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, "Unexpected Error! Please try again later."); + } + } + + private async Task GetPrivateChatsIdAsync(Guid userA, Guid userB) + { + Guid recipientId; + // Makes new PrivateChat if not exist + if (!_privateChats.Exist(userA, userB)) + { + recipientId = Guid.NewGuid(); + await _privateChats.AddAsync(new PrivateChat() + { Id = recipientId, UserAId = userA, UserBId = userB }); + } + else + { + recipientId = (await _privateChats.GetByMembersAsync(userA, userB)).Id; + } + + return recipientId; + } +} \ No newline at end of file diff --git a/Govor.API/Controllers/ProfileController.cs b/Govor.API/Controllers/ProfileController.cs index f5091ee..3ce3b87 100644 --- a/Govor.API/Controllers/ProfileController.cs +++ b/Govor.API/Controllers/ProfileController.cs @@ -72,14 +72,6 @@ public class ProfileController : ControllerBase await _profileService.SetNewIcon(userId, mediaInfo.MediaId); var iconId = mediaInfo.MediaId; - var payload = new { userId, iconId }; - - await _profileHubContext.Clients.All.SendAsync( - "AvatarUpdated", - payload - ); - - return Ok(mediaInfo); } catch (System.Exception ex) diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index 9ffedfa..e9136fa 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -1,6 +1,6 @@ using Govor.API.Common.SignalR.Helpers; +using Govor.API.Hubs.Infrastructure; using Govor.Application.Exceptions.VerifyFriendship; -using Govor.Application.Interfaces; using Govor.Application.Interfaces.Messages; using Govor.Application.Interfaces.Messages.Parameters; using Govor.Contracts.Requests.SignalR; @@ -15,20 +15,23 @@ namespace Govor.API.Hubs; public class ChatsHub : Hub { private readonly ILogger _logger; - private readonly IMessageCommandService _messageCommandService; - private readonly IUserGroupsService _userService; + private readonly IMessageCommandService _commandService; private readonly IHubUserAccessor _userAccessor; + private readonly IChatNotificationService _notifier; + private readonly IConnectionManager _connectionManager; public ChatsHub( ILogger logger, - IMessageCommandService messageCommandService, - IUserGroupsService userService, - IHubUserAccessor userAccessor) + IMessageCommandService commandService, + IHubUserAccessor userAccessor, + IChatNotificationService notifier, + IConnectionManager connectionManager) { _logger = logger; - _messageCommandService = messageCommandService; - _userService = userService; + _commandService = commandService; _userAccessor = userAccessor; + _notifier = notifier; + _connectionManager = connectionManager; } public override async Task OnConnectedAsync() @@ -36,132 +39,58 @@ public class ChatsHub : Hub var userId = _userAccessor.GetUserId(Context); if (userId == Guid.Empty) { - _logger.LogWarning("User connected with invalid UserID claim."); - Context.Abort(); + Context.Abort(); return; } - - await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); - _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId} and added to their group", - userId, Context.ConnectionId); - - var userGroups = await _userService.GetUserGroupsAsync(userId); - - foreach (var group in userGroups) - { - await Groups.AddToGroupAsync(Context.ConnectionId, $"group_{group.Id}"); - } + await _connectionManager.OnConnectedAsync(Context.ConnectionId, userId); + _logger.LogInformation("User {UserId} connected", userId); await base.OnConnectedAsync(); } - public override async Task OnDisconnectedAsync(Exception exception) + public override async Task OnDisconnectedAsync(Exception? exception) { var userId = _userAccessor.GetUserId(Context, true); - if (userId != Guid.Empty) - { - await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString()); - _logger.LogInformation("User {UserId} disconnected with ConnectionId {ConnectionId} and removed from their group", - userId, Context.ConnectionId); - - var userGroups = await _userService.GetUserGroupsAsync(userId); - - foreach (var group in userGroups) - { - await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"group_{group.Id}"); - } - } - else if (exception != null) - { - _logger.LogWarning(exception, - "User disconnected with an exception and invalid UserID claim. ConnectionId: {ConnectionId}", - Context.ConnectionId); - } - else - { - _logger.LogInformation( - "User disconnected with no exception and invalid UserID claim. ConnectionId: {ConnectionId}", - Context.ConnectionId); - } + await _connectionManager.OnDisconnectedAsync(Context.ConnectionId, userId); + + if (exception != null) + _logger.LogWarning(exception, "User {UserId} disconnected with error", userId); + else + _logger.LogInformation("User {UserId} disconnected", userId); await base.OnDisconnectedAsync(exception); } - + + // --- SEND --- public async Task> Send(MessageRequest request) { - var senderId = _userAccessor.GetUserId(Context); - - if (string.IsNullOrWhiteSpace(request.EncryptedContent) && - (request.MediaAttachments == null || !request.MediaAttachments.Any())) + return await SafeExecute(async (userId) => { - _logger.LogWarning("Empty message received from user {UserId}", senderId); - return HubResult.BadRequest("Message must contain content or media."); - } + ValidateMessageRequest(request); - if (request.EncryptedContent.Length > 50_000) - { - _logger.LogWarning("User {SenderId} tried to send a too long message (length: {Length})", senderId, request.EncryptedContent.Length); - return HubResult.BadRequest("Message cannot exceed 50,000 characters."); - } + var sendParams = MapToSendMessage(request, userId); + var result = await _commandService.SendMessageAsync(sendParams); - _logger.LogInformation("Sending message from {SenderId} to {RecipientId} ({RecipientType})", - senderId, request.RecipientId, request.RecipientType); + if (!result.IsSuccess) + throw new InvalidOperationException(result.Exception.Message ?? "Failed to send message"); - var sendMessageParams = new SendMessage( - EncryptContent: request.EncryptedContent, - ReplyToMessageId: request.ReplyToMessageId, - FromUserId: senderId, - RecipientId: request.RecipientId, - RecipientType: request.RecipientType, - SendAt: DateTime.UtcNow, - Media: request.MediaAttachments?.Select(f => new SendMedia(f.MediaId, f.EncryptedKey)) ?? - Array.Empty() - ); + var response = MapToResponse(result.Message, request.ReplyToMessageId); + + await _notifier.NotifyMessageSentAsync(response); - try - { - var result = await _messageCommandService.SendMessageAsync(sendMessageParams); - - if (!result.IsSuccess || result.Message.Id == Guid.Empty) - return LogAndError(senderId, request.RecipientId, "Failed to send message", result.Exception); - - var response = BuildUserMessageResponse(result.Message, request.ReplyToMessageId); - - await NotifyClientsAboutMessage(response); - - return HubResult.Ok(response); - } - catch (UnauthorizedAccessException ex) - { - return LogAndUnauthorized(ex, "Unauthorized sending attempt", senderId, - request.RecipientId); - } - catch (FriendshipException) - { - return HubResult.Unauthorized( - "You cannot send this message because you are not friends."); - } - catch (ArgumentException) - { - return HubResult.BadRequest("Invalid message content."); - } - catch (Exception ex) - { - return LogAndError(senderId, request.RecipientId, "Unhandled exception", ex); - } - } + return HubResult.Ok(response); + }, request.RecipientId); + } + // --- REMOVE --- public async Task> Remove(RemoveMessageRequest request) { - var removerId = _userAccessor.GetUserId(Context); - _logger.LogInformation("Removing message {MessageId} by user {RemoverId}", request.MessageId, removerId); - - try + return await SafeExecute(async (userId) => { - var result = await _messageCommandService.DeleteMessageAsync(new DeleteMessage(removerId, request.MessageId)); + var result = await _commandService.DeleteMessageAsync(new DeleteMessage(userId, request.MessageId)); if (!result.IsSuccess || result.OriginalMessage == null) - return LogAndError(removerId, request.MessageId, "Message deletion failed", result.Exception); + throw new InvalidOperationException("Message deletion failed"); var notification = new MessageRemovedResponse { @@ -171,76 +100,98 @@ public class ChatsHub : Hub RecipientType = result.OriginalMessage.RecipientType }; - await NotifyClientsAboutRemoval(notification); + await _notifier.NotifyMessageRemovedAsync(notification); - _logger.LogInformation("Message {MessageId} removed successfully by {RemoverId}", request.MessageId, removerId); return HubResult.Ok(notification); - } - catch (UnauthorizedAccessException ex) - { - return LogAndUnauthorized(ex, "Unauthorized removal", removerId, request.MessageId); - } - catch (KeyNotFoundException ex) - { - return LogAndNotFound(ex, "Message not found", removerId, request.MessageId); - } - catch (Exception ex) - { - return LogAndError(removerId, request.MessageId, "Unhandled deletion error", ex); - } + }, request.MessageId); } + // --- EDIT --- public async Task> Edit(EditMessageRequest request) { - var editor = _userAccessor.GetUserId(Context); - _logger.LogInformation("Editing message {MessageId} by user {EditorId}", request.MessageId, editor); - - var editMessageParam = new EditMessage(editor, - request.MessageId, - request.NewEncryptedContent, - DateTime.UtcNow); - - try + return await SafeExecute(async (userId) => { - var result = await _messageCommandService.EditMessageAsync(editMessageParam); + var editParams = new EditMessage(userId, request.MessageId, request.NewEncryptedContent, DateTime.UtcNow); + var result = await _commandService.EditMessageAsync(editParams); if (!result.IsSuccess || result.OriginalMessage == null) - return LogAndError(editor, request.MessageId, "Edit message error", - result.Exception); + throw new InvalidOperationException("Edit message error"); - var response = new MessageEditResponse() + var response = new MessageEditResponse { MessageId = result.messageId, - EditorId = editor, + EditorId = userId, RecipientId = result.OriginalMessage.RecipientId, RecipientType = result.OriginalMessage.RecipientType, NewEncryptedContent = request.NewEncryptedContent, - EditedAt = editMessageParam.EditedAt, + EditedAt = editParams.EditedAt, }; - - await NotifyClientsAboutEdit(response); - - _logger.LogInformation("Message {MessageId} edited successfully by {editor}", request.MessageId, editor); - + + await _notifier.NotifyMessageEditedAsync(response); + return HubResult.Ok(response); + }, request.MessageId); + } + + private async Task> SafeExecute(Func>> action, Guid targetIdForLog) + { + var userId = _userAccessor.GetUserId(Context); + try + { + return await action(userId); } catch (UnauthorizedAccessException ex) { - return LogAndUnauthorized(ex, "Unauthorized editing", editor, request.MessageId); + _logger.LogWarning(ex, "Unauthorized: {UserId} -> {TargetId}", userId, targetIdForLog); + return HubResult.Unauthorized("You are not authorized."); } - catch (KeyNotFoundException ex) + catch (FriendshipException) { - return LogAndNotFound(ex, "Message not found", editor, request.MessageId); + return HubResult.Unauthorized("You cannot perform this action due to friendship status."); + } + catch (ArgumentException ex) + { + return HubResult.BadRequest(ex.Message); + } + catch (KeyNotFoundException) + { + return HubResult.NotFound("Resource not found."); } catch (Exception ex) { - return LogAndError(editor, request.MessageId, "Unhandled exception error", ex); + _logger.LogError(ex, "Error executing hub method for {UserId}", userId); + return HubResult.Error("Internal server error"); + } + } + + private void ValidateMessageRequest(MessageRequest request) + { + if (string.IsNullOrWhiteSpace(request.EncryptedContent) && + (request.MediaAttachments == null || !request.MediaAttachments.Any())) + { + throw new ArgumentException("Message must contain content or media."); + } + if (request.EncryptedContent.Length > 50_000) + { + throw new ArgumentException("Message is too long."); } } + private SendMessage MapToSendMessage(MessageRequest request, Guid senderId) + { + return new SendMessage( + EncryptContent: request.EncryptedContent, + ReplyToMessageId: request.ReplyToMessageId, + FromUserId: senderId, + RecipientId: request.RecipientId, + RecipientType: request.RecipientType, + SendAt: DateTime.UtcNow, + Media: request.MediaAttachments?.Select(f => new SendMedia(f.MediaId, f.EncryptedKey)) + ?? Array.Empty() + ); + } - #region common - private UserMessageResponse BuildUserMessageResponse(Message message, Guid? replyToId) + private UserMessageResponse MapToResponse(Message message, Guid? replyToId) { return new UserMessageResponse { @@ -255,62 +206,4 @@ public class ChatsHub : Hub ReplyToMessageId = replyToId }; } - - private async Task NotifyClientsAboutMessage(UserMessageResponse response) - { - string group = response.RecipientType == RecipientType.User - ? response.RecipientId.ToString() - : $"group_{response.RecipientId}"; - - await Clients.Group(group).SendAsync("ReceiveMessage", response); - await Clients.Caller.SendAsync("MessageSent", response); - } - - private async Task NotifyClientsAboutRemoval(MessageRemovedResponse response) - { - if (response.RecipientType == RecipientType.User) - { - await Clients.Group(response.SenderId.ToString()).SendAsync("MessageRemoved", response); - if (response.SenderId != response.RecipientId) - await Clients.Group(response.RecipientId.ToString()).SendAsync("MessageRemoved", response); - } - else - { - await Clients.Group($"group_{response.RecipientId}").SendAsync("MessageRemoved", response); - } - } - - private async Task NotifyClientsAboutEdit(MessageEditResponse response) - { - if (response.RecipientType == RecipientType.User) - { - await Clients.Group(response.EditorId.ToString()).SendAsync("MessageEdit", response); - if (response.EditorId != response.RecipientId) - await Clients.Group(response.RecipientId.ToString()).SendAsync("MessageEdit", response); - } - else - { - await Clients.Group($"group_{response.RecipientId}").SendAsync("MessageEdit", response); - } - } - - // Logging helpers - private HubResult LogAndError(Guid userId, Guid targetId, string message, Exception ex) - { - _logger.LogError(ex, "{Message} from {UserId} to {TargetId}", message, userId, targetId); - return HubResult.Error(ex?.Message ?? "Internal server error"); - } - - private HubResult LogAndUnauthorized(Exception ex, string msg, Guid userId, Guid targetId) - { - _logger.LogWarning(ex, "{Msg}: {UserId} -> {TargetId}", msg, userId, targetId); - return HubResult.Unauthorized("You are not authorized to perform this action."); - } - - private HubResult LogAndNotFound(Exception ex, string msg, Guid userId, Guid targetId) - { - _logger.LogWarning(ex, "{Msg}: {UserId} -> {TargetId}", msg, userId, targetId); - return HubResult.NotFound("Message not found."); - } - #endregion } \ No newline at end of file diff --git a/Govor.API/Hubs/FriendsHub.cs b/Govor.API/Hubs/FriendsHub.cs index c9a379c..87b1a36 100644 --- a/Govor.API/Hubs/FriendsHub.cs +++ b/Govor.API/Hubs/FriendsHub.cs @@ -44,6 +44,7 @@ public class FriendsHub : Hub await base.OnConnectedAsync(); } + public override async Task OnDisconnectedAsync(Exception? exception) { var userId = _userAccessor.GetUserId(Context, true); @@ -76,10 +77,14 @@ public class FriendsHub : Hub { var userId = _userAccessor.GetUserId(Context); var friendship = await _friendRequestService.SendAsync(userId, targetUserId); + var dto = _mapper.Map(friendship); await Clients.Group(targetUserId.ToString()) - .SendAsync("FriendRequestReceived", _mapper.Map(friendship)); - + .SendAsync("FriendRequestReceived", dto); + + await Clients.Group(userId.ToString()) + .SendAsync("YourFriendRequestReceived", dto); + _logger.LogInformation($"Friend request received for user {targetUserId} from {userId}."); return HubResult.Created(); } @@ -111,12 +116,13 @@ public class FriendsHub : Hub { var userId = _userAccessor.GetUserId(Context); var friendship = await _friendRequestService.AcceptAsync(friendshipId, userId); + var dto = _mapper.Map(friendship); await Clients.Group(userId.ToString()) - .SendAsync("FriendRequestAccepted", _mapper.Map(friendship)); + .SendAsync("FriendRequestAccepted", dto); await Clients.Group(friendship.RequesterId.ToString()) - .SendAsync( "YourFriendRequestAccepted", _mapper.Map(friendship.Addressee)); + .SendAsync( "YourFriendRequestAccepted", dto); _logger.LogInformation($"Friend request accepted for user {userId} from {userId}."); return HubResult.Ok(); diff --git a/Govor.API/Hubs/Infrastructure/ChatHubConstants.cs b/Govor.API/Hubs/Infrastructure/ChatHubConstants.cs new file mode 100644 index 0000000..dc8c8fd --- /dev/null +++ b/Govor.API/Hubs/Infrastructure/ChatHubConstants.cs @@ -0,0 +1,13 @@ +namespace Govor.API.Hubs.Infrastructure; + +public static class ChatHubConstants +{ + public const string ReceiveMessage = "ReceiveMessage"; + public const string MessageSent = "MessageSent"; + public const string MessageRemoved = "MessageRemoved"; + public const string MessageEdited = "MessageEdit"; + + public static string GetUserGroup(Guid userId) => userId.ToString(); + public static string GetChatGroup(Guid groupId) => $"group_{groupId}"; + public static string GetPrivateChat(Guid groupId) => $"private_{groupId}"; +} \ No newline at end of file diff --git a/Govor.API/Hubs/Infrastructure/ChatNotificationService.cs b/Govor.API/Hubs/Infrastructure/ChatNotificationService.cs new file mode 100644 index 0000000..8e42900 --- /dev/null +++ b/Govor.API/Hubs/Infrastructure/ChatNotificationService.cs @@ -0,0 +1,73 @@ +using Govor.Contracts.Responses.SignalR; +using Govor.Core.Models.Messages; +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Hubs.Infrastructure; + +public class ChatNotificationService : IChatNotificationService +{ +private readonly IHubContext _hubContext; + + public ChatNotificationService(IHubContext hubContext) + { + _hubContext = hubContext; + } + + public async Task NotifyMessageSentAsync(UserMessageResponse message) + { + if (message.RecipientType == RecipientType.User) + { + await _hubContext.Clients.Group(ChatHubConstants.GetPrivateChat(message.RecipientId)) + .SendAsync(ChatHubConstants.ReceiveMessage, message); + } + else + { + await _hubContext.Clients.Group(ChatHubConstants.GetChatGroup(message.RecipientId)) + .SendAsync(ChatHubConstants.ReceiveMessage, message); + } + + // await _hubContext.Clients.Group(ChatHubConstants.GetUserGroup(message.SenderId)) + // .SendAsync(ChatHubConstants.MessageSent, message); + } + + public async Task NotifyMessageRemovedAsync(MessageRemovedResponse response) + { + await NotifyParticipantsAsync( + response.SenderId, + response.RecipientId, + response.RecipientType, + ChatHubConstants.MessageRemoved, + response); + } + + public async Task NotifyMessageEditedAsync(MessageEditResponse response) + { + await NotifyParticipantsAsync( + response.EditorId, + response.RecipientId, + response.RecipientType, + ChatHubConstants.MessageEdited, + response); + } + + private async Task NotifyParticipantsAsync(Guid initiatorId, Guid targetId, RecipientType type, string method, object payload) + { + if (type == RecipientType.User) + { + await _hubContext.Clients.Group(ChatHubConstants.GetUserGroup(initiatorId)) + .SendAsync(method, payload); + + if (initiatorId != targetId) + { + await _hubContext.Clients.Group(ChatHubConstants.GetUserGroup(targetId)) + .SendAsync(method, payload); + } + } + else + { + // to groups + await _hubContext.Clients.Group(ChatHubConstants.GetChatGroup(targetId)) + .SendAsync(method, payload); + } + } +} \ No newline at end of file diff --git a/Govor.API/Hubs/Infrastructure/ConnectionManager.cs b/Govor.API/Hubs/Infrastructure/ConnectionManager.cs new file mode 100644 index 0000000..d79af1d --- /dev/null +++ b/Govor.API/Hubs/Infrastructure/ConnectionManager.cs @@ -0,0 +1,46 @@ +using Govor.Application.Interfaces; +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Hubs.Infrastructure; + +public class ConnectionManager : IConnectionManager +{ + private readonly IUserGroupsGetterService _userGroupsGetterService; + private readonly IUserPrivateChatsGetterService _userPrivateChatsGetterService; + private readonly IHubContext _hubContext; + + public ConnectionManager(IUserGroupsGetterService userGroupsGetterService, IUserPrivateChatsGetterService userPrivateChatsGetterService, IHubContext hubContext) + { + _userGroupsGetterService = userGroupsGetterService; + _userPrivateChatsGetterService = userPrivateChatsGetterService; + _hubContext = hubContext; + } + + public async Task OnConnectedAsync(string connectionId, Guid userId) + { + // user + await _hubContext.Groups.AddToGroupAsync(connectionId, ChatHubConstants.GetUserGroup(userId)); + + // groups + var userGroups = await _userGroupsGetterService.GetUserGroupsAsync(userId); + foreach (var group in userGroups) + { + await _hubContext.Groups.AddToGroupAsync(connectionId, ChatHubConstants.GetChatGroup(group.Id)); + } + + // private chats + var chats = await _userPrivateChatsGetterService.GetUserChatsAsync(userId); + foreach (var group in chats) + { + await _hubContext.Groups.AddToGroupAsync(connectionId, ChatHubConstants.GetPrivateChat(group.Id)); + } + } + + public async Task OnDisconnectedAsync(string connectionId, Guid userId) + { + if (userId != Guid.Empty) + { + await _hubContext.Groups.RemoveFromGroupAsync(connectionId, ChatHubConstants.GetUserGroup(userId)); + } + } +} \ No newline at end of file diff --git a/Govor.API/Hubs/Infrastructure/IChatNotificationService.cs b/Govor.API/Hubs/Infrastructure/IChatNotificationService.cs new file mode 100644 index 0000000..410f039 --- /dev/null +++ b/Govor.API/Hubs/Infrastructure/IChatNotificationService.cs @@ -0,0 +1,10 @@ +using Govor.Contracts.Responses.SignalR; + +namespace Govor.API.Hubs.Infrastructure; + +public interface IChatNotificationService +{ + Task NotifyMessageSentAsync(UserMessageResponse message); + Task NotifyMessageRemovedAsync(MessageRemovedResponse response); + Task NotifyMessageEditedAsync(MessageEditResponse response); +} \ No newline at end of file diff --git a/Govor.API/Hubs/Infrastructure/IConnectionManager.cs b/Govor.API/Hubs/Infrastructure/IConnectionManager.cs new file mode 100644 index 0000000..1457926 --- /dev/null +++ b/Govor.API/Hubs/Infrastructure/IConnectionManager.cs @@ -0,0 +1,7 @@ +namespace Govor.API.Hubs.Infrastructure; + +public interface IConnectionManager +{ + Task OnConnectedAsync(string connectionId, Guid userId); + Task OnDisconnectedAsync(string connectionId, Guid userId); +} \ No newline at end of file diff --git a/Govor.API/Hubs/PresenceHub.cs b/Govor.API/Hubs/PresenceHub.cs index c06de5c..33dadb6 100644 --- a/Govor.API/Hubs/PresenceHub.cs +++ b/Govor.API/Hubs/PresenceHub.cs @@ -34,21 +34,22 @@ public class PresenceHub : Hub public override async Task OnConnectedAsync() { var userId = _userAccessor.GetUserId(Context); - if (userId == Guid.Empty || await _users.ExistsByIdAsync(userId) == false) + + if (userId == Guid.Empty) { _logger.LogWarning("User connected with invalid UserId claim."); Context.Abort(); return; } + + var isFirstConnection = _onlineUserStore.AddConnection(userId, Context.ConnectionId); - _onlineUserStore.SetOnlineUser(userId); await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); - var friends = await _scopeService.GetNotifiedUsers(userId); - - foreach (var recipient in friends) + if (isFirstConnection) { - await Clients.Group(recipient.ToString()) + var friends = await _scopeService.GetNotifiedUsers(userId); + await Clients.Groups(friends.Select(f => f.ToString()).ToList()) .SendAsync("UserOnline", userId); } @@ -74,21 +75,38 @@ public class PresenceHub : Hub return; } - _onlineUserStore.SetOfflineUser(userId); + var isLastConnection = _onlineUserStore.RemoveConnection(userId, Context.ConnectionId); - // Updating was online - var user = await _users.FindByIdAsync(userId); - user.WasOnline = DateTime.UtcNow; - await _users.UpdateAsync(user); - - var friends = await _scopeService.GetNotifiedUsers(userId); - - foreach (var recipient in friends) + if (isLastConnection) { - await Clients.Group(recipient.ToString()) - .SendAsync("UserOffline", userId); + _ = Task.Run(async () => + { + await Task.Delay(TimeSpan.FromSeconds(5)); + + var currentConnections = _onlineUserStore.GetConnections(userId); + if (currentConnections == null || !currentConnections.Any()) + { + var friends = await _scopeService.GetNotifiedUsers(userId); + await Clients.Groups(friends.Select(f => f.ToString()).ToList()) + .SendAsync("UserOffline", userId); + + try + { + var user = await _users.FindByIdAsync(userId); + if (user != null) + { + user.WasOnline = DateTime.UtcNow; + await _users.UpdateAsync(user); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating WasOnline for {UserId}", userId); + } + } + }); } - + await base.OnDisconnectedAsync(exception); } } \ No newline at end of file diff --git a/Govor.API/Hubs/ProfileHub.cs b/Govor.API/Hubs/ProfileHub.cs index cd74f69..27d1918 100644 --- a/Govor.API/Hubs/ProfileHub.cs +++ b/Govor.API/Hubs/ProfileHub.cs @@ -1,11 +1,9 @@ using Govor.API.Common.SignalR.Helpers; using Govor.Application.Interfaces; using Govor.Application.Interfaces.Friends; -using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Medias; +using Govor.Contracts.DTOs; using Govor.Contracts.Responses.SignalR; -using Govor.Core.Models; -using Govor.Core.Models.Users; using Govor.Core.Repositories.Groups; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; @@ -19,6 +17,7 @@ public class ProfileHub : Hub private readonly IFriendshipService _friendsService; private readonly IProfileService _profileService; private readonly IHubUserAccessor _userAccessor; + private readonly ISynchingService _synchingService; private readonly ILogger _logger; private readonly IMediaService _mediaService; @@ -27,46 +26,24 @@ public class ProfileHub : Hub IFriendshipService friendsService, IProfileService profileService, IHubUserAccessor userAccessor, + ISynchingService synchingService, + IMediaService mediaService, ILogger logger) { _groupsRepository = groupsRepository; _friendsService = friendsService; _profileService = profileService; _userAccessor = userAccessor; + _synchingService = synchingService; + _mediaService = mediaService; _logger = logger; } public override async Task OnConnectedAsync() { var userId = _userAccessor.GetUserId(Context); - - await Groups.AddToGroupAsync(Context.ConnectionId, $"user-{userId}"); - // Friends - try - { - var friendships = await _friendsService.GetFriendsAsync(userId); - foreach (var friends in friendships) - await Groups.AddToGroupAsync(Context.ConnectionId, $"friends-{friends.Id}"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to get friends for user {userId}", userId); - } - - // Groups - try - { - var groups = await _groupsRepository.GetByUserIdAsync(userId); - foreach (var group in groups) - await Groups.AddToGroupAsync(Context.ConnectionId, $"group-{group.Id}"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to get groups for user {userId}", userId); - } - - _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId}", userId, Context.ConnectionId); + await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}"); await base.OnConnectedAsync(); } @@ -76,57 +53,36 @@ public class ProfileHub : Hub var userId = _userAccessor.GetUserId(Context); await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user-{userId}"); - - // Friends - try - { - var friendships = await _friendsService.GetFriendsAsync(userId); - - foreach (var friendship in friendships) - await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"friends-{friendship.Id}"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to remove user {userId} from friend groups", userId); - } - - // Groups - try - { - var groups = await _groupsRepository.GetByUserIdAsync(userId); - - foreach (var group in groups) - await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"group-{group.Id}"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to remove user {userId} from groups", userId); - } - + await base.OnDisconnectedAsync(exception); } public async Task> SetDescription(string description) { - if(description.Length > 500) - return HubResult.Error("The description cannot be longer than 500 characters."); - + if (description.Length > 500) + return HubResult.Error("Description length exceeded."); + var userId = _userAccessor.GetUserId(Context); try { + description = _synchingService.NormalizeNewlines(description); await _profileService.SetDescription(description, userId); - var payload = new { userId, description }; + var payload = new DescriptionUpdatePayload + { + UserId = userId, + Description = description + }; - await NotifyFriendsAndGroupsAsync(userId, "DescriptionUpdated", payload); + await NotifyProfileUpdatedAsync(userId, "DescriptionUpdated", payload); return HubResult.Ok(true); } catch (Exception ex) { - _logger.LogError(ex, "An error occurred while updating the user's {userId} descripton!", userId); - return HubResult.Error("An unaccounted error on the server!"); + _logger.LogError(ex, "Failed to update description for user {UserId}", userId); + return HubResult.Error("Server error."); } } @@ -136,14 +92,14 @@ public class ProfileHub : Hub try { - if (iconId == Guid.Empty) - return HubResult.BadRequest("IconId can't be empty!"); + if (iconId == Guid.Empty || !await _mediaService.HasMediaAsync(iconId)) + return HubResult.BadRequest("Invalid icon id."); await _profileService.SetNewIcon(userId, iconId); - var payload = new { userId, iconId }; + var payload = new AvatarUpdatePayload(){UserId = userId, IconId = iconId}; - await NotifyFriendsAndGroupsAsync(userId, "AvatarUpdated", payload); + await NotifyProfileUpdatedAsync(userId, "AvatarUpdated", payload); return HubResult.Ok(true); } @@ -154,50 +110,70 @@ public class ProfileHub : Hub } } - private async Task NotifyFriendsAndGroupsAsync(Guid userId, string eventName, object payload) + private async Task NotifyProfileUpdatedAsync( + Guid UserId, + string eventName, + object payload) { - var friendIds = Enumerable.Empty(); - var groups = Enumerable.Empty(); - try { - friendIds = await _friendsService.GetFriendsAsync(userId); + var recipients = new HashSet(); + + // 1. Friends + try + { + var friends = await _friendsService.GetFriendsAsync(UserId); + recipients.UnionWith(friends.Select(f => f.Id)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "User has no friends for user {userId}", UserId); + } + + // 2. (pending) + try + { + var potentialFriends = await _friendsService.GetPotentialFriendsAsync(UserId); + recipients.UnionWith(potentialFriends.Select(p => p.Id)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "User has potential friends for user {userId}", UserId); + } + + + // 3. groups + /*var groups = await _groupsRepository.GetByUserIdAsync(authorUserId); + foreach (var group in groups) + { + var members = + await _groupsRepository.Get(group.Id); + + recipients.UnionWith(members.Select(m => m.Id)); + }*/ + + recipients.Add(UserId); + + // 5. + // recipients.RemoveWhere(id => + // !_profileVisibilityService.CanViewProfile(id, authorUserId)); + + + // 6. 1 user = 1 event + var recipientsStrings = + recipients.Select(r => $"user:{r}").ToList(); + + foreach (var recipientId in recipients) + { + await Clients.Groups(recipientsStrings) + .SendAsync(eventName, payload); + } } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to load friend list for notifications. userId: {userId}", userId); + _logger.LogError(ex, + "Failed to notify profile update for user {UserId}", + UserId); } - - try - { - groups = await _groupsRepository.GetByUserIdAsync(userId); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to load group list for notifications. userId: {userId}", userId); - } - - var groupIds = groups.Select(g => g.Id).ToList(); - - // Current user - await Clients.Group($"user-{userId}") - .SendAsync(eventName, payload); - - // Friends - foreach (var friend in friendIds) - { - await Clients.Group($"friends-{friend.Id}") - .SendAsync(eventName, payload); - } - - // Groups - foreach (var groupId in groupIds) - { - await Clients.Group($"group-{groupId}") - .SendAsync(eventName, payload); - } - - _logger.LogInformation("Sent {EventName} for {UserId} to {Friends} friends and {Groups} groups", - eventName, userId, friendIds.Count(), groupIds.Count); } } diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index 6489eb3..9a27186 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -22,6 +22,8 @@ builder.Services.AddCors(options => policy.WithOrigins( "https://localhost:7155", "http://localhost:7155", + "http://192.168.1.107:8080", + "http://0.0.0.0:8080", "https://govor-team-govor-8ce1.twc1.net", "http://govor-team-govor-8ce1.twc1.net" ) @@ -113,6 +115,7 @@ if (!app.Environment.IsDevelopment()) { //app.MapOpenApi(); builder.WebHost.UseUrls("http://0.0.0.0:8080"); + builder.WebHost.UseUrls("http://192.168.1.107:8080"); } app.UseSwagger(); @@ -132,6 +135,7 @@ app.MapControllers(); app.MapHub("/hubs/chats"); app.MapHub("/hubs/friends"); app.MapHub("/hubs/profiles"); +app.MapHub("/hubs/presence"); app.MapSwagger() .RequireAuthorization(); diff --git a/Govor.API/Properties/launchSettings.json b/Govor.API/Properties/launchSettings.json index defffb2..67f4782 100644 --- a/Govor.API/Properties/launchSettings.json +++ b/Govor.API/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://0.0.0.0:8080;", + "applicationUrl": "http://0.0.0.0:8080;http://localhost:7155;http://192.168.1.107:8080;72.56.93.242:8080;", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "https://0.0.0.0:8080;", + "applicationUrl": "https://0.0.0.0:8080; https://localhost:7155", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/Govor.API/appsettings.json b/Govor.API/appsettings.json index d3b602d..77bb983 100644 --- a/Govor.API/appsettings.json +++ b/Govor.API/appsettings.json @@ -6,7 +6,7 @@ } }, "ConnectionStrings": { - "GovorDbContext": "Host=localhost;Port=5432;Database=GovorDb;Username=postgres;Password=stalcker;" + "GovorDbContext": "Host=72.56.93.203;Port=5432;Database=default_db;Username=gen_user;Password=9_-L%OLk+v(rQj;" }, "UseMySql": false, "AllowedHosts": "*", @@ -18,6 +18,6 @@ "RefreshTokenLifetimeDays": 30 }, "EncryptionOption": { - "Secret": "4r8B2j9kP5mX7nQwE2zY3A==" + "Secret": "8B2j9kkw9xP5m7nQwE2zY3A-=Q8zP7C4+TqLZpg" } } diff --git a/Govor.Application.Tests/Services/SynchingServiceTests.cs b/Govor.Application.Tests/Services/SynchingServiceTests.cs new file mode 100644 index 0000000..e18e30e --- /dev/null +++ b/Govor.Application.Tests/Services/SynchingServiceTests.cs @@ -0,0 +1,105 @@ +using Govor.Application.Services; + +namespace Govor.Application.Tests.Services; + +[TestFixture] +public class SynchingServiceTests +{ + private readonly SynchingService _sut; // System Under Test + + public SynchingServiceTests() + { + _sut = new SynchingService(); + } + + [Test] + public void NormalizeNewlines_ShouldReturnEmptyString_WhenInputIsEmpty() + { + // Act + var result = _sut.NormalizeNewlines(string.Empty); + + // Assert + Assert.That(result, Is.EqualTo(string.Empty)); + } + + [Test] + public void NormalizeNewlines_ShouldReturnNull_WhenInputIsNull() + { + // Arrange + string input = null; + + // Act + var result = _sut.NormalizeNewlines(input); + + // Assert + Assert.That(result, Is.Null); + } + + [Test] + public void NormalizeNewlines_ShouldNotChangeUnixStyleNewlines_WhenInputIsLF() + { + // Arrange + const string input = "Line 1\nLine 2\nLine 3"; + + // Act + var result = _sut.NormalizeNewlines(input); + + // Assert + Assert.That(result, Is.EqualTo(input)); + } + + [Test] + public void NormalizeNewlines_ShouldConvertWindowsStyleNewlines_WhenInputIsCRLF() + { + // Arrange + const string input = "Line 1\r\nLine 2\r\nLine 3"; + const string expected = "Line 1\nLine 2\nLine 3"; + + // Act + var result = _sut.NormalizeNewlines(input); + + // Assert + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void NormalizeNewlines_ShouldConvertMacStyleNewlines_WhenInputIsCR() + { + // Arrange + const string input = "Line 1\rLine 2\rLine 3"; + const string expected = "Line 1\nLine 2\nLine 3"; + + // Act + var result = _sut.NormalizeNewlines(input); + + // Assert + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void NormalizeNewlines_ShouldHandleMixedNewlines_WhenInputIsMixed() + { + // Arrange + const string input = "Line 1\r\nLine 2\rLine 3\nLine 4"; + const string expected = "Line 1\nLine 2\nLine 3\nLine 4"; + + // Act + var result = _sut.NormalizeNewlines(input); + + // Assert + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void NormalizeNewlines_ShouldHandleTextWithoutNewlines() + { + // Arrange + const string input = "This is a single line of text."; + + // Act + var result = _sut.NormalizeNewlines(input); + + // Assert + Assert.That(result, Is.EqualTo(input)); + } +} \ No newline at end of file diff --git a/Govor.Application.Tests/Services/UserGroupsServiceTests.cs b/Govor.Application.Tests/Services/UserGroupsGetterServiceTests.cs similarity index 84% rename from Govor.Application.Tests/Services/UserGroupsServiceTests.cs rename to Govor.Application.Tests/Services/UserGroupsGetterServiceTests.cs index d093df6..83af882 100644 --- a/Govor.Application.Tests/Services/UserGroupsServiceTests.cs +++ b/Govor.Application.Tests/Services/UserGroupsGetterServiceTests.cs @@ -9,11 +9,11 @@ using Moq; namespace Govor.Application.Tests.Services; [TestFixture] -public class UserGroupsServiceTests +public class UserGroupsGetterServiceTests { private Fixture _fixture; private Mock _repositoryMock; - private IUserGroupsService _service; + private IUserGroupsGetterService _getterService; [SetUp] public void SetUp() @@ -29,7 +29,7 @@ public class UserGroupsServiceTests _repositoryMock = new Mock(); - _service = new UserGroupsService(_repositoryMock.Object); + _getterService = new UserGroupsGetterService(_repositoryMock.Object); } [Test] @@ -43,7 +43,7 @@ public class UserGroupsServiceTests .ReturnsAsync([chats.First()]); // Act - var result = await _service.GetUserGroupsAsync(userId); + var result = await _getterService.GetUserGroupsAsync(userId); // Assert Assert.That(result, Is.Not.Null); @@ -61,7 +61,7 @@ public class UserGroupsServiceTests .ThrowsAsync(new NotFoundByKeyException(userId)); // Act - var result = await _service.GetUserGroupsAsync(userId); + var result = await _getterService.GetUserGroupsAsync(userId); // Assert Assert.That(result, Is.Not.Null); diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs index b86877c..4fc2e6b 100644 --- a/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs @@ -20,6 +20,7 @@ public class UserSessionRefresherTests private Mock _jwtServiceMock; private Mock> _loggerMock; private Mock> _optionsMock; + private Mock _jwtTokenHasherMock; private JwtRefreshOption _options; private UserSessionRefresher _refresher; private const string OldRefreshToken = "old-refresh-token"; @@ -36,6 +37,7 @@ public class UserSessionRefresherTests _jwtServiceMock = new Mock(); _loggerMock = new Mock>(); _optionsMock = new Mock>(); + _jwtTokenHasherMock = new Mock(); _options = new JwtRefreshOption { RefreshTokenLifetimeDays = 30 }; @@ -46,6 +48,7 @@ public class UserSessionRefresherTests _loggerMock.Object, _usersRepoMock.Object, _optionsMock.Object, + _jwtTokenHasherMock.Object, _jwtServiceMock.Object); _user = new User diff --git a/Govor.Application/Interfaces/Friends/IFriendshipService.cs b/Govor.Application/Interfaces/Friends/IFriendshipService.cs index e3d5a7c..0351278 100644 --- a/Govor.Application/Interfaces/Friends/IFriendshipService.cs +++ b/Govor.Application/Interfaces/Friends/IFriendshipService.cs @@ -5,5 +5,6 @@ namespace Govor.Application.Interfaces.Friends; public interface IFriendshipService { Task> GetFriendsAsync(Guid userId); + Task> GetPotentialFriendsAsync(Guid userId); Task> SearchUsersAsync(string query, Guid currentId); } \ No newline at end of file diff --git a/Govor.Application/Interfaces/IMessagesLoader.cs b/Govor.Application/Interfaces/IMessagesLoader.cs index 54fff2f..67633e1 100644 --- a/Govor.Application/Interfaces/IMessagesLoader.cs +++ b/Govor.Application/Interfaces/IMessagesLoader.cs @@ -4,6 +4,6 @@ namespace Govor.Application.Interfaces; public interface IMessagesLoader { - Task> LoadMessagesInUserChat(Guid userId,Guid currentId, Guid? startMessageId, int before = 20, int after = 2); + Task> LoadMessagesInUserChat(Guid privateChatId,Guid currentId, Guid? startMessageId, int before = 20, int after = 2); Task> LoadMessagesInChatGroup(Guid chatId,Guid currentId, Guid? startMessageId, int before = 20, int after = 2); } \ No newline at end of file diff --git a/Govor.Application/Interfaces/ISynchingService.cs b/Govor.Application/Interfaces/ISynchingService.cs new file mode 100644 index 0000000..ece6de5 --- /dev/null +++ b/Govor.Application/Interfaces/ISynchingService.cs @@ -0,0 +1,11 @@ +namespace Govor.Application.Interfaces; + +public interface ISynchingService +{ + /// + /// Brings all line breaks (CRLF, CR) to a single LF(\n) standard. + /// + /// The original line containing the line break. + /// A string normalized using only \n. + string NormalizeNewlines(string input); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/IUserGroupsService.cs b/Govor.Application/Interfaces/IUserGroupsGetterService.cs similarity index 74% rename from Govor.Application/Interfaces/IUserGroupsService.cs rename to Govor.Application/Interfaces/IUserGroupsGetterService.cs index 1e51276..a07ad39 100644 --- a/Govor.Application/Interfaces/IUserGroupsService.cs +++ b/Govor.Application/Interfaces/IUserGroupsGetterService.cs @@ -2,7 +2,7 @@ using Govor.Core.Models; namespace Govor.Application.Interfaces; -public interface IUserGroupsService +public interface IUserGroupsGetterService { Task> GetUserGroupsAsync(Guid userId); } \ No newline at end of file diff --git a/Govor.Application/Interfaces/IUserPrivateChatsGetterService.cs b/Govor.Application/Interfaces/IUserPrivateChatsGetterService.cs new file mode 100644 index 0000000..53dee37 --- /dev/null +++ b/Govor.Application/Interfaces/IUserPrivateChatsGetterService.cs @@ -0,0 +1,8 @@ +using Govor.Core.Models; + +namespace Govor.Application.Interfaces; + +public interface IUserPrivateChatsGetterService +{ + Task> GetUserChatsAsync(Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/Medias/IMediaService.cs b/Govor.Application/Interfaces/Medias/IMediaService.cs index 18769cd..53def6b 100644 --- a/Govor.Application/Interfaces/Medias/IMediaService.cs +++ b/Govor.Application/Interfaces/Medias/IMediaService.cs @@ -9,6 +9,8 @@ public interface IMediaService public Task DeleteMediaAsync(Guid fileId); public Task GetMediaByUrlAsync(string url); public Task GetMediaByIdAsync(Guid mediaId); + public Task HasMediaAsync(Guid mediaId); + public Task HasMediaByUrlAsync(string url); Task AttachToMessageAsync(Guid mediaId, Guid messageId); } diff --git a/Govor.Application/Interfaces/UserOnlineStatus/IOnlineUserStore.cs b/Govor.Application/Interfaces/UserOnlineStatus/IOnlineUserStore.cs index 65c4030..80512ee 100644 --- a/Govor.Application/Interfaces/UserOnlineStatus/IOnlineUserStore.cs +++ b/Govor.Application/Interfaces/UserOnlineStatus/IOnlineUserStore.cs @@ -2,8 +2,9 @@ namespace Govor.Application.Interfaces.UserOnlineStatus; public interface IOnlineUserStore { - void SetOnlineUser(Guid userId); - void SetOfflineUser(Guid userId); + bool AddConnection(Guid userId, string connectionId); + bool RemoveConnection(Guid userId, string connectionId); bool IsOnline(Guid userId); + IEnumerable GetConnections(Guid userId); IReadOnlyCollection GetAllOnlineUsers(); } \ No newline at end of file diff --git a/Govor.Application/Services/Authentication/JwtTokenHasher.cs b/Govor.Application/Services/Authentication/JwtTokenHasher.cs index 0e26f59..68d4a4a 100644 --- a/Govor.Application/Services/Authentication/JwtTokenHasher.cs +++ b/Govor.Application/Services/Authentication/JwtTokenHasher.cs @@ -1,16 +1,32 @@ +using System.Security.Cryptography; +using System.Text; using Govor.Application.Interfaces.Authentication; +using Microsoft.Extensions.Configuration; namespace Govor.Application.Services.Authentication; public class JwtTokenHasher : IJwtTokenHasher { + private readonly string _pepper; + + public JwtTokenHasher(IConfiguration config) + { + _pepper = config["EncryptionOption:Secret"] ?? "D1fault%Lxng%Randxm^Secret^Key(123!"; + } + public string HashToken(string token) { - return BCrypt.Net.BCrypt.HashPassword(token, workFactor: 13); + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_pepper)); + var bytes = Encoding.UTF8.GetBytes(token); + var hash = hmac.ComputeHash(bytes); + return Convert.ToBase64String(hash); } public bool VerifyToken(string token, string storedHash) { - return BCrypt.Net.BCrypt.Verify(token, storedHash); + var currentHashBytes = Encoding.UTF8.GetBytes(HashToken(token)); + var storedHashBytes = Encoding.UTF8.GetBytes(storedHash); + + return CryptographicOperations.FixedTimeEquals(currentHashBytes, storedHashBytes); } } \ No newline at end of file diff --git a/Govor.Application/Services/Friends/FriendRequestCommandService.cs b/Govor.Application/Services/Friends/FriendRequestCommandService.cs index 3e6b4bb..ad29313 100644 --- a/Govor.Application/Services/Friends/FriendRequestCommandService.cs +++ b/Govor.Application/Services/Friends/FriendRequestCommandService.cs @@ -19,20 +19,35 @@ public class FriendRequestCommandService : IFriendRequestCommandService { if (fromUserId == toUserId) throw new InvalidOperationException("Cannot send a request to self user"); + + var friendship = await _friendshipsRepository.GetFriendshipAsync(fromUserId, toUserId); - if (_friendshipsRepository.Exist(fromUserId, toUserId)) - throw new RequestAlreadySentException(fromUserId, toUserId); - - var friendship = new Friendship + if (friendship is null) { - Id = Guid.NewGuid(), - RequesterId = fromUserId, - AddresseeId = toUserId, - Status = FriendshipStatus.Pending - }; - - await _friendshipsRepository.AddAsync(friendship); + friendship = new Friendship + { + Id = Guid.NewGuid(), + RequesterId = fromUserId, + AddresseeId = toUserId, + Status = FriendshipStatus.Pending + }; + await _friendshipsRepository.AddAsync(friendship); + } + else + { + if (friendship.Status == FriendshipStatus.Pending || friendship.Status == FriendshipStatus.Accepted + || friendship.Status == FriendshipStatus.Blocked) + { + throw new RequestAlreadySentException(fromUserId, toUserId); + } + + friendship.RequesterId = fromUserId; + friendship.AddresseeId = toUserId; + friendship.Status = FriendshipStatus.Pending; + await _friendshipsRepository.UpdateAsync(friendship); + } + return friendship; } diff --git a/Govor.Application/Services/Friends/FriendshipService.cs b/Govor.Application/Services/Friends/FriendshipService.cs index 9944e68..fce9a29 100644 --- a/Govor.Application/Services/Friends/FriendshipService.cs +++ b/Govor.Application/Services/Friends/FriendshipService.cs @@ -19,6 +19,7 @@ public class FriendshipService : IFriendshipService _friendshipsRepository = friendshipsRepository; } + public async Task> SearchUsersAsync(string query, Guid currentId) { List all = new List(); @@ -42,6 +43,23 @@ public class FriendshipService : IFriendshipService throw new UnauthorizedAccessException($"When we try find friends by pattern {query} something wrong", ex); } } + + public async Task> GetPotentialFriendsAsync(Guid userId) + { + try + { + var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); + + return friendships + .Where(f => f.Status == FriendshipStatus.Pending) + .Select(f => f.RequesterId == userId ? f.Addressee : f.Requester) + .ToList(); + } + catch (NotFoundByKeyException ex) + { + throw new InvalidOperationException("Nothing was found for the specified id.", ex); + } + } public async Task> GetFriendsAsync(Guid userId) { diff --git a/Govor.Application/Services/Medias/MediaService.cs b/Govor.Application/Services/Medias/MediaService.cs index 226cacc..389027b 100644 --- a/Govor.Application/Services/Medias/MediaService.cs +++ b/Govor.Application/Services/Medias/MediaService.cs @@ -100,6 +100,19 @@ public class MediaService : IMediaService throw; } } + + public async Task HasMediaAsync(Guid mediaId) + { + return await _dbContext.MediaFiles.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == mediaId) is not null; + } + + public async Task HasMediaByUrlAsync(string url) + { + return await _dbContext.MediaFiles.AsNoTracking() + .FirstOrDefaultAsync(x => x.Url == url) is not null; + } + public async Task AttachToMessageAsync(Guid mediaId, Guid messageId) { var mediaFile = await _dbContext.MediaFiles diff --git a/Govor.Application/Services/Messages/MessageCommandService.cs b/Govor.Application/Services/Messages/MessageCommandService.cs index 40c1914..1f0a4e6 100644 --- a/Govor.Application/Services/Messages/MessageCommandService.cs +++ b/Govor.Application/Services/Messages/MessageCommandService.cs @@ -51,12 +51,22 @@ public class MessageCommandService : IMessageCommandService { if (!await _usersRepository.ExistsByIdAsync(sendParams.RecipientId)) { - _logger.LogWarning("Attempt to send message to non-existent user {RecipientId}", sendParams.RecipientId); - return new SendMessageResult(false, new KeyNotFoundException($"Recipient user {sendParams.RecipientId} not found."), default); + if (!_privateChats.Exist(sendParams.RecipientId)) + { + _logger.LogWarning("Attempt to send message to non-existent user {RecipientId}", sendParams.RecipientId); + return new SendMessageResult(false, new KeyNotFoundException($"Recipient user {sendParams.RecipientId} not found."), default); + } + else + { + recipientId = sendParams.RecipientId; + } + } + else + { + // Verify friendship for private messages + await _verifyFriendship.VerifyAsync(sendParams.FromUserId, sendParams.RecipientId); + recipientId = await GetPrivateChatsIdAsync(sendParams.FromUserId, sendParams.RecipientId); } - // Verify friendship for private messages - await _verifyFriendship.VerifyAsync(sendParams.FromUserId, sendParams.RecipientId); - recipientId = await GetPrivateChatsIdAsync(sendParams.FromUserId, sendParams.RecipientId); } else if (sendParams.RecipientType == RecipientType.Group) { diff --git a/Govor.Application/Services/Messages/MessagesLoader.cs b/Govor.Application/Services/Messages/MessagesLoader.cs index 872084b..ff27317 100644 --- a/Govor.Application/Services/Messages/MessagesLoader.cs +++ b/Govor.Application/Services/Messages/MessagesLoader.cs @@ -25,26 +25,24 @@ public class MessagesLoader : IMessagesLoader } public async Task> LoadMessagesInUserChat( - Guid userId, + Guid privateChatId, Guid currentUser, Guid? startMessageId, int before = 20, int after = 2) { - if (userId == Guid.Empty) - throw new ArgumentException("User id cannot be empty"); + if (privateChatId == Guid.Empty) + throw new ArgumentException("PrivateChatId id cannot be empty"); - if (!_privateChatsRepository.Exist(userId, currentUser)) + if (!_privateChatsRepository.Exist(privateChatId)) throw new InvalidOperationException("Private chat not found"); - - var chat = await _privateChatsRepository.GetByMembersAsync(userId, currentUser); - + var query = _dbContext.Messages .AsNoTracking() .Include(m => m.MediaAttachments) .ThenInclude(m => m.MediaFile) .Where(m => m.RecipientType == RecipientType.User && - m.RecipientId == chat.Id); + m.RecipientId == privateChatId); if (startMessageId is null) { diff --git a/Govor.Application/Services/SynchingService.cs b/Govor.Application/Services/SynchingService.cs new file mode 100644 index 0000000..63f00da --- /dev/null +++ b/Govor.Application/Services/SynchingService.cs @@ -0,0 +1,20 @@ +using Govor.Application.Interfaces; + +namespace Govor.Application.Services; + +public class SynchingService : ISynchingService +{ + public string NormalizeNewlines(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + string normalized = input.Replace("\r\n", "\n"); + + normalized = normalized.Replace('\r', '\n'); + + return normalized; + } +} \ No newline at end of file diff --git a/Govor.Application/Services/UserGroupsService.cs b/Govor.Application/Services/UserGroupsGetterService.cs similarity index 80% rename from Govor.Application/Services/UserGroupsService.cs rename to Govor.Application/Services/UserGroupsGetterService.cs index 04b04c0..a665667 100644 --- a/Govor.Application/Services/UserGroupsService.cs +++ b/Govor.Application/Services/UserGroupsGetterService.cs @@ -5,11 +5,11 @@ using Govor.Data.Repositories.Exceptions; namespace Govor.Application.Services; -public class UserGroupsService : IUserGroupsService +public class UserGroupsGetterService : IUserGroupsGetterService { private readonly IGroupsRepository _groupRep; - public UserGroupsService(IGroupsRepository groupsRepository) + public UserGroupsGetterService(IGroupsRepository groupsRepository) { _groupRep = groupsRepository; } diff --git a/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs b/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs index 2100472..b772486 100644 --- a/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs +++ b/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs @@ -5,25 +5,68 @@ namespace Govor.Application.Services.UserOnlineStatus; public class OnlineUserStore : IOnlineUserStore { - private readonly ConcurrentDictionary _onlineUsers = new(); + private readonly ConcurrentDictionary> _userConnections = new(); - public void SetOnlineUser(Guid userId) + public bool AddConnection(Guid userId, string connectionId) { - _onlineUsers[userId] = DateTime.UtcNow; + bool isFirstConnection = false; + + _userConnections.AddOrUpdate(userId, + _ => { + isFirstConnection = true; + return new HashSet { connectionId }; + }, + (_, connections) => { + lock (connections) + { + if (connections.Count == 0) isFirstConnection = true; + connections.Add(connectionId); + } + return connections; + }); + + return isFirstConnection; } - public void SetOfflineUser(Guid userId) + public bool RemoveConnection(Guid userId, string connectionId) { - _onlineUsers.TryRemove(userId, out _); + bool isLastConnection = false; + + if (_userConnections.TryGetValue(userId, out var connections)) + { + lock (connections) + { + connections.Remove(connectionId); + if (connections.Count == 0) + { + isLastConnection = true; + _userConnections.TryRemove(userId, out _); + } + } + } + + return isLastConnection; + } + + public IEnumerable GetConnections(Guid userId) + { + if (_userConnections.TryGetValue(userId, out var connections)) + { + lock (connections) + { + return connections.ToList(); + } + } + return Enumerable.Empty(); } public bool IsOnline(Guid userId) { - return _onlineUsers.ContainsKey(userId); + return _userConnections.TryGetValue(userId, out var connections) && connections.Count > 0; } public IReadOnlyCollection GetAllOnlineUsers() { - return _onlineUsers.Keys.ToList(); + return _userConnections.Keys.ToList(); } -} +} \ No newline at end of file diff --git a/Govor.Application/Services/UserOnlineStatus/UserNotificationScopeService.cs b/Govor.Application/Services/UserOnlineStatus/UserNotificationScopeService.cs index 5435f49..82c5cae 100644 --- a/Govor.Application/Services/UserOnlineStatus/UserNotificationScopeService.cs +++ b/Govor.Application/Services/UserOnlineStatus/UserNotificationScopeService.cs @@ -26,7 +26,7 @@ public class UserNotificationScopeService : IUserNotificationScopeService } catch (NotFoundByKeyException ex) { - _logger.LogError(ex, ex.Message); + _logger.LogWarning(ex, ex.Message); throw new InvalidOperationException("User not found"); } } diff --git a/Govor.Application/Services/UserPrivateChatsGetter.cs b/Govor.Application/Services/UserPrivateChatsGetter.cs new file mode 100644 index 0000000..96e1644 --- /dev/null +++ b/Govor.Application/Services/UserPrivateChatsGetter.cs @@ -0,0 +1,28 @@ +using Govor.Application.Interfaces; +using Govor.Core.Models; +using Govor.Core.Repositories.PrivateChats; +using Govor.Data.Repositories.Exceptions; + +namespace Govor.Application.Services; + +public class UserPrivateChatsGetter : IUserPrivateChatsGetterService +{ + private readonly IPrivateChatsRepository _groupRep; + + public UserPrivateChatsGetter(IPrivateChatsRepository groupsRepository) + { + _groupRep = groupsRepository; + } + + public async Task> GetUserChatsAsync(Guid userId) + { + try + { + return await _groupRep.GetAllOfUser(userId); + } + catch (NotFoundByKeyException ex) + { + return []; + } + } +} \ No newline at end of file diff --git a/Govor.Application/Services/UserSessions/UserSessionRefresher.cs b/Govor.Application/Services/UserSessions/UserSessionRefresher.cs index 10f359b..c737826 100644 --- a/Govor.Application/Services/UserSessions/UserSessionRefresher.cs +++ b/Govor.Application/Services/UserSessions/UserSessionRefresher.cs @@ -16,6 +16,7 @@ public class UserSessionRefresher : IUserSessionRefresher private readonly ILogger _logger; private readonly IUsersRepository _usersRepository; private readonly JwtRefreshOption _options; + private readonly IJwtTokenHasher _jwtTokenHasher; private readonly IJwtService _jwtService; public UserSessionRefresher( @@ -23,12 +24,14 @@ public class UserSessionRefresher : IUserSessionRefresher ILogger logger, IUsersRepository usersRepository, IOptions options, + IJwtTokenHasher jwtTokenHasher, IJwtService jwtService) { _sessionsRepository = sessionsRepository; _logger = logger; _usersRepository = usersRepository; _options = options.Value; + _jwtTokenHasher = jwtTokenHasher; _jwtService = jwtService; } @@ -36,7 +39,8 @@ public class UserSessionRefresher : IUserSessionRefresher { try { - var session = await _sessionsRepository.GetByHashedRefreshTokenAsync(refreshToken); + + var session = await _sessionsRepository.GetByHashedRefreshTokenAsync(_jwtTokenHasher.HashToken(refreshToken)); if (session.IsRevoked || session.ExpiresAt <= DateTime.UtcNow) throw new UnauthorizedAccessException("Refresh token is invalid or expired"); @@ -50,12 +54,14 @@ public class UserSessionRefresher : IUserSessionRefresher // New tokens var newAccessToken = await _jwtService.GenerateAccessTokenAsync(user, session.Id); var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); - + + var newRefreshTokenHash = _jwtTokenHasher.HashToken(newRefreshToken); + // Opening new session var newSession = new UserSession { UserId = user.Id, - RefreshTokenHash = newRefreshToken, + RefreshTokenHash = newRefreshTokenHash, DeviceInfo = session.DeviceInfo, CreatedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays) diff --git a/Govor.ConsoleClient.Tests/Govor.ConsoleClient.Tests.csproj b/Govor.ConsoleClient.Tests/Govor.ConsoleClient.Tests.csproj index e50bf85..8dc0b55 100644 --- a/Govor.ConsoleClient.Tests/Govor.ConsoleClient.Tests.csproj +++ b/Govor.ConsoleClient.Tests/Govor.ConsoleClient.Tests.csproj @@ -14,7 +14,10 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Govor.ConsoleClient/Commands/SendMessageCommand.cs b/Govor.ConsoleClient/Commands/SendMessageCommand.cs index 56d4528..0c59601 100644 --- a/Govor.ConsoleClient/Commands/SendMessageCommand.cs +++ b/Govor.ConsoleClient/Commands/SendMessageCommand.cs @@ -34,11 +34,11 @@ public class SendMessageCommand : IInteractiveCommand public string LongHelp() { - return "Отпарвка тестовых сообщений не существующему юзеру 2"; + return "Отпарвка тестовых сообщений существующему юзеру 2"; } public string ShortHelp() { - return "Отпарвка тестовых сообщений не существующему юзеру"; + return "Отпарвка тестовых сообщений существующему юзеру"; } } \ No newline at end of file diff --git a/Govor.Contracts/DTOs/AvatarUpdatePayload.cs b/Govor.Contracts/DTOs/AvatarUpdatePayload.cs new file mode 100644 index 0000000..75c2aa4 --- /dev/null +++ b/Govor.Contracts/DTOs/AvatarUpdatePayload.cs @@ -0,0 +1,7 @@ +namespace Govor.Contracts.DTOs; + +public class AvatarUpdatePayload +{ + public Guid UserId { get; set; } + public Guid IconId { get; set; } +} \ No newline at end of file diff --git a/Govor.Contracts/DTOs/DescriptionUpdatePayload.cs b/Govor.Contracts/DTOs/DescriptionUpdatePayload.cs new file mode 100644 index 0000000..2227277 --- /dev/null +++ b/Govor.Contracts/DTOs/DescriptionUpdatePayload.cs @@ -0,0 +1,7 @@ +namespace Govor.Contracts.DTOs; + +public class DescriptionUpdatePayload +{ + public Guid UserId { get; set; } + public string? Description { get; set; } +} \ No newline at end of file diff --git a/Govor.Contracts/DTOs/UserProfileDto.cs b/Govor.Contracts/DTOs/UserProfileDto.cs index 6d8acfd..85d3f38 100644 --- a/Govor.Contracts/DTOs/UserProfileDto.cs +++ b/Govor.Contracts/DTOs/UserProfileDto.cs @@ -6,4 +6,5 @@ public class UserProfileDto public string Username { get; set; } = string.Empty; public string? Description { get; set; } public Guid? IconId { get; set; } + public bool IsOnline { get; set; } } diff --git a/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs b/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs index 6632c60..0106c6e 100644 --- a/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs +++ b/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs @@ -3,15 +3,15 @@ using Govor.Core.Models.Messages; namespace Govor.Contracts.Responses.SignalR; -public record UserMessageResponse +public class UserMessageResponse { - public Guid MessageId { get; init; } - public Guid SenderId { get; init; } - public Guid RecipientId { get; init; } - public RecipientType RecipientType{get; init; } - public string EncryptedContent { get; init; } = string.Empty; - public Guid? ReplyToMessageId { get; init; } - public DateTime SentAt { get; init; } - public bool IsEdited { get; init; } = false; - public List MediaAttachments { get; init; } = new List(); + public Guid MessageId { get; set; } + public Guid SenderId { get; set; } + public Guid RecipientId { get; set; } + public RecipientType RecipientType{get; set; } + public string EncryptedContent { get; set; } = string.Empty; + public Guid? ReplyToMessageId { get; set; } + public DateTime SentAt { get; set; } + public bool IsEdited { get; set; } = false; + public List MediaAttachments { get; set; } = new List(); } \ No newline at end of file diff --git a/Govor.Core/Models/Users/User.cs b/Govor.Core/Models/Users/User.cs index 0669a69..cefaf19 100644 --- a/Govor.Core/Models/Users/User.cs +++ b/Govor.Core/Models/Users/User.cs @@ -19,17 +19,7 @@ public class User public override bool Equals(object? obj) { var user = obj as User; - - return Id == user.Id && - Username == user.Username && - Description == user.Description && - PasswordHash == user.PasswordHash && - IconId == user.IconId && - CreatedOn == user.CreatedOn && - WasOnline == user.WasOnline && - InviteId == user.InviteId && - Invite == user.Invite && - SentFriendRequests == user.SentFriendRequests && - ReceivedFriendRequests == user.ReceivedFriendRequests; + + return Id == user.Id; } } \ No newline at end of file diff --git a/Govor.Core/Repositories/Friendships/IFriendshipsExist.cs b/Govor.Core/Repositories/Friendships/IFriendshipsExist.cs index 3715a2d..5475414 100644 --- a/Govor.Core/Repositories/Friendships/IFriendshipsExist.cs +++ b/Govor.Core/Repositories/Friendships/IFriendshipsExist.cs @@ -3,4 +3,5 @@ namespace Govor.Core.Repositories.Friendships; public interface IFriendshipsExist { bool Exist(Guid requesterId, Guid addresseeId); + bool IsPossibilityOfCreatingFriendship(Guid requesterId, Guid addresseeId); } \ No newline at end of file diff --git a/Govor.Core/Repositories/Friendships/IFriendshipsReader.cs b/Govor.Core/Repositories/Friendships/IFriendshipsReader.cs index dc1943b..cee9027 100644 --- a/Govor.Core/Repositories/Friendships/IFriendshipsReader.cs +++ b/Govor.Core/Repositories/Friendships/IFriendshipsReader.cs @@ -6,5 +6,6 @@ public interface IFriendshipsReader { Task> GetAllAsync(); Task GetByIdAsync(Guid id); + Task GetFriendshipAsync(Guid fromUserId, Guid toUserId); Task> FindByUserIdAsync(Guid userId); } \ No newline at end of file diff --git a/Govor.Core/Repositories/PrivateChats/IPrivateChatsReader.cs b/Govor.Core/Repositories/PrivateChats/IPrivateChatsReader.cs index bcc2f83..0327dad 100644 --- a/Govor.Core/Repositories/PrivateChats/IPrivateChatsReader.cs +++ b/Govor.Core/Repositories/PrivateChats/IPrivateChatsReader.cs @@ -7,4 +7,5 @@ public interface IPrivateChatsReader Task> GetAllAsync(); Task GetByIdAsync(Guid id); Task GetByMembersAsync(Guid memberAId, Guid memberBId); + Task> GetAllOfUser(Guid userId); } \ No newline at end of file diff --git a/Govor.Data/Configurations/UserConfiguration.cs b/Govor.Data/Configurations/UserConfiguration.cs index 76edac4..98e4dd6 100644 --- a/Govor.Data/Configurations/UserConfiguration.cs +++ b/Govor.Data/Configurations/UserConfiguration.cs @@ -9,7 +9,11 @@ public class UserConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.HasKey(x => x.Id); - + + builder.HasIndex(u => u.Username).IsUnique(); + builder.HasIndex(u => u.CreatedOn); + builder.HasIndex(u => u.WasOnline); + builder.HasOne(u => u.Invite) .WithMany(i => i.Users) .HasForeignKey(u => u.InviteId); diff --git a/Govor.Data/Repositories/FriendshipsRepository.cs b/Govor.Data/Repositories/FriendshipsRepository.cs index 87e98f8..128ed14 100644 --- a/Govor.Data/Repositories/FriendshipsRepository.cs +++ b/Govor.Data/Repositories/FriendshipsRepository.cs @@ -40,6 +40,14 @@ public class FriendshipsRepository : IFriendshipsRepository ?? throw new NotFoundByKeyException(id, "Friendship with given id was not found"); } + public async Task GetFriendshipAsync(Guid fromUserId, Guid toUserId) + { + return await _context.Friendships + .FirstOrDefaultAsync(x => + (x.RequesterId == fromUserId && x.AddresseeId == toUserId) || + (x.RequesterId == toUserId && x.AddresseeId == fromUserId)); + } + public async Task> FindByUserIdAsync(Guid userId) { return await _context.Friendships @@ -120,4 +128,23 @@ public class FriendshipsRepository : IFriendshipsRepository return _context.Friendships.AsNoTracking().Any(x => (x.RequesterId == requesterId && x.AddresseeId == addresseeId) || x.RequesterId == addresseeId && x.AddresseeId == requesterId); } + + public bool IsPossibilityOfCreatingFriendship(Guid requesterId, Guid addresseeId) + { + // Ищем любую существующую связь между пользователями + var existingFriendship = _context.Friendships + .AsNoTracking() + .FirstOrDefault(x => + (x.RequesterId == requesterId && x.AddresseeId == addresseeId) || + (x.RequesterId == addresseeId && x.AddresseeId == requesterId)); + + // Если записи нет — создавать можно + if (existingFriendship == null) return true; + + // Если запись есть, создавать новую НЕЛЬЗЯ (нужно обновлять старую), + // за исключением случаев, когда статус "Rejected" или "Blocked" (смотря какая логика) + // Но обычно метод "IsPossibility" подразумевает именно "можно ли инициировать процесс" + + return existingFriendship.Status == FriendshipStatus.Rejected; + } } \ No newline at end of file diff --git a/Govor.Data/Repositories/PrivateChatsRepository.cs b/Govor.Data/Repositories/PrivateChatsRepository.cs index 38a3f58..e72de8a 100644 --- a/Govor.Data/Repositories/PrivateChatsRepository.cs +++ b/Govor.Data/Repositories/PrivateChatsRepository.cs @@ -42,6 +42,14 @@ public class PrivateChatsRepository : IPrivateChatsRepository ?? throw new NotFoundByKeyException<(Guid, Guid)>((memberAId, memberBId), "Private Chat with given members Id's does not exist"); } + public async Task> GetAllOfUser(Guid userId) + { + return await _context.PrivateChats + .AsNoTracking() + .Where(f => f.UserAId == userId || f.UserBId == userId) + .ToListOrThrowIfEmpty(new NotFoundByKeyException(userId, "Private Chat with given member Id's does not exist")); + } + public async Task AddAsync(PrivateChat chat) { try diff --git a/Govor.Data/Repositories/UserSessionsRepository.cs b/Govor.Data/Repositories/UserSessionsRepository.cs index 1355425..229c108 100644 --- a/Govor.Data/Repositories/UserSessionsRepository.cs +++ b/Govor.Data/Repositories/UserSessionsRepository.cs @@ -102,8 +102,6 @@ public class UserSessionsRepository : IUserSessionsRepository if (rowsAffected == 0) throw new UpdateException($"Not found user session by given id {userSession.Id}"); - - await _context.SaveChangesAsync(); } catch (Exception ex) { diff --git a/Govor.Data/Repositories/UsersRepository.cs b/Govor.Data/Repositories/UsersRepository.cs index 8cbe546..1a1c1a4 100644 --- a/Govor.Data/Repositories/UsersRepository.cs +++ b/Govor.Data/Repositories/UsersRepository.cs @@ -1,5 +1,6 @@ using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; +using Govor.Core.Models; using Govor.Core.Models.Users; using Govor.Core.Repositories.Users; using Govor.Data.Repositories.Exceptions; @@ -77,16 +78,13 @@ public class UsersRepository : IUsersRepository { return await _context.Users .AsNoTracking() - .Include(u => u.Invite) - .Include(u => u.ReceivedFriendRequests) - .Include(u => u.SentFriendRequests) .AsSplitQuery() .Where(u => u.Id != currentUserId && u.Username.ToLower().Contains(query.ToLower()) && !_context.Friendships.Any(f => - (f.RequesterId == currentUserId && f.AddresseeId == u.Id) || - (f.RequesterId == u.Id && f.AddresseeId == currentUserId))) - .Take(20) + ((f.RequesterId == currentUserId && f.AddresseeId == u.Id) || + (f.RequesterId == u.Id && f.AddresseeId == currentUserId)) && f.Status != FriendshipStatus.Rejected)) + .Take(10) .OrderBy(u => u.Username) .ToListOrThrowIfEmpty(new NotFoundByKeyException<(string, Guid)>((query, currentUserId), $"Users with given query for user {currentUserId} not found")); } @@ -162,8 +160,6 @@ public class UsersRepository : IUsersRepository if (rowsAffected == 0) throw new UpdateException($"Not found user by given id {user.Id}"); - - await _context.SaveChangesAsync(); } catch (Exception ex) {