Compare commits
39
Commits
8ce4bc714f
...
android_v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a426b63b7d | ||
|
|
4d4bc8edd1 | ||
|
|
40589dbb75 | ||
|
|
0b013def2e | ||
|
|
ccffc5a53c | ||
|
|
e8161d23d4 | ||
|
|
89e325556c | ||
|
|
df4feeeee5 | ||
|
|
00ed4b5959 | ||
|
|
a0b50b57b0 | ||
|
|
d4c41b333a | ||
|
|
9daa786cfb | ||
|
|
2d2e4b685e | ||
|
|
46c22300e9 | ||
|
|
d9a20e40b0 | ||
|
|
945134f029 | ||
|
|
f6400ce3ac | ||
|
|
d9462069e2 | ||
|
|
9e715fe3ab | ||
|
|
8c8ef55b58 | ||
|
|
70acad56fb | ||
|
|
629fddfca0 | ||
|
|
b68f68a1f2 | ||
|
|
754e9e8ad0 | ||
|
|
ed7521a563 | ||
|
|
bef30c2c86 | ||
|
|
8c00d1376d | ||
|
|
cea3f4d669 | ||
|
|
8409c51842 | ||
|
|
9560a9235f | ||
|
|
f17edfc0da | ||
|
|
c6bebec599 | ||
|
|
5a71e5bbfa | ||
|
|
5b0133d55e | ||
|
|
dcb733ac01 | ||
|
|
487cb1b12b | ||
|
|
58fdf1aca1 | ||
|
|
118f8b8971 | ||
|
|
8165b74e43 |
+10
@@ -87,3 +87,13 @@ postgres_data/
|
|||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
*.txt
|
*.txt
|
||||||
|
|
||||||
|
# Android / Kotlin Mobile
|
||||||
|
client-mobile/.gradle/
|
||||||
|
client-mobile/.idea/
|
||||||
|
client-mobile/.run/
|
||||||
|
client-mobile/build/
|
||||||
|
client-mobile/.cxx/
|
||||||
|
client-mobile/local.properties
|
||||||
|
client-mobile/*.iml
|
||||||
|
client-mobile/.kotlin/
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ using FluentAssertions;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Messaging.Domain;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Modules.Conversations.Application.Chats.Create;
|
using Knot.Modules.Conversations.Application.Chats.Create;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.UnitTests.Chats;
|
namespace Knot.Modules.Conversations.UnitTests.Chats;
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ using FluentAssertions;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Messaging.Domain;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Modules.Conversations.Application.Chats.GetChats;
|
using Knot.Modules.Conversations.Application.Chats.GetChats;
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
|
using Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Messaging.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,10 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\src\Modules\Chats\Knot.Modules.Conversations.csproj" />
|
<ProjectReference Include="..\..\..\src\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
|
||||||
<ProjectReference Include="..\..\..\src\Modules\Messaging\Knot.Modules.Messaging.csproj" />
|
<ProjectReference Include="..\..\..\src\Modules\Messaging\Knot.Modules.Messaging.csproj" />
|
||||||
<ProjectReference Include="..\..\..\src\Modules\Conversations\Knot.Modules.Conversations.csproj" />
|
<ProjectReference Include="..\..\..\src\Modules\Conversations\Knot.Modules.Conversations.csproj" />
|
||||||
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
|
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Conversations.Domain;
|
|
||||||
using Knot.Modules.Messaging.Domain;
|
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Send;
|
using Knot.Modules.Conversations.Application.Messages.Send;
|
||||||
using Knot.Modules.Settings.Application.Settings.Abstractions;
|
using Knot.Contracts.Settings.Application.Abstractions;
|
||||||
using Knot.Modules.Settings.Application.Settings.DTOs;
|
using Knot.Contracts.Settings.Application.DTOs;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.UnitTests.Messages;
|
namespace Knot.Modules.Conversations.UnitTests.Messages;
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ public class SendMessageCommandHandlerTests
|
|||||||
private readonly IChatsUnitOfWork _unitOfWork;
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
private readonly IMediator _mediator;
|
private readonly IMediator _mediator;
|
||||||
private readonly IMessagesSettings _messagesSettings;
|
private readonly IMessagesSettings _messagesSettings;
|
||||||
|
private readonly IIdempotencyStore _idempotencyStore;
|
||||||
|
private readonly ILogger<SendMessageCommandHandler> _logger;
|
||||||
private readonly SendMessageCommandHandler _handler;
|
private readonly SendMessageCommandHandler _handler;
|
||||||
|
|
||||||
public SendMessageCommandHandlerTests()
|
public SendMessageCommandHandlerTests()
|
||||||
@@ -33,11 +37,13 @@ public class SendMessageCommandHandlerTests
|
|||||||
_unitOfWork = Substitute.For<IChatsUnitOfWork>();
|
_unitOfWork = Substitute.For<IChatsUnitOfWork>();
|
||||||
_mediator = Substitute.For<IMediator>();
|
_mediator = Substitute.For<IMediator>();
|
||||||
_messagesSettings = Substitute.For<IMessagesSettings>();
|
_messagesSettings = Substitute.For<IMessagesSettings>();
|
||||||
|
_idempotencyStore = Substitute.For<IIdempotencyStore>();
|
||||||
|
_logger = Substitute.For<ILogger<SendMessageCommandHandler>>();
|
||||||
|
|
||||||
var config = new Knot.Modules.Settings.Application.Settings.DTOs.MessagesConfig();
|
var config = new MessagesConfig();
|
||||||
_messagesSettings.Current.Returns(config);
|
_messagesSettings.Current.Returns(config);
|
||||||
|
|
||||||
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings);
|
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings, _idempotencyStore, _logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ public interface IJwtTokenProvider
|
|||||||
{
|
{
|
||||||
string GenerateAccessToken(Guid userId, string username);
|
string GenerateAccessToken(Guid userId, string username);
|
||||||
string GenerateRefreshToken();
|
string GenerateRefreshToken();
|
||||||
|
DateTime GetRefreshTokenExpiry();
|
||||||
string Generate(Guid userId, string username, string displayName, string? avatar);
|
string Generate(Guid userId, string username, string displayName, string? avatar);
|
||||||
string Generate(Domain.UserContract user);
|
string Generate(Domain.UserContract user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ public static class AuthErrors
|
|||||||
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
|
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
|
||||||
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
|
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
|
||||||
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
|
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
|
||||||
|
public static Error IdentityRegistrationFailed => new("Auth.RegistrationFailed", "Failed to register user");
|
||||||
|
public static Error RefreshTokenExpired => new("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again.");
|
||||||
public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
|
public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,12 @@ public class UserContract
|
|||||||
public bool IsExternal { get; set; }
|
public bool IsExternal { get; set; }
|
||||||
public string? Domain { get; set; }
|
public string? Domain { get; set; }
|
||||||
public DateTime? LastSeen { get; set; }
|
public DateTime? LastSeen { get; set; }
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
public DateTime? RefreshTokenExpiry { get; set; }
|
||||||
|
|
||||||
|
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
|
||||||
|
{
|
||||||
|
RefreshToken = refreshToken;
|
||||||
|
RefreshTokenExpiry = expiry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Хранилище для обеспечения идемпотентности операций.
|
||||||
|
/// Если ключ уже существует — возвращает сохранённый результат без повторного выполнения.
|
||||||
|
/// </summary>
|
||||||
|
public interface IIdempotencyStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Возвращает сохранённый результат по ключу или выполняет factory, сохраняет и возвращает результат.
|
||||||
|
/// </summary>
|
||||||
|
Task<T> GetOrCreateAsync<T>(
|
||||||
|
string key,
|
||||||
|
Func<CancellationToken, Task<T>> factory,
|
||||||
|
TimeSpan? expiration = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -32,11 +32,11 @@ using Knot.Modules.TelegramImport.Presentation.Endpoints;
|
|||||||
using Knot.Modules.WebRtc;
|
using Knot.Modules.WebRtc;
|
||||||
using Knot.Modules.WebRtc.Presentation.Endpoints;
|
using Knot.Modules.WebRtc.Presentation.Endpoints;
|
||||||
using Knot.Shared.Infrastructure;
|
using Knot.Shared.Infrastructure;
|
||||||
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"Secret": "knot_super_secret_key_1234567890_knot",
|
"Secret": "knot_super_secret_key_1234567890_knot",
|
||||||
"Issuer": "Knot",
|
"Issuer": "Knot",
|
||||||
"Audience": "KnotUsers",
|
"Audience": "KnotUsers",
|
||||||
"ExpiryInMinutes": 1440
|
"ExpiryInMinutes": 1440,
|
||||||
|
"RefreshExpiryInDays": 30
|
||||||
},
|
},
|
||||||
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
|
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
|
||||||
}
|
}
|
||||||
@@ -5,5 +5,8 @@ namespace Knot.Modules.Auth.Application.Abstractions;
|
|||||||
public interface IJwtTokenProvider
|
public interface IJwtTokenProvider
|
||||||
{
|
{
|
||||||
string Generate(User user);
|
string Generate(User user);
|
||||||
|
string Generate(Guid userId, string username, string displayName, string? avatar);
|
||||||
|
string GenerateRefreshToken();
|
||||||
|
DateTime GetRefreshTokenExpiry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
@@ -9,10 +11,12 @@ public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
|
|||||||
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
|
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
|
||||||
{
|
{
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IJwtTokenProvider _tokenProvider;
|
||||||
|
|
||||||
public GetMeQueryHandler(IUserRepository userRepository)
|
public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
|
||||||
{
|
{
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
|
_tokenProvider = tokenProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
|
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
|
||||||
@@ -23,9 +27,18 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
|
|||||||
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
|
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if access token needs to be refreshed (less than 1 hour remaining)
|
||||||
|
string? newAccessToken = null;
|
||||||
|
|
||||||
|
// We can't directly check the current token's expiry here, but we can
|
||||||
|
// always issue a new token if the user is authenticated
|
||||||
|
// For now, let's issue a new token on every request (simplified approach)
|
||||||
|
// A better approach would be to parse the incoming token and check expiry
|
||||||
|
newAccessToken = _tokenProvider.Generate(user);
|
||||||
|
|
||||||
var response = new AuthResponseDto
|
var response = new AuthResponseDto
|
||||||
{
|
{
|
||||||
AccessToken = string.Empty,
|
AccessToken = newAccessToken,
|
||||||
RefreshToken = string.Empty,
|
RefreshToken = string.Empty,
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
Username = user.Username,
|
Username = user.Username,
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ using Knot.Shared.Kernel;
|
|||||||
|
|
||||||
namespace Knot.Modules.Auth.Application.Users.Login;
|
namespace Knot.Modules.Auth.Application.Users.Login;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ������� ��� ����� ������������. ���������� AuthResponseDto.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
|
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
|
||||||
|
|
||||||
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
|
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
|
||||||
@@ -31,14 +28,21 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
|
|||||||
}
|
}
|
||||||
|
|
||||||
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
||||||
|
string refreshToken = _tokenProvider.GenerateRefreshToken();
|
||||||
|
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||||
|
|
||||||
|
// Save refresh token to database
|
||||||
|
user.SetRefreshToken(refreshToken, refreshExpiry);
|
||||||
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||||
|
|
||||||
return Result.Success(new AuthResponseDto
|
return Result.Success(new AuthResponseDto
|
||||||
{
|
{
|
||||||
AccessToken = token,
|
AccessToken = token,
|
||||||
RefreshToken = string.Empty,
|
RefreshToken = refreshToken,
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
Username = user.Username,
|
Username = user.Username,
|
||||||
DisplayName = user.DisplayName
|
DisplayName = user.DisplayName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||||
|
|
||||||
|
public record RefreshTokenCommand(string RefreshToken) : ICommand<AuthResponseDto>;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||||
|
using Knot.Contracts.Auth.Domain;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||||
|
|
||||||
|
internal sealed class RefreshTokenCommandHandler : ICommandHandler<RefreshTokenCommand, AuthResponseDto>
|
||||||
|
{
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IJwtTokenProvider _tokenProvider;
|
||||||
|
|
||||||
|
public RefreshTokenCommandHandler(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IJwtTokenProvider tokenProvider)
|
||||||
|
{
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_tokenProvider = tokenProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<AuthResponseDto>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||||
|
{
|
||||||
|
return Result.Failure<AuthResponseDto>(
|
||||||
|
new Error("Auth.InvalidRefreshToken", "Refresh token is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userRepository.GetByRefreshTokenAsync(request.RefreshToken, cancellationToken);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return Result.Failure<AuthResponseDto>(
|
||||||
|
new Error("Auth.InvalidRefreshToken", "Invalid or expired refresh token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if refresh token has expired
|
||||||
|
if (user.RefreshTokenExpiry.HasValue && user.RefreshTokenExpiry.Value < DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
// Clear expired refresh token
|
||||||
|
user.SetRefreshToken(null, null);
|
||||||
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||||
|
|
||||||
|
return Result.Failure<AuthResponseDto>(
|
||||||
|
new Error("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var newAccessToken = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
||||||
|
|
||||||
|
var newRefreshToken = _tokenProvider.GenerateRefreshToken();
|
||||||
|
var newRefreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||||
|
user.SetRefreshToken(newRefreshToken, newRefreshExpiry);
|
||||||
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success(new AuthResponseDto
|
||||||
|
{
|
||||||
|
AccessToken = newAccessToken,
|
||||||
|
RefreshToken = newRefreshToken,
|
||||||
|
UserId = user.Id,
|
||||||
|
Username = user.Username,
|
||||||
|
DisplayName = user.DisplayName,
|
||||||
|
Avatar = user.Avatar
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using BCrypt.Net;
|
using BCrypt.Net;
|
||||||
using BCrypt.Net;
|
|
||||||
using Knot.Contracts.Auth.Application.Abstractions;
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||||
using Knot.Contracts.Settings.Application.Abstractions;
|
using Knot.Contracts.Settings.Application.Abstractions;
|
||||||
@@ -9,9 +8,6 @@ using Knot.Shared.Kernel;
|
|||||||
|
|
||||||
namespace Knot.Modules.Auth.Application.Users.Register;
|
namespace Knot.Modules.Auth.Application.Users.Register;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ������� ��� ����������� ������ ������������.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record RegisterUserCommand(
|
public sealed record RegisterUserCommand(
|
||||||
string Username,
|
string Username,
|
||||||
string Password,
|
string Password,
|
||||||
@@ -19,9 +15,6 @@ public sealed record RegisterUserCommand(
|
|||||||
string? Email,
|
string? Email,
|
||||||
string? Bio) : ICommand<AuthResponseDto>;
|
string? Bio) : ICommand<AuthResponseDto>;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ���������� ������� �����������.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
|
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
|
||||||
{
|
{
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
@@ -48,16 +41,13 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
|||||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
|
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. �������� ������������ username
|
|
||||||
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
|
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
|
||||||
{
|
{
|
||||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
|
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. ����������� ������
|
|
||||||
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
|
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
|
||||||
|
|
||||||
// 3. �������� ��������
|
|
||||||
var user = User.Create(
|
var user = User.Create(
|
||||||
request.Username,
|
request.Username,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
@@ -65,21 +55,33 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
|||||||
request.Email,
|
request.Email,
|
||||||
request.Bio);
|
request.Bio);
|
||||||
|
|
||||||
// 4. ���������� - ���������� ����� � Domain User
|
var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
|
||||||
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository;
|
repoImpl?.Add(user);
|
||||||
repoWithDomainUserAdd?.Add(user);
|
|
||||||
|
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
// Get the saved user as contract
|
||||||
|
var userContract = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
|
||||||
|
if (userContract == null)
|
||||||
|
{
|
||||||
|
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
string token = _tokenProvider.Generate(userContract.Id, userContract.Username, userContract.DisplayName, userContract.Avatar);
|
||||||
|
string refreshToken = _tokenProvider.GenerateRefreshToken();
|
||||||
|
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||||
|
|
||||||
|
userContract.SetRefreshToken(refreshToken, refreshExpiry);
|
||||||
|
await _userRepository.UpdateAsync(userContract, cancellationToken);
|
||||||
|
|
||||||
return Result.Success(new AuthResponseDto
|
return Result.Success(new AuthResponseDto
|
||||||
{
|
{
|
||||||
AccessToken = token,
|
AccessToken = token,
|
||||||
RefreshToken = string.Empty,
|
RefreshToken = refreshToken,
|
||||||
UserId = user.Id,
|
UserId = userContract.Id,
|
||||||
Username = user.Username,
|
Username = userContract.Username,
|
||||||
DisplayName = user.DisplayName
|
DisplayName = userContract.DisplayName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
public bool IsBanned { get; private set; }
|
public bool IsBanned { get; private set; }
|
||||||
public string? PhoneNumber { get; private set; }
|
public string? PhoneNumber { get; private set; }
|
||||||
public string? RefreshToken { get; private set; }
|
public string? RefreshToken { get; private set; }
|
||||||
|
public DateTime? RefreshTokenExpiry { get; private set; }
|
||||||
public DateTime? BannedUntil { get; private set; }
|
public DateTime? BannedUntil { get; private set; }
|
||||||
|
|
||||||
public void Ban() {
|
public void Ban() {
|
||||||
@@ -48,9 +49,10 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
PhoneNumber = phoneNumber;
|
PhoneNumber = phoneNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetRefreshToken(string? refreshToken)
|
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
|
||||||
{
|
{
|
||||||
RefreshToken = refreshToken;
|
RefreshToken = refreshToken;
|
||||||
|
RefreshTokenExpiry = expiry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetBannedUntil(DateTime? bannedUntil)
|
public void SetBannedUntil(DateTime? bannedUntil)
|
||||||
@@ -79,6 +81,8 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
BannedUntil = contract.BannedUntil;
|
BannedUntil = contract.BannedUntil;
|
||||||
SetOnline(contract.IsOnline, contract.LastSeen);
|
SetOnline(contract.IsOnline, contract.LastSeen);
|
||||||
UserDomain = contract.Domain;
|
UserDomain = contract.Domain;
|
||||||
|
RefreshToken = contract.RefreshToken;
|
||||||
|
RefreshTokenExpiry = contract.RefreshTokenExpiry;
|
||||||
}
|
}
|
||||||
|
|
||||||
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
|
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
|
||||||
@@ -181,7 +185,9 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
IsOnline = IsOnline,
|
IsOnline = IsOnline,
|
||||||
IsExternal = IsExternal,
|
IsExternal = IsExternal,
|
||||||
Domain = _domain,
|
Domain = _domain,
|
||||||
LastSeen = LastSeen
|
LastSeen = LastSeen,
|
||||||
|
RefreshToken = RefreshToken,
|
||||||
|
RefreshTokenExpiry = RefreshTokenExpiry
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ internal sealed class JwtTokenProvider : IJwtTokenProvider
|
|||||||
return Convert.ToBase64String(randomBytes);
|
return Convert.ToBase64String(randomBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DateTime GetRefreshTokenExpiry()
|
||||||
|
{
|
||||||
|
var expiryInDays = int.Parse(_configuration["Jwt:RefreshExpiryInDays"] ?? "30");
|
||||||
|
return DateTime.UtcNow.AddDays(expiryInDays);
|
||||||
|
}
|
||||||
|
|
||||||
public string Generate(Guid userId, string username, string displayName, string? avatar)
|
public string Generate(Guid userId, string username, string displayName, string? avatar)
|
||||||
{
|
{
|
||||||
var claims = new Claim[]
|
var claims = new Claim[]
|
||||||
|
|||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
using System;
|
||||||
|
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Knot.Modules.Auth.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(AuthDbContext))]
|
||||||
|
[Migration("20270419220000_AddRefreshTokenExpiry")]
|
||||||
|
partial class AddRefreshTokenExpiry
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.0-rc.1.25451.105")
|
||||||
|
.HasAnnotation("Relational:DefaultSchema", "identity");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Avatar")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("BannedUntil")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Birthday")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Domain")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("HideStatus")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HideStoryViews")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsExternal")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsOnline")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastSeen")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("RefreshToken")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RefreshTokenExpiry")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users", "identity");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Knot.Modules.Auth.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRefreshTokenExpiry : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "RefreshTokenExpiry",
|
||||||
|
schema: "identity",
|
||||||
|
table: "Users",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RefreshTokenExpiry",
|
||||||
|
schema: "identity",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
using Knot.Shared.Kernel;
|
|
||||||
using Knot.Modules.Auth.Application.Users.Login;
|
|
||||||
using Knot.Modules.Auth.Application.Users.Register;
|
|
||||||
using Knot.Modules.Auth.Application.Users.GetMe;
|
using Knot.Modules.Auth.Application.Users.GetMe;
|
||||||
|
using Knot.Modules.Auth.Application.Users.Login;
|
||||||
|
using Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||||
|
using Knot.Modules.Auth.Application.Users.Register;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
namespace Knot.Modules.Auth.Presentation.Endpoints;
|
namespace Knot.Modules.Auth.Presentation.Endpoints;
|
||||||
|
|
||||||
@@ -28,6 +29,12 @@ public static class AuthEndpoints
|
|||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapPost("refresh", async ([FromBody] RefreshTokenCommand command, ISender sender, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var result = await sender.Send(command, ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
|
||||||
|
});
|
||||||
|
|
||||||
group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
|
group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
|
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ public record MessageDetailDto(
|
|||||||
bool? PollIsMultipleChoice = null,
|
bool? PollIsMultipleChoice = null,
|
||||||
bool? PollIsAnonymous = null,
|
bool? PollIsAnonymous = null,
|
||||||
bool? PollIsClosed = null,
|
bool? PollIsClosed = null,
|
||||||
List<Guid>? UserVotedOptionIds = null
|
List<Guid>? UserVotedOptionIds = null,
|
||||||
|
bool IsDeletedForUser = false
|
||||||
);
|
);
|
||||||
|
|
||||||
public record ReplyToMessageDto(
|
public record ReplyToMessageDto(
|
||||||
|
|||||||
+12
-17
@@ -42,10 +42,16 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
|||||||
|
|
||||||
if (request.DeleteForAll)
|
if (request.DeleteForAll)
|
||||||
{
|
{
|
||||||
|
// Only message sender can delete for everyone
|
||||||
if (message.SenderId == request.UserId)
|
if (message.SenderId == request.UserId)
|
||||||
{
|
{
|
||||||
message.Delete();
|
message.Delete();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// If not the sender, just delete for current user
|
||||||
|
message.DeleteForUser(request.UserId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -55,24 +61,13 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
|||||||
await _messageRepository.UpdateAsync(message, cancellationToken);
|
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.DeleteForAll)
|
// Notify all clients in the chat about the deletion
|
||||||
|
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
||||||
{
|
{
|
||||||
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
chatId = request.ChatId,
|
||||||
{
|
messageIds = request.MessageIds,
|
||||||
chatId = request.ChatId,
|
deleteForAll = request.DeleteForAll
|
||||||
messageIds = request.MessageIds,
|
});
|
||||||
deleteForAll = true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
|
|
||||||
{
|
|
||||||
chatId = request.ChatId,
|
|
||||||
messageIds = request.MessageIds,
|
|
||||||
deleteForAll = false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return global::Knot.Shared.Kernel.Result.Success();
|
return global::Knot.Shared.Kernel.Result.Success();
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-9
@@ -3,11 +3,11 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
using Knot.Contracts.Messaging.Domain;
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
@@ -115,6 +115,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
senders.TryGetValue(message.SenderId, out var sender);
|
senders.TryGetValue(message.SenderId, out var sender);
|
||||||
reactionsByMessage.TryGetValue(message.Id, out var reactions);
|
reactionsByMessage.TryGetValue(message.Id, out var reactions);
|
||||||
|
|
||||||
|
|
||||||
Message? replyMsg = null;
|
Message? replyMsg = null;
|
||||||
if (message.ReplyToId.HasValue)
|
if (message.ReplyToId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -141,7 +142,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
|
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
|
||||||
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
|
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
|
||||||
) : null,
|
) : null,
|
||||||
message is TextMessage tm ? tm.Quote : null,
|
(message as TextMessage)?.Quote,
|
||||||
message.IsEdited,
|
message.IsEdited,
|
||||||
message.IsDeleted,
|
message.IsDeleted,
|
||||||
message.CreatedAt,
|
message.CreatedAt,
|
||||||
@@ -154,19 +155,22 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
|
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
|
||||||
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||||
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
|
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
|
||||||
reactions?.Select(r => {
|
reactions?.Select(r =>
|
||||||
|
{
|
||||||
senders.TryGetValue(r.UserId, out var ru);
|
senders.TryGetValue(r.UserId, out var ru);
|
||||||
return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null);
|
return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null);
|
||||||
}).ToList() ?? new List<MessageReactionDto>(),
|
}).ToList() ?? new List<MessageReactionDto>(),
|
||||||
(message as CallMessage)?.CallType,
|
(message as CallMessage)?.CallType,
|
||||||
(message as CallMessage)?.CallStatus,
|
(message as CallMessage)?.CallStatus,
|
||||||
(message as CallMessage)?.Duration,
|
(message as CallMessage)?.Duration,
|
||||||
(message as PollMessage)?.Options.Select(o => {
|
(message as PollMessage)?.Options.Select(o =>
|
||||||
|
{
|
||||||
var pm = (PollMessage)message;
|
var pm = (PollMessage)message;
|
||||||
var voters = pm.IsAnonymous == false
|
var voters = pm.IsAnonymous == false
|
||||||
? pm.Votes
|
? pm.Votes
|
||||||
.Where(v => v.OptionId == o.Id)
|
.Where(v => v.OptionId == o.Id)
|
||||||
.Select(v => {
|
.Select(v =>
|
||||||
|
{
|
||||||
senders.TryGetValue(v.UserId, out var vu);
|
senders.TryGetValue(v.UserId, out var vu);
|
||||||
return vu != null
|
return vu != null
|
||||||
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
|
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
|
||||||
@@ -174,12 +178,13 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
})
|
})
|
||||||
.ToList()
|
.ToList()
|
||||||
: null;
|
: null;
|
||||||
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
|
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
(message as PollMessage)?.IsMultipleChoice,
|
(message as PollMessage)?.IsMultipleChoice,
|
||||||
(message as PollMessage)?.IsAnonymous,
|
(message as PollMessage)?.IsAnonymous,
|
||||||
(message as PollMessage)?.IsClosed,
|
(message as PollMessage)?.IsClosed,
|
||||||
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList()
|
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList(),
|
||||||
|
message.IsDeletedForUser(request.UserId)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
-40
@@ -1,9 +1,10 @@
|
|||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
using Knot.Contracts.Messaging.Domain;
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Contracts.Settings.Application.Abstractions;
|
using Knot.Contracts.Settings.Application.Abstractions;
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
||||||
|
|
||||||
@@ -30,7 +31,8 @@ public sealed record SendMessageCommand(
|
|||||||
DateTime? PollExpiresAt = null,
|
DateTime? PollExpiresAt = null,
|
||||||
string? CallType = null,
|
string? CallType = null,
|
||||||
string? CallStatus = null,
|
string? CallStatus = null,
|
||||||
int? Duration = null) : ICommand<Guid>;
|
int? Duration = null,
|
||||||
|
string? IdempotencyKey = null) : ICommand<Guid>;
|
||||||
|
|
||||||
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
|
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
|
||||||
{
|
{
|
||||||
@@ -39,64 +41,74 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
private readonly IChatsUnitOfWork _unitOfWork;
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
private readonly MediatR.IMediator _mediator;
|
private readonly MediatR.IMediator _mediator;
|
||||||
private readonly IMessagesSettings _messagesSettings;
|
private readonly IMessagesSettings _messagesSettings;
|
||||||
|
private readonly IIdempotencyStore _idempotencyStore;
|
||||||
|
private readonly ILogger<SendMessageCommandHandler> _logger;
|
||||||
|
|
||||||
public SendMessageCommandHandler(
|
public SendMessageCommandHandler(
|
||||||
IChatRepository chatRepository,
|
IChatRepository chatRepository,
|
||||||
IMessageRepository messageRepository,
|
IMessageRepository messageRepository,
|
||||||
IChatsUnitOfWork unitOfWork,
|
IChatsUnitOfWork unitOfWork,
|
||||||
MediatR.IMediator mediator,
|
MediatR.IMediator mediator,
|
||||||
IMessagesSettings messagesSettings)
|
IMessagesSettings messagesSettings,
|
||||||
|
IIdempotencyStore idempotencyStore,
|
||||||
|
ILogger<SendMessageCommandHandler> logger)
|
||||||
{
|
{
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
_messageRepository = messageRepository;
|
_messageRepository = messageRepository;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mediator = mediator;
|
_mediator = mediator;
|
||||||
_messagesSettings = messagesSettings;
|
_messagesSettings = messagesSettings;
|
||||||
|
_idempotencyStore = idempotencyStore;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
|
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// 1. ��������� ������������� ����
|
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||||
|
{
|
||||||
|
var key = $"send_msg:{request.ChatId}:{request.IdempotencyKey}";
|
||||||
|
return await _idempotencyStore.GetOrCreateAsync(
|
||||||
|
key,
|
||||||
|
factory: ct => ExecuteAsync(request, ct),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ExecuteAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Result<Guid>> ExecuteAsync(SendMessageCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. Проверка существования чата
|
||||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||||
if (chat is null)
|
if (chat is null)
|
||||||
{
|
{
|
||||||
return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
|
return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. ���������, �������� �� ����������� ����������
|
// 2. Проверка, является ли отправитель участником чата
|
||||||
if (!chat.Members.Any(m => m.UserId == request.SenderId))
|
if (!chat.Members.Any(m => m.UserId == request.SenderId))
|
||||||
{
|
{
|
||||||
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
|
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. ������� ���������
|
// 3. Создание сообщения
|
||||||
Message message;
|
Message message;
|
||||||
if (request.Type == "story_reply" || request.Type == "story_reaction")
|
if (request.Type == "story_reply" || request.Type == "story_reaction")
|
||||||
{
|
{
|
||||||
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
|
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
|
||||||
|
|
||||||
|
|
||||||
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
|
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
|
||||||
message = new StoryMessage(
|
message = new StoryMessage(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
|
|
||||||
request.ChatId,
|
request.ChatId,
|
||||||
|
|
||||||
request.SenderId,
|
request.SenderId,
|
||||||
|
|
||||||
request.StoryId ?? Guid.Empty,
|
request.StoryId ?? Guid.Empty,
|
||||||
|
|
||||||
request.StoryMediaUrl ?? string.Empty,
|
request.StoryMediaUrl ?? string.Empty,
|
||||||
request.StoryMediaType,
|
request.StoryMediaType,
|
||||||
|
|
||||||
request.Content,
|
request.Content,
|
||||||
|
|
||||||
request.ReplyToId,
|
request.ReplyToId,
|
||||||
|
|
||||||
request.ForwardedFromId,
|
request.ForwardedFromId,
|
||||||
|
|
||||||
DateTime.UtcNow,
|
DateTime.UtcNow,
|
||||||
|
|
||||||
false);
|
false);
|
||||||
}
|
}
|
||||||
else if (request.Attachments != null && request.Attachments.Any())
|
else if (request.Attachments != null && request.Attachments.Any())
|
||||||
@@ -106,26 +118,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
var firstAtt = request.Attachments.First();
|
var firstAtt = request.Attachments.First();
|
||||||
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
|
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
|
||||||
|
|
||||||
|
|
||||||
message = new MediaMessage(
|
message = new MediaMessage(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
|
|
||||||
request.ChatId,
|
request.ChatId,
|
||||||
|
|
||||||
request.SenderId,
|
request.SenderId,
|
||||||
parsedType,
|
parsedType,
|
||||||
|
|
||||||
request.Content,
|
request.Content,
|
||||||
|
|
||||||
request.ReplyToId,
|
request.ReplyToId,
|
||||||
|
|
||||||
request.ForwardedFromId,
|
request.ForwardedFromId,
|
||||||
|
|
||||||
DateTime.UtcNow,
|
DateTime.UtcNow,
|
||||||
|
|
||||||
false);
|
false);
|
||||||
|
|
||||||
|
|
||||||
foreach (var att in request.Attachments)
|
foreach (var att in request.Attachments)
|
||||||
{
|
{
|
||||||
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
|
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
|
||||||
@@ -167,25 +170,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
{
|
{
|
||||||
message = new TextMessage(
|
message = new TextMessage(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
|
|
||||||
request.ChatId,
|
request.ChatId,
|
||||||
|
|
||||||
request.SenderId,
|
request.SenderId,
|
||||||
|
|
||||||
request.Content ?? string.Empty,
|
request.Content ?? string.Empty,
|
||||||
|
|
||||||
request.ReplyToId,
|
request.ReplyToId,
|
||||||
|
|
||||||
request.Quote,
|
request.Quote,
|
||||||
|
|
||||||
request.ForwardedFromId,
|
request.ForwardedFromId,
|
||||||
|
|
||||||
DateTime.UtcNow,
|
DateTime.UtcNow,
|
||||||
|
|
||||||
false);
|
false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. ������������������ ��������� High-Water Mark
|
// 4. Обновление High-Water Mark
|
||||||
chat.IncrementSequenceId();
|
chat.IncrementSequenceId();
|
||||||
message.SetSequenceId(chat.LastMessageSequenceId);
|
message.SetSequenceId(chat.LastMessageSequenceId);
|
||||||
|
|
||||||
@@ -193,19 +188,15 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
||||||
senderMember.UpdateDeliveredCursor(message.Id);
|
senderMember.UpdateDeliveredCursor(message.Id);
|
||||||
|
|
||||||
// 5. ���������
|
// 5. Сохранение
|
||||||
_messageRepository.Add(message);
|
_messageRepository.Add(message);
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
await _mediator.Publish(new MessageSentDomainEvent(
|
await _mediator.Publish(new MessageSentDomainEvent(
|
||||||
message.Id,
|
message.Id,
|
||||||
|
|
||||||
message.ChatId,
|
message.ChatId,
|
||||||
|
|
||||||
message.SenderId,
|
message.SenderId,
|
||||||
|
|
||||||
message.Content),
|
message.Content),
|
||||||
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return Result.Success(message.Id);
|
return Result.Success(message.Id);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Modules.Conversations.Infrastructure.Idempotency;
|
||||||
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||||
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
||||||
using Knot.Modules.Conversations.Infrastructure.Services;
|
using Knot.Modules.Conversations.Infrastructure.Services;
|
||||||
@@ -8,7 +10,6 @@ using Knot.Shared.Kernel;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations;
|
namespace Knot.Modules.Conversations;
|
||||||
|
|
||||||
@@ -43,6 +44,9 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>();
|
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>();
|
||||||
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>();
|
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>();
|
||||||
|
|
||||||
|
services.AddMemoryCache();
|
||||||
|
services.AddSingleton<Knot.Contracts.Conversations.Application.Abstractions.IIdempotencyStore, MemoryCacheIdempotencyStore>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Infrastructure.Idempotency;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Реализация хранилища идемпотентности на основе IMemoryCache.
|
||||||
|
/// Использует семафор для предотвращения race condition при одновременных запросах с одинаковым ключом.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemoryCacheIdempotencyStore : IIdempotencyStore
|
||||||
|
{
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||||
|
|
||||||
|
public MemoryCacheIdempotencyStore(IMemoryCache cache)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T> GetOrCreateAsync<T>(
|
||||||
|
string key,
|
||||||
|
Func<CancellationToken, Task<T>> factory,
|
||||||
|
TimeSpan? expiration = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_cache.TryGetValue(key, out T? cachedValue) && cachedValue is not null)
|
||||||
|
{
|
||||||
|
return cachedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _semaphore.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Double-check после получения блокировки
|
||||||
|
if (_cache.TryGetValue(key, out cachedValue) && cachedValue is not null)
|
||||||
|
{
|
||||||
|
return cachedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = await factory(cancellationToken);
|
||||||
|
|
||||||
|
var options = new MemoryCacheEntryOptions()
|
||||||
|
.SetAbsoluteExpiration(expiration ?? TimeSpan.FromHours(24))
|
||||||
|
.SetPriority(CacheItemPriority.Normal);
|
||||||
|
|
||||||
|
_cache.Set(key, value, options);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_semaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||||
|
<InternalsVisibleTo Include="Knot.Modules.Conversations.UnitTests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -55,11 +55,14 @@ public static class MessagesEndpoints
|
|||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
group.MapPost("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, HttpRequest httpRequest, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var attachments = request.Attachments?.Select(a =>
|
var attachments = request.Attachments?.Select(a =>
|
||||||
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
|
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
|
||||||
|
|
||||||
|
// Получаем idempotency ключ из заголовка
|
||||||
|
httpRequest.Headers.TryGetValue("X-Idempotency-Key", out var idempotencyKey);
|
||||||
|
|
||||||
var command = new SendMessageCommand(
|
var command = new SendMessageCommand(
|
||||||
chatId,
|
chatId,
|
||||||
userContext.UserId,
|
userContext.UserId,
|
||||||
@@ -68,7 +71,8 @@ public static class MessagesEndpoints
|
|||||||
attachments,
|
attachments,
|
||||||
request.ReplyToId,
|
request.ReplyToId,
|
||||||
request.Quote,
|
request.Quote,
|
||||||
request.ForwardedFromId);
|
request.ForwardedFromId,
|
||||||
|
IdempotencyKey: idempotencyKey.ToString());
|
||||||
|
|
||||||
var result = await sender.Send(command, ct);
|
var result = await sender.Send(command, ct);
|
||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
migrate_working_dir/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.packages
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
#Tue Apr 14 00:27:15 MSK 2026
|
|
||||||
gradle.version=8.5
|
|
||||||
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
#Tue Apr 14 00:13:58 MSK 2026
|
|
||||||
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||||
|
channel: stable
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||||
|
base_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||||
|
- platform: android
|
||||||
|
create_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||||
|
base_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Инструкция по запуску проекта
|
||||||
|
|
||||||
|
## Предварительные требования
|
||||||
|
|
||||||
|
1. **Flutter SDK** (версия 3.0.0 или выше)
|
||||||
|
- Установите Flutter: https://docs.flutter.dev/get-started/install
|
||||||
|
- Проверьте установку: `flutter doctor`
|
||||||
|
|
||||||
|
2. **Android Studio** или **VS Code** с Flutter плагином
|
||||||
|
|
||||||
|
3. **Android SDK** (для сборки под Android)
|
||||||
|
- MinSDK: 21
|
||||||
|
- TargetSDK: 34
|
||||||
|
- CompileSDK: 34
|
||||||
|
|
||||||
|
## Установка зависимостей
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client-mobile
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
## Генерация кода
|
||||||
|
|
||||||
|
Проект использует code generation для:
|
||||||
|
- Freezed (immutable модели)
|
||||||
|
- AutoRoute (навигация)
|
||||||
|
- Isar (база данных)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
Для автоматической генерации при изменениях:
|
||||||
|
```bash
|
||||||
|
dart run build_runner watch --delete-conflicting-outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск приложения
|
||||||
|
|
||||||
|
### Android
|
||||||
|
```bash
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сборка релиза
|
||||||
|
```bash
|
||||||
|
flutter build apk --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── core/ # Общие компоненты
|
||||||
|
│ ├── constants/ # Константы приложения
|
||||||
|
│ ├── errors/ # Обработка ошибок
|
||||||
|
│ ├── network/ # Сетевой клиент (Dio)
|
||||||
|
│ └── theme/ # Темы оформления
|
||||||
|
├── features/ # Функциональные модули
|
||||||
|
│ ├── auth/ # Аутентификация
|
||||||
|
│ │ ├── data/ # Data layer
|
||||||
|
│ │ ├── domain/ # Domain layer
|
||||||
|
│ │ └── presentation/ # UI layer (BLoC, страницы)
|
||||||
|
│ ├── chat/ # Чаты
|
||||||
|
│ ├── profile/ # Профиль
|
||||||
|
│ └── settings/ # Настройки
|
||||||
|
└── internal/ # Внутренняя конфигурация
|
||||||
|
├── di/ # Dependency Injection (GetIt)
|
||||||
|
└── router/ # Навигация
|
||||||
|
```
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
Проект следует принципам **Clean Architecture**:
|
||||||
|
|
||||||
|
- **Domain Layer**: Бизнес-логика, entities, use cases, repository interfaces
|
||||||
|
- **Data Layer**: Реализации репозиториев, datasources, модели
|
||||||
|
- **Presentation Layer**: UI, BLoC, страницы, виджеты
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
Используется **flutter_bloc** для управления состоянием:
|
||||||
|
- Каждый feature имеет свой BLoC
|
||||||
|
- События и состояния генерируются через Freezed
|
||||||
|
- DI через GetIt
|
||||||
|
|
||||||
|
## Навигация
|
||||||
|
|
||||||
|
Используется **auto_route** для декларативной навигации.
|
||||||
|
|
||||||
|
## База данных
|
||||||
|
|
||||||
|
Используется **Isar** - быстрая NoSQL база данных для Flutter.
|
||||||
|
|
||||||
|
## Сетевые запросы
|
||||||
|
|
||||||
|
- **Dio** для REST API запросов
|
||||||
|
- **SignalR** для real-time обновлений
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Полезные команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Анализ кода
|
||||||
|
flutter analyze
|
||||||
|
|
||||||
|
# Форматирование
|
||||||
|
dart format .
|
||||||
|
|
||||||
|
# Очистка
|
||||||
|
flutter clean
|
||||||
|
|
||||||
|
# Проверка зависимостей
|
||||||
|
flutter pub outdated
|
||||||
|
```
|
||||||
|
|
||||||
|
## Контакты
|
||||||
|
|
||||||
|
Для вопросов и предложений обращайтесь к команде разработки.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Messenger App
|
||||||
|
|
||||||
|
A Telegram-like mobile messenger application built with Flutter using Clean Architecture.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This project follows Clean Architecture principles with the following structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── core/ # Core utilities, network, errors, theme
|
||||||
|
├── features/ # Feature modules (auth, chat, profile, settings)
|
||||||
|
│ └── [feature]/
|
||||||
|
│ ├── data/ # Data layer (repositories, datasources)
|
||||||
|
│ ├── domain/ # Domain layer (entities, usecases, repository interfaces)
|
||||||
|
│ └── presentation/ # UI layer (bloc, pages, widgets)
|
||||||
|
└── internal/ # App configuration, DI, routing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **State Management**: flutter_bloc, bloc
|
||||||
|
- **Architecture & DI**: get_it, injectable, freezed_annotation
|
||||||
|
- **Network & Real-time**: dio, signalr_netcore
|
||||||
|
- **Navigation**: auto_route
|
||||||
|
- **Database**: isar, isar_flutter_libs
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install Flutter dependencies:
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Generate code:
|
||||||
|
```bash
|
||||||
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the app:
|
||||||
|
```bash
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Authentication (Login/Register)
|
||||||
|
- Real-time Chat
|
||||||
|
- Contacts
|
||||||
|
- Profile Management
|
||||||
|
- Settings
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
prefer_const_constructors: true
|
||||||
|
prefer_const_declarations: true
|
||||||
|
avoid_print: false
|
||||||
|
prefer_single_quotes: true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
plugins {
|
||||||
|
id "com.android.application"
|
||||||
|
id "kotlin-android"
|
||||||
|
id "dev.flutter.flutter-gradle-plugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
def localProperties = new Properties()
|
||||||
|
def localPropertiesFile = rootProject.file('local.properties')
|
||||||
|
if (localPropertiesFile.exists()) {
|
||||||
|
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||||
|
localProperties.load(reader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||||
|
if (flutterVersionCode == null) {
|
||||||
|
flutterVersionCode = '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||||
|
if (flutterVersionName == null) {
|
||||||
|
flutterVersionName = '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "com.example.messenger_app"
|
||||||
|
compileSdkVersion 36
|
||||||
|
ndkVersion flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
main.java.srcDirs += 'src/main/kotlin'
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.example.messenger_app"
|
||||||
|
minSdkVersion flutter.minSdkVersion
|
||||||
|
targetSdkVersion 35
|
||||||
|
versionCode flutterVersionCode.toInteger()
|
||||||
|
versionName flutterVersionName
|
||||||
|
multiDexEnabled true
|
||||||
|
}
|
||||||
|
|
||||||
|
packagingOptions {
|
||||||
|
resources {
|
||||||
|
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source '../..'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="Messenger"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.messenger_app
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#2481CC" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#2481CC" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#2481CC" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#2481CC" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#2481CC" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.buildDir = '../build'
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||||
|
afterEvaluate { project ->
|
||||||
|
if (project.hasProperty("android")) {
|
||||||
|
project.android {
|
||||||
|
if (namespace == null) {
|
||||||
|
namespace project.group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register("clean", Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
+1
-3
@@ -1,7 +1,5 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
|
||||||
networkTimeout=10000
|
|
||||||
validateDistributionUrl=true
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
pluginManagement {
|
||||||
|
def flutterSdkPath = {
|
||||||
|
def properties = new Properties()
|
||||||
|
file("local.properties").withInputStream { properties.load(it) }
|
||||||
|
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
|
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||||
|
return flutterSdkPath
|
||||||
|
}()
|
||||||
|
|
||||||
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||||
|
id "com.android.application" version "8.6.0" apply false
|
||||||
|
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include ":app"
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("com.android.application")
|
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
id("com.google.dagger.hilt.android")
|
|
||||||
id("com.google.gms.google-services")
|
|
||||||
kotlin("kapt")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "ru.knot.messager"
|
|
||||||
compileSdk = 34
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "ru.knot.messager"
|
|
||||||
minSdk = 26
|
|
||||||
targetSdk = 34
|
|
||||||
versionCode = 1
|
|
||||||
versionName = "1.0.0"
|
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
vectorDrawables {
|
|
||||||
useSupportLibrary = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Подключаем все наши папки с кодом как sourceSets
|
|
||||||
sourceSets {
|
|
||||||
getByName("main") {
|
|
||||||
java.srcDirs(
|
|
||||||
"src/main/kotlin",
|
|
||||||
"../auth",
|
|
||||||
"../chats",
|
|
||||||
"../core",
|
|
||||||
"../calls",
|
|
||||||
"../stories",
|
|
||||||
"../contacts",
|
|
||||||
"../profiles",
|
|
||||||
"../settings",
|
|
||||||
"../navigation"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
buildFeatures {
|
|
||||||
compose = true
|
|
||||||
}
|
|
||||||
composeOptions {
|
|
||||||
kotlinCompilerExtensionVersion = "1.5.8"
|
|
||||||
}
|
|
||||||
packaging {
|
|
||||||
resources {
|
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
// AndroidX & UI
|
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
|
||||||
implementation("androidx.activity:activity-compose:1.8.1")
|
|
||||||
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
|
||||||
implementation("androidx.compose.ui:ui")
|
|
||||||
implementation("androidx.compose.ui:ui-graphics")
|
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
|
||||||
implementation("androidx.compose.material3:material3")
|
|
||||||
implementation("com.google.android.material:material:1.11.0")
|
|
||||||
implementation("androidx.navigation:navigation-compose:2.7.5")
|
|
||||||
implementation("androidx.compose.material:material-icons-extended")
|
|
||||||
|
|
||||||
// Hilt
|
|
||||||
implementation("com.google.dagger:hilt-android:2.48")
|
|
||||||
kapt("com.google.dagger:hilt-android-compiler:2.48")
|
|
||||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
|
||||||
|
|
||||||
// Network & SignalR
|
|
||||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
|
||||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
|
||||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
|
||||||
implementation("com.microsoft.signalr:signalr:7.0.0")
|
|
||||||
|
|
||||||
// WebRTC
|
|
||||||
implementation("com.github.webrtc-sdk:android:104.5112.01")
|
|
||||||
|
|
||||||
// Media3 (ExoPlayer)
|
|
||||||
implementation("androidx.media3:media3-exoplayer:1.2.0")
|
|
||||||
implementation("androidx.media3:media3-ui:1.2.0")
|
|
||||||
implementation("androidx.media3:media3-common:1.2.0")
|
|
||||||
|
|
||||||
// Images & GIF
|
|
||||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
|
||||||
implementation("io.coil-kt:coil-gif:2.5.0")
|
|
||||||
implementation("io.coil-kt:coil-svg:2.5.0")
|
|
||||||
|
|
||||||
// Security
|
|
||||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
|
||||||
|
|
||||||
// UCrop (Image Cropping)
|
|
||||||
implementation("com.github.yalantis:ucrop:2.2.8")
|
|
||||||
|
|
||||||
// Firebase (Push Notifications)
|
|
||||||
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
|
|
||||||
implementation("com.google.firebase:firebase-messaging-ktx")
|
|
||||||
implementation("com.google.firebase:firebase-analytics-ktx")
|
|
||||||
|
|
||||||
// Testing
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"project_info": {
|
|
||||||
"project_number": "483917912506",
|
|
||||||
"project_id": "knot-bad1a",
|
|
||||||
"storage_bucket": "knot-bad1a.firebasestorage.app"
|
|
||||||
},
|
|
||||||
"client": [
|
|
||||||
{
|
|
||||||
"client_info": {
|
|
||||||
"mobilesdk_app_id": "1:483917912506:android:cd39213364869ef9e82583",
|
|
||||||
"android_client_info": {
|
|
||||||
"package_name": "ru.knot.messager"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"oauth_client": [],
|
|
||||||
"api_key": [
|
|
||||||
{
|
|
||||||
"current_key": "AIzaSyBAL_bZJYaa7rGERLX63LeFXz-__JXRWQY"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"services": {
|
|
||||||
"appinvite_service": {
|
|
||||||
"other_platform_oauth_client": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"configuration_version": "1"
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
package="ru.knot.messager">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:name="com.knot.messenger.MainApplication"
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@style/Theme.KnotMessenger">
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name="com.knot.messenger.MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:theme="@style/Theme.KnotMessenger">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<service
|
|
||||||
android:name="core.notifications.data.ForkFirebaseMessagingService"
|
|
||||||
android:exported="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package com.knot.messenger
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import navigation.AppNavigation
|
|
||||||
import core.presentation.theme.ForkMessengerTheme
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContent {
|
|
||||||
ForkMessengerTheme {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
color = MaterialTheme.colorScheme.background
|
|
||||||
) {
|
|
||||||
AppNavigation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.knot.messenger
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
|
||||||
|
|
||||||
@HiltAndroidApp
|
|
||||||
class MainApplication : Application()
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<string name="app_name">ForkMessenger</string>
|
|
||||||
<string name="login">Login</string>
|
|
||||||
<string name="register">Register</string>
|
|
||||||
<string name="username">Username</string>
|
|
||||||
<string name="password">Password</string>
|
|
||||||
<string name="display_name">Display Name</string>
|
|
||||||
<string name="settings">Settings</string>
|
|
||||||
<string name="save">Save</string>
|
|
||||||
<string name="back">Back</string>
|
|
||||||
<string name="server_connection">Server Connection</string>
|
|
||||||
<string name="api_base_url">API Base URL</string>
|
|
||||||
<string name="server_features">Server Features</string>
|
|
||||||
<string name="stories">Stories</string>
|
|
||||||
<string name="polls">Polls</string>
|
|
||||||
<string name="calls">Calls</string>
|
|
||||||
<string name="groups">Groups</string>
|
|
||||||
<string name="enabled">Enabled</string>
|
|
||||||
<string name="disabled">Disabled</string>
|
|
||||||
<string name="limits">Limits</string>
|
|
||||||
<string name="max_file_size">Max File Size</string>
|
|
||||||
<string name="max_group_members">Max Group Members</string>
|
|
||||||
<string name="message">Message</string>
|
|
||||||
<string name="call">Call</string>
|
|
||||||
<string name="block">Block</string>
|
|
||||||
<string name="profile">Profile</string>
|
|
||||||
<string name="confirm_password">Confirm Password</string>
|
|
||||||
<string name="passwords_not_match">Passwords do not match</string>
|
|
||||||
<string name="no_account_register">Don\'t have an account? Register</string>
|
|
||||||
<string name="already_have_account">Already have an account? Login</string>
|
|
||||||
<string name="error_occurred">An error occurred</string>
|
|
||||||
<string name="loading">Loading...</string>
|
|
||||||
<string name="chats_title">Chats</string>
|
|
||||||
<string name="contacts_title">Contacts</string>
|
|
||||||
<string name="stories_title">Stories</string>
|
|
||||||
<string name="create_story">Create Story</string>
|
|
||||||
<string name="send_message_hint">Type a message...</string>
|
|
||||||
<string name="reply_to_user">Reply to %1$s...</string>
|
|
||||||
<string name="story_editor">STORY EDITOR</string>
|
|
||||||
<string name="publish">PUBLISH</string>
|
|
||||||
<string name="start_creation">START CREATION</string>
|
|
||||||
<string name="text_tool">TEXT</string>
|
|
||||||
<string name="crop_tool">CROP</string>
|
|
||||||
<string name="stickers_tool">STICKERS</string>
|
|
||||||
<string name="brush_tool">BRUSH</string>
|
|
||||||
<string name="filters_tool">FILTERS</string>
|
|
||||||
<string name="remove">Remove</string>
|
|
||||||
<string name="no_chats_found">No chats found</string>
|
|
||||||
<string name="typing">typing...</string>
|
|
||||||
<string name="video_call">Video Call</string>
|
|
||||||
<string name="emoji">Emoji</string>
|
|
||||||
<string name="attach">Attach</string>
|
|
||||||
<string name="message_placeholder">Message...</string>
|
|
||||||
<string name="voice_message">Voice Message</string>
|
|
||||||
<string name="send">Send</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<string name="app_name">ForkMessenger</string>
|
|
||||||
<string name="login">Войти</string>
|
|
||||||
<string name="register">Регистрация</string>
|
|
||||||
<string name="username">Имя пользователя</string>
|
|
||||||
<string name="password">Пароль</string>
|
|
||||||
<string name="display_name">Отображаемое имя</string>
|
|
||||||
<string name="settings">Настройки</string>
|
|
||||||
<string name="save">Сохранить</string>
|
|
||||||
<string name="back">Назад</string>
|
|
||||||
<string name="server_connection">Подключение к серверу</string>
|
|
||||||
<string name="api_base_url">API Base URL</string>
|
|
||||||
<string name="server_features">Функции сервера</string>
|
|
||||||
<string name="stories">Истории</string>
|
|
||||||
<string name="polls">Опросы</string>
|
|
||||||
<string name="calls">Звонки</string>
|
|
||||||
<string name="groups">Группы</string>
|
|
||||||
<string name="enabled">Включено</string>
|
|
||||||
<string name="disabled">Отключено</string>
|
|
||||||
<string name="limits">Лимиты</string>
|
|
||||||
<string name="max_file_size">Макс. размер файла</string>
|
|
||||||
<string name="max_group_members">Макс. участников в группе</string>
|
|
||||||
<string name="message">Сообщение</string>
|
|
||||||
<string name="call">Позвонить</string>
|
|
||||||
<string name="block">Заблокировать</string>
|
|
||||||
<string name="profile">Профиль</string>
|
|
||||||
<string name="confirm_password">Подтвердите пароль</string>
|
|
||||||
<string name="passwords_not_match">Пароли не совпадают</string>
|
|
||||||
<string name="no_account_register">Нет аккаунта? Зарегистрироваться</string>
|
|
||||||
<string name="already_have_account">Уже есть аккаунт? Войти</string>
|
|
||||||
<string name="error_occurred">Произошла ошибка</string>
|
|
||||||
<string name="loading">Загрузка...</string>
|
|
||||||
<string name="chats_title">Чаты</string>
|
|
||||||
<string name="contacts_title">Контакты</string>
|
|
||||||
<string name="stories_title">Истории</string>
|
|
||||||
<string name="create_story">Создать историю</string>
|
|
||||||
<string name="send_message_hint">Напишите сообщение...</string>
|
|
||||||
<string name="reply_to_user">Ответить %1$s...</string>
|
|
||||||
<string name="story_editor">РЕДАКТОР ИСТОРИЙ</string>
|
|
||||||
<string name="publish">ОПУБЛИКОВАТЬ</string>
|
|
||||||
<string name="start_creation">НАЧАТЬ СОЗДАНИЕ</string>
|
|
||||||
<string name="text_tool">ТЕКСТ</string>
|
|
||||||
<string name="crop_tool">ОБРЕЗКА</string>
|
|
||||||
<string name="stickers_tool">СТИКЕРЫ</string>
|
|
||||||
<string name="brush_tool">КИСТЬ</string>
|
|
||||||
<string name="filters_tool">ФИЛЬТРЫ</string>
|
|
||||||
<string name="remove">Удалить</string>
|
|
||||||
<string name="no_chats_found">Чаты не найдены</string>
|
|
||||||
<string name="typing">печатает...</string>
|
|
||||||
<string name="video_call">Видеозвонок</string>
|
|
||||||
<string name="emoji">Эмодзи</string>
|
|
||||||
<string name="attach">Прикрепить</string>
|
|
||||||
<string name="message_placeholder">Сообщение...</string>
|
|
||||||
<string name="voice_message">Голосовое сообщение</string>
|
|
||||||
<string name="send">Отправить</string>
|
|
||||||
<string name="search_hint">Поиск...</string>
|
|
||||||
<string name="online">В сети</string>
|
|
||||||
<string name="last_seen">Был(а): %1$s</string>
|
|
||||||
<string name="last_seen_recently">недавно</string>
|
|
||||||
<string name="all">Все</string>
|
|
||||||
<string name="online_tab">Онлайн</string>
|
|
||||||
<string name="blocked">Заблокированные</string>
|
|
||||||
<string name="media">Медиа</string>
|
|
||||||
<string name="notifications">Уведомления</string>
|
|
||||||
<string name="mute">Без звука</string>
|
|
||||||
<string name="unmute">Включить звук</string>
|
|
||||||
<string name="log_out">Выйти из аккаунта</string>
|
|
||||||
<string name="bio">О себе</string>
|
|
||||||
<string name="edit_profile">Редактировать профиль</string>
|
|
||||||
<string name="username_label">Имя пользователя</string>
|
|
||||||
<string name="change_photo">Изменить фото</string>
|
|
||||||
<string name="cancel">Отмена</string>
|
|
||||||
<string name="crop">Обрезать</string>
|
|
||||||
<string name="chats">Чаты</string>
|
|
||||||
<string name="contacts_tab">Контакты</string>
|
|
||||||
<string name="profile_tab">Профиль</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<style name="Theme.KnotMessenger" parent="Theme.Material3.DayNight.NoActionBar">
|
|
||||||
<item name="android:statusBarColor">#0F0F10</item>
|
|
||||||
<item name="android:windowBackground">#0F0F10</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package auth.data.remote.api
|
|
||||||
|
|
||||||
import auth.data.remote.dto.AuthRequest
|
|
||||||
import auth.data.remote.dto.AuthResponse
|
|
||||||
import core.domain.model.ServerConfigModel
|
|
||||||
import retrofit2.http.Body
|
|
||||||
import retrofit2.http.GET
|
|
||||||
import retrofit2.http.Headers
|
|
||||||
import retrofit2.http.POST
|
|
||||||
|
|
||||||
interface AuthApi {
|
|
||||||
@POST("auth/login")
|
|
||||||
@Headers("Cache-Control: no-cache")
|
|
||||||
suspend fun login(@Body request: AuthRequest): AuthResponse
|
|
||||||
|
|
||||||
@POST("auth/register")
|
|
||||||
@Headers("Cache-Control: no-cache")
|
|
||||||
suspend fun register(@Body request: AuthRequest): AuthResponse
|
|
||||||
|
|
||||||
@GET("config")
|
|
||||||
@Headers("Cache-Control: no-cache")
|
|
||||||
suspend fun getConfig(): ServerConfigModel
|
|
||||||
|
|
||||||
@POST("auth/push-token")
|
|
||||||
@Headers("Cache-Control: no-cache")
|
|
||||||
suspend fun updatePushToken(@Body token: String): Unit
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package auth.data.remote.dto
|
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
|
|
||||||
data class AuthRequest(
|
|
||||||
@SerializedName("userName") val userName: String,
|
|
||||||
@SerializedName("password") val password: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AuthResponse(
|
|
||||||
@SerializedName("accessToken") val accessToken: String?,
|
|
||||||
@SerializedName("user") val user: UserDto?,
|
|
||||||
@SerializedName("userId") val userId: String?,
|
|
||||||
@SerializedName("username") val username: String?,
|
|
||||||
@SerializedName("displayName") val displayName: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UserDto(
|
|
||||||
@SerializedName("id") val id: String,
|
|
||||||
@SerializedName("userName") val userName: String,
|
|
||||||
@SerializedName("displayName") val displayName: String?,
|
|
||||||
@SerializedName("avatarUrl") val avatarUrl: String?
|
|
||||||
)
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
package auth.data.repository
|
|
||||||
|
|
||||||
import auth.data.remote.api.AuthApi
|
|
||||||
import auth.data.remote.dto.AuthRequest
|
|
||||||
import auth.domain.model.AuthResult
|
|
||||||
import auth.domain.repository.AuthRepository
|
|
||||||
import core.network.ServerConfig
|
|
||||||
import core.security.TokenManager
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class AuthRepositoryImpl @Inject constructor(
|
|
||||||
private val api: AuthApi,
|
|
||||||
private val tokenManager: TokenManager,
|
|
||||||
private val serverConfig: ServerConfig
|
|
||||||
) : AuthRepository {
|
|
||||||
|
|
||||||
override suspend fun login(userName: String, password: String): Result<AuthResult> {
|
|
||||||
return try {
|
|
||||||
val response = api.login(AuthRequest(userName, password))
|
|
||||||
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
|
|
||||||
val userId = response.userId ?: ""
|
|
||||||
|
|
||||||
tokenManager.saveToken(token, userId)
|
|
||||||
fetchConfig()
|
|
||||||
Result.success(
|
|
||||||
AuthResult(
|
|
||||||
token = token,
|
|
||||||
userId = userId,
|
|
||||||
userName = response.username ?: userName,
|
|
||||||
displayName = response.displayName ?: response.username ?: userName,
|
|
||||||
avatarUrl = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun register(userName: String, password: String): Result<AuthResult> {
|
|
||||||
return try {
|
|
||||||
val response = api.register(AuthRequest(userName, password))
|
|
||||||
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
|
|
||||||
val userId = response.userId ?: ""
|
|
||||||
|
|
||||||
tokenManager.saveToken(token, userId)
|
|
||||||
fetchConfig()
|
|
||||||
Result.success(
|
|
||||||
AuthResult(
|
|
||||||
token = token,
|
|
||||||
userId = userId,
|
|
||||||
userName = response.username ?: userName,
|
|
||||||
displayName = response.displayName ?: response.username ?: userName,
|
|
||||||
avatarUrl = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun logout() {
|
|
||||||
tokenManager.deleteToken()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isAuthenticated(): Boolean {
|
|
||||||
return tokenManager.getToken() != null
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun fetchConfig(): Result<Unit> {
|
|
||||||
return try {
|
|
||||||
val config = api.getConfig()
|
|
||||||
serverConfig.saveServerConfig(config)
|
|
||||||
Result.success(Unit)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package auth.di
|
|
||||||
|
|
||||||
import auth.data.remote.api.AuthApi
|
|
||||||
import auth.data.repository.AuthRepositoryImpl
|
|
||||||
import auth.domain.repository.AuthRepository
|
|
||||||
import core.network.ServerConfig
|
|
||||||
import core.security.TokenManager
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import retrofit2.Retrofit
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object AuthModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideAuthApi(retrofit: Retrofit): AuthApi {
|
|
||||||
return retrofit.create(AuthApi::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideAuthRepository(
|
|
||||||
api: AuthApi,
|
|
||||||
tokenManager: TokenManager,
|
|
||||||
serverConfig: ServerConfig
|
|
||||||
): AuthRepository {
|
|
||||||
return AuthRepositoryImpl(api, tokenManager, serverConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user