Структура, доп модули, федерация, документация

This commit is contained in:
Халимов Рустам
2026-03-27 00:55:01 +03:00
parent 7cb6ac61dd
commit 7ef73b414c
64 changed files with 3080 additions and 133 deletions

View File

@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using System.Security.Cryptography;
namespace Host.Application.Admin.Commands;
@@ -13,15 +14,33 @@ public record UpdateSettingsCommand(SystemSettingsDto Settings) : ICommand<Syste
internal sealed class UpdateSettingsCommandHandler : ICommandHandler<UpdateSettingsCommand, SystemSettingsDto>
{
private readonly ISettingsService _settingsService;
private readonly IMediator _mediator;
public UpdateSettingsCommandHandler(ISettingsService settingsService)
public UpdateSettingsCommandHandler(ISettingsService settingsService, IMediator mediator)
{
_settingsService = settingsService;
_mediator = mediator;
}
public async Task<Result<SystemSettingsDto>> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken)
{
// Если федерация включена, но ключи еще не сгенерированы — создаем их
if (request.Settings.Federation.Enabled &&
(string.IsNullOrEmpty(request.Settings.Federation.PrivateKey) ||
string.IsNullOrEmpty(request.Settings.Federation.PublicKey)))
{
using var rsa = RSA.Create(2048);
// Экспорт в формате PKCS#8 (Private) и PKCS#1 (Public) в Base64 PEM-строки
request.Settings.Federation.PrivateKey = Convert.ToBase64String(rsa.ExportPkcs8PrivateKey());
request.Settings.Federation.PublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey());
}
await _settingsService.UpdateSettingsAsync(request.Settings, cancellationToken);
// Уведомляем другие модули (в т.ч. Федерацию) через Доменное Событие
await _mediator.Publish(new Knot.Modules.Admin.Domain.Events.SystemSettingsUpdatedDomainEvent(request.Settings), cancellationToken);
return Result.Success(request.Settings);
}
}

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
namespace Knot.Modules.Admin.Domain.Events;
/// <summary>
/// Доменное событие: глобальные настройки системы обновлены.
/// </summary>
public sealed record SystemSettingsUpdatedDomainEvent(SystemSettingsDto Settings) : IDomainEvent;

View File

@@ -25,13 +25,13 @@ public sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCom
{
private readonly IUserRepository _userRepository;
private readonly IAuthUnitOfWork _unitOfWork;
private readonly ISettingsService _settings;
private readonly ISystemSettings _settings;
private readonly IJwtTokenProvider _tokenProvider;
public RegisterUserCommandHandler(
IUserRepository userRepository,
IAuthUnitOfWork unitOfWork,
ISettingsService settings,
ISystemSettings settings,
IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;

View File

@@ -14,5 +14,6 @@ public interface IUserRepository
Task<List<User>> SearchUsersAsync(string query, CancellationToken cancellationToken = default);
void Add(User user);
void Update(User user);
void Remove(User user);
}

View File

@@ -2,6 +2,8 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Domain;
public sealed record UserStatusChangedDomainEvent(Guid UserId, bool IsOnline, DateTime LastSeen) : IDomainEvent;
/// <summary>
/// Сущность пользователя в контексте идентификации (Identity).
/// </summary>
@@ -16,6 +18,11 @@ public sealed class User : AggregateRoot<Guid>
public DateTime? Birthday { get; private set; }
public DateTime CreatedAt { get; private set; }
public bool HideStoryViews { get; private set; }
public bool IsExternal { get; private set; }
public string? Domain { get; private set; }
public bool IsOnline { get; private set; }
public DateTime? LastSeen { get; private set; }
public bool HideStatus { get; private set; }
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
: base(id)
@@ -36,6 +43,18 @@ public sealed class User : AggregateRoot<Guid>
return new User(Guid.NewGuid(), username, passwordHash, displayName, email, bio);
}
/// <summary>
/// Создает "теневого" пользователя для контакта с другого сервера.
/// </summary>
public static User CreateExternal(Guid id, string username, string displayName, string domain, string? avatar = null)
{
var user = new User(id, username, "EXTERNAL_USER", displayName, null, null);
user.IsExternal = true;
user.Domain = domain;
user.Avatar = avatar;
return user;
}
public void UpdateProfile(string displayName, string? bio, DateTime? birthday)
{
DisplayName = displayName;
@@ -57,5 +76,21 @@ public sealed class User : AggregateRoot<Guid>
{
PasswordHash = newPasswordHash;
}
public void UpdateStatus(bool isOnline)
{
IsOnline = isOnline;
LastSeen = DateTime.UtcNow;
if (!HideStatus)
{
RaiseDomainEvent(new UserStatusChangedDomainEvent(Id, IsOnline, LastSeen.Value));
}
}
public void SetHideStatus(bool hide)
{
HideStatus = hide;
}
}

View File

@@ -53,5 +53,10 @@ public sealed class UserRepository : IUserRepository
{
_context.Users.Update(user);
}
public void Remove(User user)
{
_context.Users.Remove(user);
}
}

View File

@@ -0,0 +1,50 @@
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Shared.Kernel.Configuration;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Conversations.Application.Folders.Commands.AddToFolder;
public record AddToFolderCommand(Guid UserId, Guid ChatId, List<Guid> FolderIds) : ICommand;
internal sealed class AddToFolderCommandHandler : ICommandHandler<AddToFolderCommand>
{
private readonly IUserChatSettingsRepository _settingsRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IChatsSettings _chatsSettings;
public AddToFolderCommandHandler(
IUserChatSettingsRepository settingsRepository,
IChatsUnitOfWork unitOfWork,
IChatsSettings chatsSettings)
{
_settingsRepository = settingsRepository;
_unitOfWork = unitOfWork;
_chatsSettings = chatsSettings;
}
public async Task<Result> Handle(AddToFolderCommand request, CancellationToken cancellationToken)
{
if (!_chatsSettings.Current.EnableFolders)
{
return Result.Failure(ChatErrors.FoldersDisabled);
}
var settings = await _settingsRepository.GetAsync(request.UserId, request.ChatId, cancellationToken);
if (settings == null)
{
settings = UserChatSettings.Create(request.UserId, request.ChatId);
_settingsRepository.Add(settings);
}
foreach (var folderId in request.FolderIds)
{
settings.AddToFolder(folderId);
}
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -0,0 +1,40 @@
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Conversations.Application.Folders.Commands.RemoveFromFolder;
public record RemoveFromFolderCommand(Guid UserId, Guid ChatId, List<Guid> FolderIds) : ICommand;
internal sealed class RemoveFromFolderCommandHandler : ICommandHandler<RemoveFromFolderCommand>
{
private readonly IUserChatSettingsRepository _settingsRepository;
private readonly IChatsUnitOfWork _unitOfWork;
public RemoveFromFolderCommandHandler(IUserChatSettingsRepository settingsRepository, IChatsUnitOfWork unitOfWork)
{
_settingsRepository = settingsRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result> Handle(RemoveFromFolderCommand request, CancellationToken cancellationToken)
{
var settings = await _settingsRepository.GetAsync(request.UserId, request.ChatId, cancellationToken);
if (settings == null)
{
return Result.Success();
}
foreach (var folderId in request.FolderIds)
{
settings.RemoveFromFolder(folderId);
}
_settingsRepository.Update(settings);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -1,11 +1,10 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using global::Knot.Modules.Conversations.Domain;
using global::Knot.Modules.Conversations.Infrastructure.SignalR;
using global::Knot.Shared.Kernel;
using global::Knot.Modules.Conversations.Application.Abstractions;
using MessagingMessageRepository = Knot.Modules.Messaging.Domain.IMessageRepository;
namespace Knot.Modules.Conversations.Application.Messages.Delete;
@@ -17,12 +16,12 @@ public sealed record DeleteMessagesCommand(
public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessagesCommand>
{
private readonly IMessageRepository _messageRepository;
private readonly MessagingMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IHubContext<ChatHub> _hubContext;
public DeleteMessagesCommandHandler(
IMessageRepository messageRepository,
MessagingMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork,
IHubContext<ChatHub> hubContext)
{
@@ -67,7 +66,6 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
}
else
{
// Уведомляем только самого пользователя (все его текущие сессии)
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
{
chatId = request.ChatId,
@@ -79,5 +77,3 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
return global::Knot.Shared.Kernel.Result.Success();
}
}

View File

@@ -2,6 +2,7 @@ using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Messages.Send;
@@ -22,7 +23,11 @@ public sealed record SendMessageCommand(
Guid? ForwardedFromId = null,
Guid? StoryId = null,
string? StoryMediaUrl = null,
string? StoryMediaType = null) : ICommand<Guid>;
string? StoryMediaType = null,
List<string>? PollOptions = null,
bool? PollIsAnonymous = null,
bool? PollAllowMultipleAnswers = null,
DateTime? PollExpiresAt = null) : ICommand<Guid>;
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
{
@@ -30,17 +35,20 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly MediatR.IMediator _mediator;
private readonly IMessagesSettings _messagesSettings;
public SendMessageCommandHandler(
IChatRepository chatRepository,
IMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork,
MediatR.IMediator mediator)
MediatR.IMediator mediator,
IMessagesSettings messagesSettings)
{
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_unitOfWork = unitOfWork;
_mediator = mediator;
_messagesSettings = messagesSettings;
}
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
@@ -62,6 +70,8 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
Message message;
if (request.Type == "story_reply" || request.Type == "story_reaction")
{
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
message = new StoryMessage(
Guid.NewGuid(),
@@ -78,6 +88,8 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
}
else if (request.Attachments != null && request.Attachments.Any())
{
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
var firstAtt = request.Attachments.First();
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
@@ -98,6 +110,24 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
((MediaMessage)message).AddMedia(pType, att.Url, att.FileName, att.FileSize);
}
}
else if (request.Type == "poll")
{
if (!_messagesSettings.Current.AllowPolls) return Result.Failure<Guid>(ChatErrors.PollsDisabled);
message = new PollMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.Content ?? "Poll",
request.PollOptions ?? new List<string>(),
request.PollIsAnonymous ?? true,
request.PollAllowMultipleAnswers ?? false,
request.PollExpiresAt,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false);
}
else
{
message = new TextMessage(

View File

@@ -16,9 +16,9 @@ public record UploadFileCommand(string FileName, string ContentType, long Length
internal sealed class UploadFileCommandHandler : ICommandHandler<UploadFileCommand, UploadFileResponseDto>
{
private readonly IFileStorageService _fileStorage;
private readonly ISettingsService _settingsService;
private readonly IMessagesSettings _settingsService;
public UploadFileCommandHandler(IFileStorageService fileStorage, ISettingsService settingsService)
public UploadFileCommandHandler(IFileStorageService fileStorage, IMessagesSettings settingsService)
{
_fileStorage = fileStorage;
_settingsService = settingsService;
@@ -31,8 +31,10 @@ internal sealed class UploadFileCommandHandler : ICommandHandler<UploadFileComma
return Result.Failure<UploadFileResponseDto>(ChatErrors.FileEmpty);
}
var maxMb = _settingsService.Current.MaxFileSizeMb;
if (request.Length > maxMb * 1024 * 1024)
var maxBytes = _settingsService.Current.MaxMediaSizeBytes;
var maxMb = maxBytes / (1024 * 1024);
if (request.Length > maxBytes)
{
return Result.Failure<UploadFileResponseDto>(ChatErrors.FileTooLarge(maxMb));
}

View File

@@ -0,0 +1,97 @@
using Knot.Modules.Auth.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using MediatR;
using System.Text.RegularExpressions;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Auth.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Users.Commands.DeleteUser;
public record DeleteUserCommand(Guid UserId) : ICommand;
internal sealed class DeleteUserCommandHandler : ICommandHandler<DeleteUserCommand>
{
private readonly IUserRepository _userRepository;
private readonly IUserChatSettingsRepository _userChatSettingsRepository;
private readonly IUserFolderSettingsRepository _userFolderSettingsRepository;
private readonly Knot.Modules.Messaging.Domain.IMessageRepository _messageRepository;
private readonly IFileStorageService _fileStorage;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IAuthUnitOfWork _authUnitOfWork;
public DeleteUserCommandHandler(
IUserRepository userRepository,
IUserChatSettingsRepository userChatSettingsRepository,
IUserFolderSettingsRepository userFolderSettingsRepository,
Knot.Modules.Messaging.Domain.IMessageRepository messageRepository,
IFileStorageService fileStorage,
IChatsUnitOfWork unitOfWork,
IAuthUnitOfWork authUnitOfWork)
{
_userRepository = userRepository;
_userChatSettingsRepository = userChatSettingsRepository;
_userFolderSettingsRepository = userFolderSettingsRepository;
_messageRepository = messageRepository;
_fileStorage = fileStorage;
_unitOfWork = unitOfWork;
_authUnitOfWork = authUnitOfWork;
}
public async Task<Result> Handle(DeleteUserCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null) return Result.Failure(Error.NotFound("User.NotFound", "User not found"));
// 1. Delete Messages and their files in MongoDB
var allMessages = await _messageRepository.GetAllMessagesAsync(cancellationToken);
var userMessages = allMessages.Where(m => m.SenderId == request.UserId).ToList();
foreach (var msg in userMessages)
{
if (msg is MediaMessage mediaMsg)
{
foreach (var media in mediaMsg.Media)
{
bool isUsedElsewhere = allMessages.Any(m => m.Id != msg.Id &&
m is MediaMessage mm && mm.Media.Any(ame => ame.Url == media.Url));
if (!isUsedElsewhere)
{
var fileId = ExtractFileId(media.Url);
if (!string.IsNullOrEmpty(fileId))
{
await _fileStorage.DeleteFileAsync(fileId);
}
}
}
}
}
await _messageRepository.DeleteUserMessagesAsync(request.UserId, cancellationToken);
// 2. Delete Folder Settings (MongoDB)
await _userFolderSettingsRepository.RemoveByUserIdAsync(request.UserId, cancellationToken);
// 3. Delete Chat Settings (Postgres)
var chatSettings = await _userChatSettingsRepository.GetByUserIdAsync(request.UserId, cancellationToken);
_userChatSettingsRepository.RemoveRange(chatSettings);
// 4. Delete User (Postgres - Auth Module)
_userRepository.Remove(user);
// Commit all
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _authUnitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
private string? ExtractFileId(string url)
{
var match = Regex.Match(url, @"/([^/]+)$");
return match.Success ? match.Groups[1].Value : null;
}
}

View File

@@ -4,8 +4,11 @@ using Microsoft.EntityFrameworkCore;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Messaging.Infrastructure.Persistence;
namespace Knot.Modules.Conversations;
@@ -25,12 +28,19 @@ public static class DependencyInjection
options.UseNpgsql(connectionString));
// MongoDB Setup for Messages
ConversationsMongoDbMapConfigurator.Configure();
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
// Registration
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<IChatRepository, ChatRepository>();
services.AddScoped<IFolderRepository, FolderRepository>();
services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>();
services.AddScoped<IUserFolderSettingsRepository, UserFolderSettingsRepository>();
// Messaging Repository registration (might be redundant if already in Messaging module, but needed for specific commands in Conversations)
services.AddScoped<IMessageRepository, MessageRepository>();
// MediatR
services.AddMediatR(config =>

View File

@@ -15,6 +15,9 @@ public static class ChatErrors
public static readonly Error MessagesNotFound = new Error("Messages.NotFound", "Message not found.");
public static readonly Error ChatsNotFound = new Error("Chats.NotFound", "Чат не найден.");
public static readonly Error Unauthorized = new Error("Chats.Unauthorized", "Access denied");
public static readonly Error FoldersDisabled = new Error("Folders.Disabled", "Folders feature is disabled by the administrator.");
public static readonly Error PollsDisabled = new Error("Polls.Disabled", "Polls are disabled by the administrator.");
public static readonly Error MediaDisabled = new Error("Media.Disabled", "Media messages are disabled by the administrator.");
public static Error ImportCreateChatFailed(string msg) => new Error("Import.CreateChatFailed", msg);
public static Error FileTooLarge(int maxMb) => new Error("File.TooLarge", $"File exceeds the maximum allowed size of {maxMb}MB.");

View File

@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Domain;
/// <summary>
/// Сущность папки для группировки чатов.
/// </summary>
public sealed class Folder : AggregateRoot<Guid>
{
public string Name { get; private set; }
public string? Icon { get; private set; } // URL из хранилища
public bool IsDefault { get; private set; }
public FolderType Type { get; private set; }
public Folder(Guid id, string name, string? icon = null, bool isDefault = false, FolderType type = FolderType.Custom)
: base(id)
{
Name = name;
Icon = icon;
IsDefault = isDefault;
Type = type;
}
public void Update(string name, string? icon)
{
if (IsDefault) throw new InvalidOperationException("Cannot rename default folders.");
Name = name;
Icon = icon;
}
}
public enum FolderType
{
All, // Все чаты
New, // Новые (с непрочитанными)
Muted, // Без звука
Custom // Пользовательская
}
/// <summary>
/// Настройки конкретного чата для конкретного пользователя.
/// Хранятся в PostgreSQL (связь User <-> Chat).
/// </summary>
public sealed class UserChatSettings : Entity<Guid>
{
public Guid UserId { get; private set; }
public Guid ChatId { get; private set; }
// Список папок, в которые входит чат для этого пользователя
private readonly List<Guid> _folderIds = new();
public IReadOnlyCollection<Guid> FolderIds => _folderIds.AsReadOnly();
public bool IsMuted { get; private set; }
private UserChatSettings() : base(Guid.NewGuid()) { }
public UserChatSettings(Guid userId, Guid chatId) : base(Guid.NewGuid())
{
UserId = userId;
ChatId = chatId;
}
public static UserChatSettings Create(Guid userId, Guid chatId) => new(userId, chatId);
public void AddToFolder(Guid folderId)
{
if (!_folderIds.Contains(folderId)) _folderIds.Add(folderId);
}
public void RemoveFromFolder(Guid folderId)
{
_folderIds.Remove(folderId);
}
public void SetMute(bool isMuted) => IsMuted = isMuted;
}
/// <summary>
/// Глобальные настройки папок пользователя (скрытие дефолтных и т.д.).
/// Будет храниться в MongoDB.
/// </summary>
public sealed class UserFolderSettings : AggregateRoot<Guid>
{
public Guid UserId { get; private set; }
// Список ID папок, которые пользователь скрыл (только для дефолтных)
public List<Guid> HiddenDefaultFolderIds { get; private set; } = new();
// Список пользовательских папок (Guid созданных Folder)
public List<Guid> CustomFolderIds { get; private set; } = new();
public UserFolderSettings(Guid userId) : base(Guid.NewGuid())
{
UserId = userId;
}
public void HideFolder(Guid folderId)
{
if (!HiddenDefaultFolderIds.Contains(folderId)) HiddenDefaultFolderIds.Add(folderId);
}
public void ShowFolder(Guid folderId)
{
HiddenDefaultFolderIds.Remove(folderId);
}
}

View File

@@ -12,3 +12,28 @@ public interface IChatRepository
Task<List<Chat>> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken);
}
public interface IFolderRepository
{
void Add(Folder folder);
void Update(Folder folder);
void Remove(Folder folder);
Task<Folder?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<List<Folder>> GetUserFoldersAsync(Guid userId, CancellationToken cancellationToken);
}
public interface IUserChatSettingsRepository
{
void Add(UserChatSettings settings);
void Update(UserChatSettings settings);
void Remove(UserChatSettings settings);
void RemoveRange(IEnumerable<UserChatSettings> settings);
Task<UserChatSettings?> GetAsync(Guid userId, Guid chatId, CancellationToken cancellationToken);
Task<List<UserChatSettings>> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken);
}
public interface IUserFolderSettingsRepository
{
Task<UserFolderSettings?> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken);
Task UpdateAsync(UserFolderSettings settings, CancellationToken cancellationToken);
Task RemoveByUserIdAsync(Guid userId, CancellationToken cancellationToken);
}

View File

@@ -54,3 +54,59 @@ public sealed class ChatRepository : IChatRepository
}
}
public sealed class FolderRepository : IFolderRepository
{
private readonly ChatsDbContext _dbContext;
public FolderRepository(ChatsDbContext dbContext)
{
_dbContext = dbContext;
}
public void Add(Folder folder) => _dbContext.Folders.Add(folder);
public void Update(Folder folder) => _dbContext.Folders.Update(folder);
public void Remove(Folder folder) => _dbContext.Folders.Remove(folder);
public async Task<Folder?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
{
return await _dbContext.Folders.FirstOrDefaultAsync(f => f.Id == id, cancellationToken);
}
public async Task<List<Folder>> GetUserFoldersAsync(Guid userId, CancellationToken cancellationToken)
{
// В доменной модели Folder не имеет UserId напрямую (может быть общей сущностью),
// но по логике "папки в настройках пользователя" можно фильтровать через настройки.
// Пока возвращаем все папки, которыми владеет пользователь (если добавить UserId)
// или все для упрощения первой итерации.
return await _dbContext.Folders.ToListAsync(cancellationToken);
}
}
public sealed class UserChatSettingsRepository : IUserChatSettingsRepository
{
private readonly ChatsDbContext _dbContext;
public UserChatSettingsRepository(ChatsDbContext dbContext)
{
_dbContext = dbContext;
}
public void Add(UserChatSettings settings) => _dbContext.UserChatSettings.Add(settings);
public void Update(UserChatSettings settings) => _dbContext.UserChatSettings.Update(settings);
public void Remove(UserChatSettings settings) => _dbContext.UserChatSettings.Remove(settings);
public void RemoveRange(IEnumerable<UserChatSettings> settings) => _dbContext.UserChatSettings.RemoveRange(settings);
public async Task<UserChatSettings?> GetAsync(Guid userId, Guid chatId, CancellationToken cancellationToken)
{
return await _dbContext.UserChatSettings
.FirstOrDefaultAsync(s => s.UserId == userId && s.ChatId == chatId, cancellationToken);
}
public async Task<List<UserChatSettings>> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken)
{
return await _dbContext.UserChatSettings
.Where(s => s.UserId == userId)
.ToListAsync(cancellationToken);
}
}

View File

@@ -5,6 +5,11 @@ using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
using System.Linq;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
@@ -24,8 +29,8 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
}
public DbSet<Chat> Chats => Set<Chat>();
public DbSet<Folder> Folders => Set<Folder>();
public DbSet<UserChatSettings> UserChatSettings => Set<UserChatSettings>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -43,7 +48,6 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
builder.HasKey(c => c.Id);
builder.Property(c => c.Type).HasConversion<string>();
builder.OwnsMany(c => c.Members, mb =>
{
mb.ToTable("ChatMembers");
@@ -53,15 +57,32 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
}).Navigation(c => c.Members).UsePropertyAccessMode(PropertyAccessMode.Field);
});
modelBuilder.Entity<Folder>(builder =>
{
builder.ToTable("Folders");
builder.HasKey(f => f.Id);
builder.Property(f => f.Type).HasConversion<string>();
});
modelBuilder.Entity<UserChatSettings>(builder =>
{
builder.ToTable("UserChatSettings");
builder.HasKey(s => s.Id);
builder.HasIndex(s => new { s.UserId, s.ChatId }).IsUnique();
builder.Property(s => s.FolderIds)
.HasConversion(
v => string.Join(',', v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(Guid.Parse).ToList()
);
});
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 1. Получаем все события из агрегатов
var domainEvents = ChangeTracker
.Entries<IAggregateRoot>()
.SelectMany(x =>
{
if (x.Entity is AggregateRoot<Guid> root)
{
@@ -73,10 +94,8 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
})
.ToList();
// 2. Сохраняем изменения
int result = await base.SaveChangesAsync(cancellationToken);
// 3. Публикуем события через MediatR
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent, cancellationToken);
@@ -85,4 +104,3 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
return result;
}
}

View File

@@ -0,0 +1,25 @@
using Knot.Modules.Conversations.Domain;
using MongoDB.Bson.Serialization;
namespace Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
public static class ConversationsMongoDbMapConfigurator
{
private static bool _initialized;
public static void Configure()
{
if (_initialized) return;
if (!BsonClassMap.IsClassMapRegistered(typeof(UserFolderSettings)))
{
BsonClassMap.RegisterClassMap<UserFolderSettings>(cm =>
{
cm.AutoMap();
cm.SetDiscriminator("UserFolderSettings");
});
}
_initialized = true;
}
}

View File

@@ -0,0 +1,30 @@
using Knot.Modules.Conversations.Domain;
using MongoDB.Driver;
namespace Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
public sealed class UserFolderSettingsRepository : IUserFolderSettingsRepository
{
private readonly IMongoCollection<UserFolderSettings> _collection;
public UserFolderSettingsRepository(IMongoDatabase database)
{
_collection = database.GetCollection<UserFolderSettings>("user_folder_settings");
}
public async Task<UserFolderSettings?> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken)
{
return await _collection.Find(s => s.UserId == userId).FirstOrDefaultAsync(cancellationToken);
}
public async Task UpdateAsync(UserFolderSettings settings, CancellationToken cancellationToken)
{
var options = new ReplaceOptions { IsUpsert = true };
await _collection.ReplaceOneAsync(s => s.Id == settings.Id, settings, options, cancellationToken);
}
public async Task RemoveByUserIdAsync(Guid userId, CancellationToken cancellationToken)
{
await _collection.DeleteManyAsync(s => s.UserId == userId, cancellationToken);
}
}

View File

@@ -3,6 +3,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" />
</ItemGroup>
<ItemGroup>
@@ -35,4 +36,3 @@
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,155 @@
{
"$schema": "http://json.schemastore.org/sarif-1.0.0",
"version": "1.0.0",
"runs": [
{
"tool": {
"name": "Компилятор Microsoft (R) Visual C#",
"version": "5.0.0.0",
"fileVersion": "5.0.0-1.25358.103 (75972a5b)",
"semanticVersion": "5.0.0",
"language": "ru-RU"
},
"results": [
{
"ruleId": "CS0234",
"level": "error",
"message": "Тип или имя пространства имен \"Auth\" не существует в пространстве имен \"Knot.Modules\" (возможно, отсутствует ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs",
"region": {
"startLine": 1,
"startColumn": 20,
"endLine": 1,
"endColumn": 24
}
}
}
]
},
{
"ruleId": "CS0234",
"level": "error",
"message": "Тип или имя пространства имен \"Auth\" не существует в пространстве имен \"Knot.Modules\" (возможно, отсутствует ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs",
"region": {
"startLine": 9,
"startColumn": 20,
"endLine": 9,
"endColumn": 24
}
}
}
]
},
{
"ruleId": "CS0246",
"level": "error",
"message": "Не удалось найти тип или имя пространства имен \"IUserRepository\" (возможно, отсутствует директива using или ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs",
"region": {
"startLine": 17,
"startColumn": 22,
"endLine": 17,
"endColumn": 37
}
}
}
]
},
{
"ruleId": "CS0246",
"level": "error",
"message": "Не удалось найти тип или имя пространства имен \"IAuthUnitOfWork\" (возможно, отсутствует директива using или ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs",
"region": {
"startLine": 23,
"startColumn": 22,
"endLine": 23,
"endColumn": 37
}
}
}
]
},
{
"ruleId": "CS0246",
"level": "error",
"message": "Не удалось найти тип или имя пространства имен \"IUserRepository\" (возможно, отсутствует директива using или ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs",
"region": {
"startLine": 26,
"startColumn": 9,
"endLine": 26,
"endColumn": 24
}
}
}
]
},
{
"ruleId": "CS0246",
"level": "error",
"message": "Не удалось найти тип или имя пространства имен \"IAuthUnitOfWork\" (возможно, отсутствует директива using или ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs",
"region": {
"startLine": 32,
"startColumn": 9,
"endLine": 32,
"endColumn": 24
}
}
}
]
}
],
"rules": {
"CS0234": {
"id": "CS0234",
"defaultLevel": "error",
"helpUri": "https://msdn.microsoft.com/query/roslyn.query?appId=roslyn&k=k(CS0234)",
"properties": {
"category": "Compiler",
"isEnabledByDefault": true,
"tags": [
"Compiler",
"Telemetry",
"NotConfigurable"
]
}
},
"CS0246": {
"id": "CS0246",
"defaultLevel": "error",
"helpUri": "https://msdn.microsoft.com/query/roslyn.query?appId=roslyn&k=k(CS0246)",
"properties": {
"category": "Compiler",
"isEnabledByDefault": true,
"tags": [
"Compiler",
"Telemetry",
"NotConfigurable"
]
}
}
}
}
]
}

View File

@@ -0,0 +1,245 @@
{
"$schema": "http://json.schemastore.org/sarif-1.0.0",
"version": "1.0.0",
"runs": [
{
"tool": {
"name": "Компилятор Microsoft (R) Visual C#",
"version": "5.0.0.0",
"fileVersion": "5.0.0-1.25358.103 (75972a5b)",
"semanticVersion": "5.0.0",
"language": "ru-RU"
},
"results": [
{
"ruleId": "CS0234",
"level": "error",
"message": "Тип или имя пространства имен \"Auth\" не существует в пространстве имен \"Knot.Modules\" (возможно, отсутствует ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs",
"region": {
"startLine": 1,
"startColumn": 20,
"endLine": 1,
"endColumn": 24
}
}
}
]
},
{
"ruleId": "CS0246",
"level": "error",
"message": "Не удалось найти тип или имя пространства имен \"IChatsUnitOfWork\" (возможно, отсутствует директива using или ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Folders/Commands/AddToFolder/AddToFolderCommand.cs",
"region": {
"startLine": 12,
"startColumn": 22,
"endLine": 12,
"endColumn": 38
}
}
}
]
},
{
"ruleId": "CS0246",
"level": "error",
"message": "Не удалось найти тип или имя пространства имен \"IChatsUnitOfWork\" (возможно, отсутствует директива using или ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Folders/Commands/AddToFolder/AddToFolderCommand.cs",
"region": {
"startLine": 14,
"startColumn": 86,
"endLine": 14,
"endColumn": 102
}
}
}
]
},
{
"ruleId": "CS0246",
"level": "error",
"message": "Не удалось найти тип или имя пространства имен \"IChatsUnitOfWork\" (возможно, отсутствует директива using или ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Folders/Commands/RemoveFromFolder/RemoveFromFolderCommand.cs",
"region": {
"startLine": 12,
"startColumn": 22,
"endLine": 12,
"endColumn": 38
}
}
}
]
},
{
"ruleId": "CS0246",
"level": "error",
"message": "Не удалось найти тип или имя пространства имен \"IChatsUnitOfWork\" (возможно, отсутствует директива using или ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Folders/Commands/RemoveFromFolder/RemoveFromFolderCommand.cs",
"region": {
"startLine": 14,
"startColumn": 91,
"endLine": 14,
"endColumn": 107
}
}
}
]
},
{
"ruleId": "CS0246",
"level": "error",
"message": "Не удалось найти тип или имя пространства имен \"IUserRepository\" (возможно, отсутствует директива using или ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs",
"region": {
"startLine": 15,
"startColumn": 22,
"endLine": 15,
"endColumn": 37
}
}
}
]
},
{
"ruleId": "CS0246",
"level": "error",
"message": "Не удалось найти тип или имя пространства имен \"IChatsUnitOfWork\" (возможно, отсутствует директива using или ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs",
"region": {
"startLine": 20,
"startColumn": 22,
"endLine": 20,
"endColumn": 38
}
}
}
]
},
{
"ruleId": "CS0234",
"level": "error",
"message": "Тип или имя пространства имен \"Auth\" не существует в пространстве имен \"Knot.Modules\" (возможно, отсутствует ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs",
"region": {
"startLine": 21,
"startColumn": 35,
"endLine": 21,
"endColumn": 39
}
}
}
]
},
{
"ruleId": "CS0246",
"level": "error",
"message": "Не удалось найти тип или имя пространства имен \"IUserRepository\" (возможно, отсутствует директива using или ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs",
"region": {
"startLine": 24,
"startColumn": 9,
"endLine": 24,
"endColumn": 24
}
}
}
]
},
{
"ruleId": "CS0246",
"level": "error",
"message": "Не удалось найти тип или имя пространства имен \"IChatsUnitOfWork\" (возможно, отсутствует директива using или ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs",
"region": {
"startLine": 29,
"startColumn": 9,
"endLine": 29,
"endColumn": 25
}
}
}
]
},
{
"ruleId": "CS0234",
"level": "error",
"message": "Тип или имя пространства имен \"Auth\" не существует в пространстве имен \"Knot.Modules\" (возможно, отсутствует ссылка на сборку).",
"locations": [
{
"resultFile": {
"uri": "file:///E:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs",
"region": {
"startLine": 30,
"startColumn": 22,
"endLine": 30,
"endColumn": 26
}
}
}
]
}
],
"rules": {
"CS0234": {
"id": "CS0234",
"defaultLevel": "error",
"helpUri": "https://msdn.microsoft.com/query/roslyn.query?appId=roslyn&k=k(CS0234)",
"properties": {
"category": "Compiler",
"isEnabledByDefault": true,
"tags": [
"Compiler",
"Telemetry",
"NotConfigurable"
]
}
},
"CS0246": {
"id": "CS0246",
"defaultLevel": "error",
"helpUri": "https://msdn.microsoft.com/query/roslyn.query?appId=roslyn&k=k(CS0246)",
"properties": {
"category": "Compiler",
"isEnabledByDefault": true,
"tags": [
"Compiler",
"Telemetry",
"NotConfigurable"
]
}
}
}
}
]
}

View File

@@ -0,0 +1,38 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
namespace Knot.Modules.Federation.Application.Abstractions;
/// <summary>
/// Шлюз для безопасной передачи данных между серверами Knot Messager.
/// </summary>
public interface IFederationGateway
{
/// <summary>
/// Отправляет пакет на конкретный домен.
/// </summary>
Task<Result> SendPacketAsync(FederationMessagePacket packet, string targetDomain, CancellationToken ct = default);
}
/// <summary>
/// Федеративный пакет сообщения с гибридным шифрованием.
/// </summary>
public record FederationMessagePacket(
string SenderDomain,
string RecipientDomain,
string EncryptedPayload, // AES-256
string EncryptedIV, // RSA-Encrypted for Recipient
string Signature, // RSA-Signed by Sender
FederationMetadata Metadata
);
public record FederationMetadata(
Guid ChatId,
Guid SenderId,
string SenderUsername,
string MessageType,
DateTime CreatedAt,
Guid UserId = default // ID пользователя-исполнителя (для реакций и т.д.)
);

View File

@@ -1,54 +1,66 @@
using Knot.Modules.Stories.Domain;
using Knot.Shared.Kernel.Constants;
using System.Security.Cryptography;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Shared.Kernel.Constants;
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
namespace Host.Application.Federation.Commands;
public record HandshakeRequest(string Domain, string PublicKey);
public record HandshakeResponse(string Domain, string PublicKey, string Status);
public record HandshakeRequest(string Domain, string PublicKey, RemoteCapabilities Capabilities);
public record HandshakeResponse(string Domain, string PublicKey, RemoteCapabilities Capabilities, string Status);
public record HandshakeFederationCommand(HandshakeRequest Request) : ICommand<HandshakeResponse>;
internal sealed class HandshakeFederationCommandHandler : ICommandHandler<HandshakeFederationCommand, HandshakeResponse>
{
private readonly ISettingsService _settings;
private readonly ISettingsService _settingsService;
public HandshakeFederationCommandHandler(ISettingsService settings)
public HandshakeFederationCommandHandler(ISettingsService settingsService)
{
_settings = settings;
_settingsService = settingsService;
}
public Task<Result<HandshakeResponse>> Handle(HandshakeFederationCommand request, CancellationToken cancellationToken)
public async Task<Result<HandshakeResponse>> Handle(HandshakeFederationCommand request, CancellationToken cancellationToken)
{
var conf = _settings.Current;
if (!conf.EnableConfederation)
return Task.FromResult(Result.Failure<HandshakeResponse>(new Error(Errors.DisabledByAdmin, "Federation is disabled")));
var allSettings = await _settingsService.GetSettingsAsync(cancellationToken);
var conf = allSettings.Federation;
if (!conf.Enabled)
return Result.Failure<HandshakeResponse>(new Error(Errors.DisabledByAdmin, "Federation is disabled or keys not generated."));
if (string.IsNullOrEmpty(request.Request.Domain) || string.IsNullOrEmpty(request.Request.PublicKey))
return Task.FromResult(Result.Failure<HandshakeResponse>(DomainErrors.RequestInvalid));
return Result.Failure<HandshakeResponse>(new Error("Request.Invalid", "Domain and PublicKey required."));
var allowedList = conf.AllowedDomains?.Select(d => d.Trim().ToLower()).ToList() ?? new System.Collections.Generic.List<string>();
if (!allowedList.Contains(request.Request.Domain.ToLowerInvariant()))
return Task.FromResult(Result.Failure<HandshakeResponse>(StoryErrors.Unauthorized));
// Проверка разрешенности домена (белый список)
var allowedList = conf.AllowedDomains ?? new List<FederationDomainConfig>();
var domainConfig = allowedList.FirstOrDefault(d => d.IsEnabled && d.Domain.Equals(request.Request.Domain, StringComparison.OrdinalIgnoreCase));
if (domainConfig == null)
return Result.Failure<HandshakeResponse>(new Error("Federation.DomainNotAllowed", "Your domain is not in our allowlist."));
using var rsa = RSA.Create(2048);
var selfPublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey());
// Сохраняем полученные данные о партнере (Capabilities и Ключ) в кэш настроек
domainConfig.PublicKey = request.Request.PublicKey;
domainConfig.Capabilities = request.Request.Capabilities;
await _settingsService.UpdateSettingsAsync(allSettings, cancellationToken);
// Наш ответ с нашими возможностями
var ourCapabilities = new RemoteCapabilities
{
AllowMedia = allSettings.Messages.AllowMedia,
AllowPolls = allSettings.Messages.AllowPolls,
AllowVoiceMessages = allSettings.Messages.AllowVoiceMessages,
AllowVideoCalls = allSettings.WebRtc.EnableVideoCalls,
AllowScreenSharing = allSettings.WebRtc.EnableScreenSharing
};
var response = new HandshakeResponse(
Environment.GetEnvironmentVariable("DOMAIN") ?? "knot.local",
selfPublicKey,
allSettings.System.DomainUrl,
conf.PublicKey ?? string.Empty,
ourCapabilities,
"Accepted"
);
return Task.FromResult(Result.Success(response));
return Result.Success(response);
}
}

View File

@@ -0,0 +1,206 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Domain;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using Knot.Shared.Kernel.Configuration;
using Knot.Shared.Kernel.Constants;
namespace Host.Application.Federation.Commands;
public record InboundFederationCommand(FederationMessagePacket Packet) : ICommand;
internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundFederationCommand>
{
private readonly ISettingsService _settingsService;
private readonly IMessageRepository _messageRepository;
private readonly IUserRepository _userRepository;
private readonly IMessageReactionRepository _reactionRepository;
private readonly IMessageNotifier _notifier;
private readonly IMediator _mediator;
public InboundFederationCommandHandler(
ISettingsService settingsService,
IMessageRepository messageRepository,
IUserRepository userRepository,
IMessageReactionRepository reactionRepository,
IMessageNotifier notifier,
IMediator mediator)
{
_settingsService = settingsService;
_messageRepository = messageRepository;
_userRepository = userRepository;
_reactionRepository = reactionRepository;
_notifier = notifier;
_mediator = mediator;
}
public async Task<Result> Handle(InboundFederationCommand request, CancellationToken cancellationToken)
{
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
if (!settings.Federation.Enabled)
return Result.Failure(new Error(Errors.DisabledByAdmin, "Federation disabled."));
var packet = request.Packet;
// 1. Проверяем подпись отправителя
var senderConfig = settings.Federation.AllowedDomains.FirstOrDefault(d => d.IsEnabled && d.Domain.Equals(packet.SenderDomain, StringComparison.OrdinalIgnoreCase));
if (senderConfig == null || string.IsNullOrEmpty(senderConfig.PublicKey))
return Result.Failure(new Error("Federation.SenderNotAllowed", "Sender domain not in allowlist."));
using (var rsaVerify = RSA.Create())
{
rsaVerify.ImportRSAPublicKey(Convert.FromBase64String(senderConfig.PublicKey), out _);
var dataToVerify = Encoding.UTF8.GetBytes(packet.EncryptedPayload + packet.SenderDomain + packet.RecipientDomain);
if (!rsaVerify.VerifyData(dataToVerify, Convert.FromBase64String(packet.Signature), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
{
return Result.Failure(new Error("Federation.InvalidSignature", "RSA Signature verification failed."));
}
}
// 2. Расшифровываем IV своим Private Key
byte[] aesKey;
byte[] aesIv;
using (var rsaDecrypt = RSA.Create())
{
rsaDecrypt.ImportPkcs8PrivateKey(Convert.FromBase64String(settings.Federation.PrivateKey!), out _);
var decryptedKeys = rsaDecrypt.Decrypt(Convert.FromBase64String(packet.EncryptedIV), RSAEncryptionPadding.Pkcs1);
// AES-256 Key (32 bytes) + IV (16 bytes)
aesKey = new byte[32];
aesIv = new byte[16];
Buffer.BlockCopy(decryptedKeys, 0, aesKey, 0, 32);
Buffer.BlockCopy(decryptedKeys, 32, aesIv, 0, 16);
}
// 3. Расшифровываем само сообщение (AES-256)
string plainText;
using (var aes = Aes.Create())
{
aes.Key = aesKey;
aes.IV = aesIv;
using (var decryptor = aes.CreateDecryptor())
{
var cipherBytes = Convert.FromBase64String(packet.EncryptedPayload);
var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
plainText = Encoding.UTF8.GetString(plainBytes);
}
}
// 4. Логика обработки типов сообщений
if (packet.Metadata.MessageType == "sync_capabilities")
{
var capabilities = JsonSerializer.Deserialize<RemoteCapabilities>(plainText);
if (capabilities != null && senderConfig != null)
{
senderConfig.Capabilities = capabilities;
await _settingsService.UpdateSettingsAsync(settings, cancellationToken);
}
return Result.Success();
}
if (packet.Metadata.MessageType == "presence_update")
{
var statusData = JsonSerializer.Deserialize<JsonElement>(plainText);
var isOnline = statusData.GetProperty("IsOnline").GetBoolean();
var user = await _userRepository.GetByIdAsync(packet.Metadata.SenderId, cancellationToken);
if (user != null && user.IsExternal)
{
user.UpdateStatus(isOnline);
_userRepository.Update(user);
// Уведомляем локальных пользователей через SignalR
await _notifier.NotifyNewMessageAsync(Guid.Empty, new { type = "presence_update", userId = user.Id, isOnline = user.IsOnline }, cancellationToken);
}
return Result.Success();
}
if (packet.Metadata.MessageType == "message_edited")
{
var messageToEdit = await _messageRepository.GetByIdAsync(packet.Metadata.SenderId, cancellationToken); // В метаданных MessageId
if (messageToEdit != null)
{
messageToEdit.Edit(plainText);
await _messageRepository.UpdateAsync(messageToEdit, cancellationToken);
await _notifier.NotifyNewMessageAsync(messageToEdit.ChatId, new { type = "message_edited", messageId = messageToEdit.Id, content = plainText }, cancellationToken);
}
return Result.Success();
}
if (packet.Metadata.MessageType == "message_deleted")
{
var messageToDelete = await _messageRepository.GetByIdAsync(packet.Metadata.SenderId, cancellationToken);
if (messageToDelete != null)
{
messageToDelete.Delete();
await _messageRepository.UpdateAsync(messageToDelete, cancellationToken);
await _notifier.NotifyNewMessageAsync(messageToDelete.ChatId, new { type = "message_deleted", messageId = messageToDelete.Id }, cancellationToken);
}
return Result.Success();
}
if (packet.Metadata.MessageType == "reaction_added" || packet.Metadata.MessageType == "reaction_removed")
{
var messageForReaction = await _messageRepository.GetByIdAsync(packet.Metadata.SenderId, cancellationToken);
if (messageForReaction != null)
{
if (packet.Metadata.MessageType == "reaction_added")
{
var reaction = new MessageReaction(messageForReaction.Id, packet.Metadata.UserId, plainText);
await _reactionRepository.AddAsync(reaction, cancellationToken);
}
else
{
await _reactionRepository.RemoveAsync(messageForReaction.Id, packet.Metadata.UserId, plainText, cancellationToken);
}
await _notifier.NotifyNewMessageAsync(messageForReaction.ChatId, new {
type = packet.Metadata.MessageType,
messageId = messageForReaction.Id,
userId = packet.Metadata.UserId,
emoji = plainText
}, cancellationToken);
}
return Result.Success();
}
if (packet.Metadata.MessageType == "rtc_signal")
{
// Здесь проброс WebRTC сигнала (Offer/Answer/ICE) конечному пользователю через SignalR
await _notifier.NotifyNewMessageAsync(packet.Metadata.ChatId, new { type = "rtc_signal", payload = plainText, senderId = packet.Metadata.SenderId }, cancellationToken);
return Result.Success();
}
if (packet.Metadata.MessageType == "poll" && !settings.Messages.AllowPolls)
return Result.Failure(new Error("Federation.PollsDisabled", "We do not accept polls."));
// 5. Сохранение в базу сообщений (MongoDB)
// В реальном приложении здесь будет маппинг на TextMessage, MediaMessage и т.д.
var message = new TextMessage(
Guid.NewGuid(),
packet.Metadata.ChatId,
packet.Metadata.SenderId,
plainText,
null, // replyToId
null, // quote
null, // forwardedFromId
packet.Metadata.CreatedAt,
false); // isImported
_messageRepository.Add(message);
// 6. Уведомление пользователя через SignalR
await _notifier.NotifyNewMessageAsync(packet.Metadata.ChatId, message, cancellationToken);
return Result.Success();
}
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Domain;
using Knot.Shared.Kernel.Configuration;
using System.Linq;
namespace Host.Application.Federation.Commands;
public record ResolveUserResponse(Guid Id, string Username, string DisplayName, string? Avatar);
public record ResolveUserCommand(string Username) : ICommand<ResolveUserResponse>;
internal sealed class ResolveUserCommandHandler : ICommandHandler<ResolveUserCommand, ResolveUserResponse>
{
private readonly IUserRepository _userRepository;
public ResolveUserCommandHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<Result<ResolveUserResponse>> Handle(ResolveUserCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
if (user == null || user.IsExternal)
{
return Result.Failure<ResolveUserResponse>(new Error("Federation.UserNotFound", "User not found on this server."));
}
return Result.Success(new ResolveUserResponse(
user.Id,
user.Username,
user.DisplayName,
user.Avatar
));
}
}

View File

@@ -0,0 +1,97 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Federation.Application.Federation.Services;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Auth.Domain;
namespace Knot.Modules.Federation.Application.Federation.Events;
/// <summary>
/// Универсальный обработчик действий с сообщениями (Edit, Delete, Reactions) для Федерации.
/// </summary>
public sealed class FederatedMessageActionsHandler :
INotificationHandler<MessageEditedDomainEvent>,
INotificationHandler<MessageDeletedDomainEvent>,
INotificationHandler<MessageReactionAddedDomainEvent>,
INotificationHandler<MessageReactionRemovedDomainEvent>
{
private readonly IChatRepository _chatRepository;
private readonly IUserRepository _userRepository;
private readonly ISettingsService _settingsService;
private readonly FederationPacketService _packetService;
private readonly IFederationGateway _gateway;
public FederatedMessageActionsHandler(
IChatRepository chatRepository,
IUserRepository userRepository,
ISettingsService settingsService,
FederationPacketService packetService,
IFederationGateway gateway)
{
_chatRepository = chatRepository;
_userRepository = userRepository;
_settingsService = settingsService;
_packetService = packetService;
_gateway = gateway;
}
public async Task Handle(MessageEditedDomainEvent notification, CancellationToken cancellationToken)
=> await ProcessAction(notification.ChatId, notification.MessageId, "message_edited", notification.NewContent, cancellationToken);
public async Task Handle(MessageDeletedDomainEvent notification, CancellationToken cancellationToken)
=> await ProcessAction(notification.ChatId, notification.MessageId, "message_deleted", string.Empty, cancellationToken);
public async Task Handle(MessageReactionAddedDomainEvent notification, CancellationToken cancellationToken)
=> await ProcessAction(notification.ChatId, notification.MessageId, "reaction_added", notification.Emoji, cancellationToken, notification.UserId);
public async Task Handle(MessageReactionRemovedDomainEvent notification, CancellationToken cancellationToken)
=> await ProcessAction(notification.ChatId, notification.MessageId, "reaction_removed", notification.Emoji, cancellationToken, notification.UserId);
private async Task ProcessAction(Guid chatId, Guid messageId, string actionType, string payload, CancellationToken cancellationToken, Guid actorId = default)
{
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
if (!settings.Federation.Enabled) return;
var chat = await _chatRepository.GetByIdAsync(chatId, cancellationToken);
if (chat == null) return;
var externalDomains = await GetExternalDomains(chat, cancellationToken);
if (!externalDomains.Any()) return;
var metadata = new FederationMetadata(
chatId,
messageId, // MessageId в SenderId для операций
"system",
actionType,
DateTime.UtcNow,
actorId
);
foreach (var domain in externalDomains)
{
var packetResult = await _packetService.PreparePacketAsync(payload, metadata, domain, cancellationToken);
if (packetResult.IsSuccess)
{
await _gateway.SendPacketAsync(packetResult.Value, domain, cancellationToken);
}
}
}
private async Task<List<string>> GetExternalDomains(Chat chat, CancellationToken cancellationToken)
{
var memberIds = chat.Members.Select(m => m.UserId).ToList();
var members = await _userRepository.GetByIdsAsync(memberIds, cancellationToken);
return members
.Where(u => u.IsExternal && !string.IsNullOrEmpty(u.Domain))
.Select(u => u.Domain!)
.Distinct()
.ToList();
}
}

View File

@@ -0,0 +1,90 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Federation.Application.Federation.Services;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Auth.Domain;
namespace Knot.Modules.Federation.Application.Federation.Events;
/// <summary>
/// Обработчик доменного события отправки сообщения.
/// Если в чате есть внешние участники — инициирует федеративную рассылку.
/// </summary>
public sealed class MessageSentDomainEventHandler : INotificationHandler<MessageSentDomainEvent>
{
private readonly IChatRepository _chatRepository;
private readonly IUserRepository _userRepository;
private readonly ISettingsService _settingsService;
private readonly FederationPacketService _packetService;
private readonly IFederationGateway _gateway;
public MessageSentDomainEventHandler(
IChatRepository chatRepository,
IUserRepository userRepository,
ISettingsService settingsService,
FederationPacketService packetService,
IFederationGateway gateway)
{
_chatRepository = chatRepository;
_userRepository = userRepository;
_settingsService = settingsService;
_packetService = packetService;
_gateway = gateway;
}
public async Task Handle(MessageSentDomainEvent notification, CancellationToken cancellationToken)
{
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
if (!settings.Federation.Enabled) return;
// 1. Загружаем чат и проверяем наличие внешних участников
var chat = await _chatRepository.GetByIdAsync(notification.ChatId, cancellationToken);
if (chat == null) return;
var memberIds = chat.Members.Select(m => m.UserId).ToList();
var members = await _userRepository.GetByIdsAsync(memberIds, cancellationToken);
// Находим уникальные домены внешних участников
var externalDomains = members
.Where(u => u.IsExternal && !string.IsNullOrEmpty(u.Domain))
.Select(u => u.Domain!)
.Distinct()
.ToList();
if (!externalDomains.Any()) return;
// 2. Получаем имя отправителя для метаданных
var sender = await _userRepository.GetByIdAsync(notification.SenderId, cancellationToken);
var senderUsername = sender?.Username ?? "unknown";
var metadata = new FederationMetadata(
notification.ChatId,
notification.SenderId,
senderUsername,
"text", // Для начала поддерживаем только текст через ивент
DateTime.UtcNow
);
// 3. Рассылка по доменам (Fan-out)
foreach (var domain in externalDomains)
{
var packetResult = await _packetService.PreparePacketAsync(
notification.Content ?? string.Empty,
metadata,
domain,
cancellationToken);
if (packetResult.IsSuccess)
{
// Отправляем асинхронно
await _gateway.SendPacketAsync(packetResult.Value, domain, cancellationToken);
}
}
}
}

View File

@@ -0,0 +1,71 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Modules.Admin.Domain.Events;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Modules.Federation.Application.Federation.Services;
using Knot.Shared.Kernel.Configuration;
namespace Knot.Modules.Federation.Application.Federation.Events;
/// <summary>
/// Обработчик события обновления настроек.
/// Уведомляет все удаленные серверы о смене способностей (Capabilities).
/// </summary>
public sealed class SystemSettingsUpdatedDomainEventHandler : INotificationHandler<SystemSettingsUpdatedDomainEvent>
{
private readonly ISettingsService _settingsService;
private readonly IFederationGateway _gateway;
private readonly FederationPacketService _packetService;
public SystemSettingsUpdatedDomainEventHandler(
ISettingsService settingsService,
IFederationGateway gateway,
FederationPacketService packetService)
{
_settingsService = settingsService;
_gateway = gateway;
_packetService = packetService;
}
public async Task Handle(SystemSettingsUpdatedDomainEvent notification, CancellationToken cancellationToken)
{
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
if (!settings.Federation.Enabled) return;
// Наши новые способности
var ourCapabilities = new RemoteCapabilities
{
AllowMedia = settings.Messages.AllowMedia,
AllowPolls = settings.Messages.AllowPolls,
AllowVoiceMessages = settings.Messages.AllowVoiceMessages,
AllowVideoCalls = settings.WebRtc.EnableVideoCalls,
AllowScreenSharing = settings.WebRtc.EnableScreenSharing
};
// Собираем метаданные для синхронизации
var metadata = new FederationMetadata(
Guid.Empty, // Системный чат
Guid.Empty, // Системный пользователь
"SYSTEM",
"sync_capabilities",
DateTime.UtcNow
);
// Рассылка по всем доменам из белого списка (Fan-out)
foreach (var domainConfig in settings.Federation.AllowedDomains.Where(d => d.IsEnabled))
{
// Упаковываем Capabilities в JSON для передачи в payload
var payload = System.Text.Json.JsonSerializer.Serialize(ourCapabilities);
var packetResult = await _packetService.PreparePacketAsync(payload, metadata, domainConfig.Domain, cancellationToken);
if (packetResult.IsSuccess)
{
await _gateway.SendPacketAsync(packetResult.Value, domainConfig.Domain, cancellationToken);
}
}
}
}

View File

@@ -0,0 +1,86 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Modules.Auth.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Modules.Federation.Application.Federation.Services;
using Knot.Shared.Kernel.Configuration;
namespace Knot.Modules.Federation.Application.Federation.Events;
/// <summary>
/// Обработчик изменения статуса пользователя.
/// Рассылает новый статус всем серверам, где у пользователя есть чаты.
/// </summary>
public sealed class UserStatusChangedDomainEventHandler : INotificationHandler<UserStatusChangedDomainEvent>
{
private readonly IChatRepository _chatRepository;
private readonly IUserRepository _userRepository;
private readonly ISettingsService _settingsService;
private readonly FederationPacketService _packetService;
private readonly IFederationGateway _gateway;
public UserStatusChangedDomainEventHandler(
IChatRepository chatRepository,
IUserRepository userRepository,
ISettingsService settingsService,
FederationPacketService packetService,
IFederationGateway gateway)
{
_chatRepository = chatRepository;
_userRepository = userRepository;
_settingsService = settingsService;
_packetService = packetService;
_gateway = gateway;
}
public async Task Handle(UserStatusChangedDomainEvent notification, CancellationToken cancellationToken)
{
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
if (!settings.Federation.Enabled) return;
// 1. Находим все чаты пользователя
var userChats = await _chatRepository.GetUserChatsAsync(notification.UserId, cancellationToken);
if (!userChats.Any()) return;
// 2. Определяем уникальные внешние домены участников этих чатов
var allMemberIds = userChats.SelectMany(c => c.Members.Select(m => m.UserId)).Distinct().ToList();
var members = await _userRepository.GetByIdsAsync(allMemberIds, cancellationToken);
var externalDomains = members
.Where(u => u.IsExternal && !string.IsNullOrEmpty(u.Domain))
.Select(u => u.Domain!)
.Distinct()
.ToList();
if (!externalDomains.Any()) return;
// 3. Формируем пакет статуса
var statusData = new {
notification.IsOnline,
notification.LastSeen
};
var payload = System.Text.Json.JsonSerializer.Serialize(statusData);
var metadata = new FederationMetadata(
Guid.Empty,
notification.UserId,
"system", // Username отправителя здесь не важен, важен SenderId
"presence_update",
DateTime.UtcNow
);
// 4. Рассылка по доменам
foreach (var domain in externalDomains)
{
var packetResult = await _packetService.PreparePacketAsync(payload, metadata, domain, cancellationToken);
if (packetResult.IsSuccess)
{
await _gateway.SendPacketAsync(packetResult.Value, domain, cancellationToken);
}
}
}
}

View File

@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Shared.Kernel.Security;
namespace Knot.Modules.Federation.Application.Federation.Services;
/// <summary>
/// Сервис для подготовки и рассылки зашифрованных федератовных пакетов.
/// </summary>
public sealed class FederationPacketService
{
private readonly ISettingsService _settingsService;
private readonly IFederationGateway _gateway;
private readonly IEncryptionService _encryption;
public FederationPacketService(
ISettingsService settingsService,
IFederationGateway gateway,
IEncryptionService encryption)
{
_settingsService = settingsService;
_gateway = gateway;
_encryption = encryption;
}
/// <summary>
/// Собирает зашифрованный пакет для целевого домена.
/// </summary>
public async Task<Result<FederationMessagePacket>> PreparePacketAsync(
string messageBody,
FederationMetadata metadata,
string targetDomain,
CancellationToken ct = default)
{
var settings = await _settingsService.GetSettingsAsync(ct);
var ourDomain = settings.System.DomainUrl;
var ourPrivateKeyBase64 = settings.Federation.PrivateKey;
// Находим ключ получателя в белом списке
var targetConfig = settings.Federation.AllowedDomains.FirstOrDefault(d => d.Domain.Equals(targetDomain, StringComparison.OrdinalIgnoreCase));
if (targetConfig == null || string.IsNullOrEmpty(targetConfig.PublicKey))
{
return Result.Failure<FederationMessagePacket>(new Error("Federation.KeyNotFound", "Public key for target domain not found."));
}
// 1. AES Шифрование тела
// Используем IEncryptionService для получения потока или метода шифрования (в реальности здесь будет AES-256)
// Для демонстрации представим, что мы получили IV и EncryptedPayload
using var aes = Aes.Create();
aes.KeySize = 256;
aes.GenerateKey();
aes.GenerateIV();
var iv = aes.IV;
var key = aes.Key; // В гибридной схеме мы передаем IV + Key (или только IV при общем ключе)
byte[] encryptedBytes;
using (var encryptor = aes.CreateEncryptor())
{
var plainBytes = Encoding.UTF8.GetBytes(messageBody);
encryptedBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
}
// 2. RSA упаковка ключей на Open Public Key получателя
byte[] encryptedKeys;
using (var rsa = RSA.Create())
{
rsa.ImportRSAPublicKey(Convert.FromBase64String(targetConfig.PublicKey), out _);
// Шифруем Key + IV (48 байт)
var combinedKeys = new byte[key.Length + iv.Length];
Buffer.BlockCopy(key, 0, combinedKeys, 0, key.Length);
Buffer.BlockCopy(iv, 0, combinedKeys, key.Length, iv.Length);
encryptedKeys = rsa.Encrypt(combinedKeys, RSAEncryptionPadding.Pkcs1);
}
// 3. Подпись всего пакета нашим Private Key
string signature;
using (var rsaSign = RSA.Create())
{
rsaSign.ImportPkcs8PrivateKey(Convert.FromBase64String(ourPrivateKeyBase64!), out _);
var dataToSign = Encoding.UTF8.GetBytes(Convert.ToBase64String(encryptedBytes) + ourDomain + targetDomain);
var sigBytes = rsaSign.SignData(dataToSign, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
signature = Convert.ToBase64String(sigBytes);
}
var packet = new FederationMessagePacket(
ourDomain,
targetDomain,
Convert.ToBase64String(encryptedBytes),
Convert.ToBase64String(encryptedKeys),
signature,
metadata
);
return Result.Success(packet);
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Shared.Kernel.Security;
namespace Knot.Modules.Federation.Infrastructure.Services;
/// <summary>
/// Реализация шлюза федерации с гибридным шифрованием (AES-RSA).
/// </summary>
public sealed class FederationGateway : IFederationGateway
{
private readonly ISettingsService _settingsService;
private readonly HttpClient _httpClient;
public FederationGateway(ISettingsService settingsService, HttpClient httpClient)
{
_settingsService = settingsService;
_httpClient = httpClient;
}
public async Task<Result> SendPacketAsync(FederationMessagePacket packet, string targetDomain, CancellationToken ct = default)
{
try
{
var url = $"{targetDomain.TrimEnd('/')}/api/federation/v1/inbound";
// 1. Формируем тело запроса
var content = JsonContent.Create(packet);
// 2. Добавляем подпись в заголовки (как доп. уровень верификации)
_httpClient.DefaultRequestHeaders.Remove("X-Knot-Signature");
_httpClient.DefaultRequestHeaders.Add("X-Knot-Signature", packet.Signature);
var response = await _httpClient.PostAsync(url, content, ct);
if (response.IsSuccessStatusCode)
{
return Result.Success();
}
var errorMsg = await response.Content.ReadAsStringAsync(ct);
return Result.Failure(new Error("Federation.DeliveryFailed", $"Target server returned: {response.StatusCode}. {errorMsg}"));
}
catch (Exception ex)
{
return Result.Failure(new Error("Federation.NetworkError", ex.Message));
}
}
}

View File

@@ -6,11 +6,15 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using System;
using Host.Application.Federation.Commands;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Shared.Kernel.Storage;
using Knot.Shared.Kernel.Configuration;
namespace Host.Endpoints;
/// <summary>
/// Регистрация эндпоинтов федерации.
/// Эндпоинты федерации для межсерверного взаимодействия.
/// </summary>
public sealed class FederationEndpoints : ICarterModule
{
@@ -18,24 +22,45 @@ public sealed class FederationEndpoints : ICarterModule
{
var group = app.MapGroup(Routes.ApiFederation);
group.MapPost("/handshake", async ([FromBody] Host.Application.Federation.Commands.HandshakeRequest request, ISender sender, CancellationToken ct) =>
group.MapPost("/handshake", async ([FromBody] HandshakeRequest request, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.Federation.Commands.HandshakeFederationCommand(request), ct);
if (result.IsSuccess)
{
return Results.Ok(result.Value);
}
var result = await sender.Send(new HandshakeFederationCommand(request), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
});
if (result.Error.Code == "Unauthorized")
{
return Results.Forbid();
}
if (result.Error.Code == Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin)
{
return Results.StatusCode(503);
}
group.MapPost("/inbound", async ([FromBody] FederationMessagePacket packet, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new InboundFederationCommand(packet), ct);
return result.IsSuccess ? Results.Accepted() : Results.BadRequest(new { error = result.Error.Description });
});
group.MapGet("/resolve/{username}", async (string username, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new ResolveUserCommand(username), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(new { error = result.Error.Description });
});
group.MapGet("/proxy/{id}", async (string id, [FromHeader(Name = "X-Knot-Signature")] string signature, [FromHeader(Name = "X-Knot-Domain")] string senderDomain, IFileStorageService storage, ISettingsService settingsService, CancellationToken ct) =>
{
// 1. Валидация подписи (упрощенно)
var settings = await settingsService.GetSettingsAsync(ct);
var senderConfig = settings.Federation.AllowedDomains.FirstOrDefault(d => d.Domain.Equals(senderDomain, StringComparison.OrdinalIgnoreCase));
return Results.BadRequest(new { error = result.Error.Description });
if (senderConfig == null || string.IsNullOrEmpty(senderConfig.PublicKey))
return Results.Forbid();
// В реальном коде здесь проверка RSA подписи параметров запроса (URL + Domain)
// 2. Стриминг файла первоисточника
try
{
var (stream, contentType, fileName) = await storage.DownloadFileAsync(id);
return Results.File(stream, contentType, fileName, enableRangeProcessing: true);
}
catch
{
return Results.NotFound();
}
});
}
}

View File

@@ -16,11 +16,11 @@ public record GetTrendingGifsQuery : IQuery<JsonElement?>;
internal sealed class GetTrendingGifsQueryHandler : IQueryHandler<GetTrendingGifsQuery, JsonElement?>
{
private readonly ISettingsService _settings;
private readonly IKlipySettings _settings;
private readonly IMemoryCache _cache;
private readonly IHttpClientFactory _httpClientFactory;
public GetTrendingGifsQueryHandler(ISettingsService settings, IMemoryCache cache, IHttpClientFactory httpClientFactory)
public GetTrendingGifsQueryHandler(IKlipySettings settings, IMemoryCache cache, IHttpClientFactory httpClientFactory)
{
_settings = settings;
_cache = cache;
@@ -30,24 +30,24 @@ internal sealed class GetTrendingGifsQueryHandler : IQueryHandler<GetTrendingGif
public async Task<Result<JsonElement?>> Handle(GetTrendingGifsQuery request, CancellationToken cancellationToken)
{
var conf = _settings.Current;
if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey))
if (!conf.Enabled || string.IsNullOrEmpty(conf.ApiKey))
return Result.Failure<JsonElement?>(new Error(Errors.KlipyNotConfigured, "Klipy is not configured"));
var cacheKeyTrending = $"klipy_trending_{conf.KlipyApiKey}";
var cacheKeyTrending = $"klipy_trending_{conf.ApiKey}";
if (_cache.TryGetValue(cacheKeyTrending, out JsonElement cachedResult))
return Result.Success<JsonElement?>(cachedResult);
var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId : conf.KlipyCustomerId;
var customerId = Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId;
var client = _httpClientFactory.CreateClient();
var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, "", customerId);
var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, "", customerId);
var response = await client.GetAsync(urlCo, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, "", customerId);
var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, "", customerId);
response = await client.GetAsync(urlCom, cancellationToken);
}

View File

@@ -16,11 +16,11 @@ public record SearchGifsQuery(string Query) : IQuery<JsonElement?>;
internal sealed class SearchGifsQueryHandler : IQueryHandler<SearchGifsQuery, JsonElement?>
{
private readonly ISettingsService _settings;
private readonly IKlipySettings _settings;
private readonly IMemoryCache _cache;
private readonly IHttpClientFactory _httpClientFactory;
public SearchGifsQueryHandler(ISettingsService settings, IMemoryCache cache, IHttpClientFactory httpClientFactory)
public SearchGifsQueryHandler(IKlipySettings settings, IMemoryCache cache, IHttpClientFactory httpClientFactory)
{
_settings = settings;
_cache = cache;
@@ -30,28 +30,28 @@ internal sealed class SearchGifsQueryHandler : IQueryHandler<SearchGifsQuery, Js
public async Task<Result<JsonElement?>> Handle(SearchGifsQuery request, CancellationToken cancellationToken)
{
var conf = _settings.Current;
if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey))
if (!conf.Enabled || string.IsNullOrEmpty(conf.ApiKey))
return Result.Failure<JsonElement?>(new Error(Errors.KlipyNotConfigured, "Klipy is not configured"));
if (string.IsNullOrWhiteSpace(request.Query))
return Result.Failure<JsonElement?>(new Error(Errors.InvalidQuery, "Invalid query parameter"));
var cacheKey = $"klipy_search_{conf.KlipyApiKey}_{request.Query.ToLowerInvariant()}";
var cacheKey = $"klipy_search_{conf.ApiKey}_{request.Query.ToLowerInvariant()}";
if (_cache.TryGetValue(cacheKey, out JsonElement cachedResult))
return Result.Success<JsonElement?>(cachedResult);
var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId : conf.KlipyCustomerId;
var customerId = Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId;
var client = _httpClientFactory.CreateClient();
var queryParam = $"q={Uri.EscapeDataString(request.Query)}";
var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceSearch, queryParam, customerId);
var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceSearch, queryParam, customerId);
var response = await client.GetAsync(urlCo, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceSearch, queryParam, customerId);
var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceSearch, queryParam, customerId);
response = await client.GetAsync(urlCom, cancellationToken);
}

View File

@@ -0,0 +1,24 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.Domain;
/// <summary>
/// Доменное событие: сообщение отредактировано.
/// </summary>
public sealed record MessageEditedDomainEvent(Guid MessageId, Guid ChatId, string NewContent) : IDomainEvent;
/// <summary>
/// Доменное событие: сообщение удалено.
/// </summary>
public sealed record MessageDeletedDomainEvent(Guid MessageId, Guid ChatId) : IDomainEvent;
/// <summary>
/// Доменное событие: добавлена реакция.
/// </summary>
public sealed record MessageReactionAddedDomainEvent(Guid MessageId, Guid ChatId, Guid UserId, string Emoji) : IDomainEvent;
/// <summary>
/// Доменное событие: удалена реакция.
/// </summary>
public sealed record MessageReactionRemovedDomainEvent(Guid MessageId, Guid ChatId, Guid UserId, string Emoji) : IDomainEvent;

View File

@@ -14,6 +14,10 @@ public interface IMessageRepository
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
Task UpdateAsync(Message message, CancellationToken cancellationToken);
Task<List<Message>> GetAllMessagesAsync(CancellationToken cancellationToken);
Task DeleteChatMessagesAsync(Guid chatId, CancellationToken cancellationToken);
Task DeleteUserMessagesAsync(Guid userId, CancellationToken cancellationToken);
Task RemoveAsync(Guid id, CancellationToken cancellationToken);
}

View File

@@ -56,10 +56,9 @@ public class MediaMessage : Message
}
}
public void Edit(string newCaption)
public override void Edit(string newCaption)
{
Caption = newCaption;
AddState(MessageState.IsEdited);
base.Edit(newCaption);
}
public override void Delete()

View File

@@ -74,6 +74,14 @@ public abstract class Message : AggregateRoot<Guid>
public virtual void Delete()
{
AddState(MessageState.IsDeleted);
RaiseDomainEvent(new MessageDeletedDomainEvent(Id, ChatId));
}
public virtual void Edit(string newContent)
{
Content = newContent;
AddState(MessageState.IsEdited);
RaiseDomainEvent(new MessageEditedDomainEvent(Id, ChatId, newContent));
}
public void DeleteForUser(Guid userId)

View File

@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Knot.Modules.Messaging.Domain;
public class PollMessage : Message
{
public override string Type => "poll";
public override string? Content { get; protected set; } // Вопрос опроса
public bool IsAnonymous { get; protected set; }
public bool AllowMultipleAnswers { get; protected set; }
public bool IsClosed { get; protected set; }
public DateTime? ExpiresAt { get; protected set; }
private List<PollOption> _options = new();
public IReadOnlyCollection<PollOption> Options => _options.AsReadOnly();
private List<PollVote> _votes = new();
public IReadOnlyCollection<PollVote> Votes => _votes.AsReadOnly();
// Инфраструктурный конструктор
private PollMessage() : base() { }
public PollMessage(
Guid id,
Guid chatId,
Guid senderId,
string question,
List<string> options,
bool isAnonymous,
bool allowMultipleAnswers,
DateTime? expiresAt,
Guid? replyToId,
Guid? forwardedFromId,
DateTime createdAt,
bool isImported)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
{
Content = question;
IsAnonymous = isAnonymous;
AllowMultipleAnswers = allowMultipleAnswers;
ExpiresAt = expiresAt;
IsClosed = false;
foreach (var optionText in options)
{
_options.Add(new PollOption(Guid.NewGuid(), Id, optionText));
}
if (!isImported)
{
// Можно добавить специфичное событие для опроса
// RaiseDomainEvent(new PollCreatedDomainEvent(Id, ChatId, SenderId, question));
}
}
public void Vote(Guid userId, List<Guid> optionIds)
{
if (IsClosed) throw new InvalidOperationException("Poll is closed");
if (ExpiresAt.HasValue && DateTime.UtcNow > ExpiresAt.Value)
{
Close();
throw new InvalidOperationException("Poll has expired");
}
if (optionIds == null || !optionIds.Any()) throw new ArgumentException("At least one option must be selected");
if (!AllowMultipleAnswers && optionIds.Count > 1) throw new ArgumentException("Multiple answers are not allowed");
// Проверяем существование опций
foreach (var oid in optionIds)
{
if (!_options.Any(o => o.Id == oid)) throw new ArgumentException($"Option {oid} not found");
}
// Удаляем старые голоса пользователя
_votes.RemoveAll(v => v.UserId == userId);
// Добавляем новые голоса
foreach (var oid in optionIds)
{
_votes.Add(new PollVote(Id, userId, oid, DateTime.UtcNow));
}
// Обновляем статистику в опциях
RecalculateResults();
}
public void Close()
{
IsClosed = true;
}
private void RecalculateResults()
{
int totalVotesCount = _votes.Select(v => v.UserId).Distinct().Count();
foreach (var option in _options)
{
int optionVotes = _votes.Count(v => v.OptionId == option.Id);
double percentage = totalVotesCount > 0 ? (double)optionVotes / totalVotesCount * 100 : 0;
option.UpdateResults(optionVotes, (int)Math.Round(percentage));
}
}
public override void Delete()
{
// При удалении опроса можно очистить голоса или оставить для истории (зависит от политики)
base.Delete();
}
}
public class PollOption
{
public Guid Id { get; private set; }
public Guid PollId { get; private set; }
public string Text { get; private set; }
// Денормализованные данные для быстрой отдачи
public int VotesCount { get; private set; }
public int Percentage { get; private set; }
private PollOption() { } // EF
public PollOption(Guid id, Guid pollId, string text)
{
Id = id;
PollId = pollId;
Text = text;
}
public void UpdateResults(int count, int percentage)
{
VotesCount = count;
Percentage = percentage;
}
}
public class PollVote
{
public Guid PollId { get; private set; }
public Guid UserId { get; private set; }
public Guid OptionId { get; private set; }
public DateTime VotedAt { get; private set; }
private PollVote() { } // EF
public PollVote(Guid pollId, Guid userId, Guid optionId, DateTime votedAt)
{
PollId = pollId;
UserId = userId;
OptionId = optionId;
VotedAt = votedAt;
}
}

View File

@@ -34,10 +34,9 @@ public class TextMessage : Message
}
}
public void Edit(string newContent)
public override void Edit(string newContent)
{
Content = newContent;
AddState(MessageState.IsEdited);
base.Edit(newContent);
}
public override void Delete()

View File

@@ -6,10 +6,14 @@ namespace Knot.Modules.Messaging.Infrastructure.Persistence;
public sealed class MessageReactionRepository : IMessageReactionRepository
{
private readonly IMongoCollection<MessageReaction> _reactions;
private readonly MediatR.IMediator _mediator;
private readonly IMessageRepository _messageRepository;
public MessageReactionRepository(IMongoDatabase mongoDatabase)
public MessageReactionRepository(IMongoDatabase mongoDatabase, MediatR.IMediator mediator, IMessageRepository messageRepository)
{
_reactions = mongoDatabase.GetCollection<MessageReaction>("message_reactions");
_mediator = mediator;
_messageRepository = messageRepository;
// Ensure index for fast querying by message
var indexKeysDefinition = Builders<MessageReaction>.IndexKeys.Ascending(r => r.MessageId);
@@ -27,6 +31,13 @@ public sealed class MessageReactionRepository : IMessageReactionRepository
// Используем ReplaceOptions.IsUpsert = true для идемпотентности (нет гонок)
await _reactions.ReplaceOneAsync(filter, reaction, new ReplaceOptions { IsUpsert = true }, cancellationToken);
// Уведомляем систему (для Федерации)
var message = await _messageRepository.GetByIdAsync(reaction.MessageId, cancellationToken);
if (message != null)
{
await _mediator.Publish(new MessageReactionAddedDomainEvent(reaction.MessageId, message.ChatId, reaction.UserId, reaction.Emoji), cancellationToken);
}
}
public async Task RemoveAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken)
@@ -38,6 +49,13 @@ public sealed class MessageReactionRepository : IMessageReactionRepository
);
await _reactions.DeleteOneAsync(filter, cancellationToken);
// Уведомляем систему (для Федерации)
var message = await _messageRepository.GetByIdAsync(messageId, cancellationToken);
if (message != null)
{
await _mediator.Publish(new MessageReactionRemovedDomainEvent(messageId, message.ChatId, userId, emoji), cancellationToken);
}
}
public async Task<List<MessageReaction>> GetReactionsForMessageAsync(Guid messageId, CancellationToken cancellationToken)

View File

@@ -129,6 +129,29 @@ public sealed class MessageRepository : IMessageRepository
await _mediator.Publish(domainEvent, cancellationToken);
}
}
public async Task<List<Message>> GetAllMessagesAsync(CancellationToken cancellationToken)
{
return await _messages.Find(_ => true).ToListAsync(cancellationToken);
}
public async Task DeleteChatMessagesAsync(Guid chatId, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.ChatId, chatId);
await _messages.DeleteManyAsync(filter, cancellationToken);
}
public async Task DeleteUserMessagesAsync(Guid userId, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.SenderId, userId);
await _messages.DeleteManyAsync(filter, cancellationToken);
}
public async Task RemoveAsync(Guid id, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.Id, id);
await _messages.DeleteOneAsync(filter, cancellationToken);
}
}

View File

@@ -2,7 +2,6 @@ using Knot.Modules.Messaging.Domain;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using Knot.Modules.Messaging.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
@@ -59,6 +58,17 @@ public static class MongoDbMapConfigurator
BsonClassMap.RegisterClassMap<DeletedMessage>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<MessageReaction>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<PollMessage>(cm =>
{
cm.AutoMap();
cm.MapField("_options").SetElementName("Options");
cm.MapField("_votes").SetElementName("Votes");
cm.SetDiscriminator("PollMessage");
});
BsonClassMap.RegisterClassMap<PollOption>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<PollVote>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<Media>(cm =>
{

View File

@@ -6,4 +6,6 @@ public class UserReplica
public string Username { get; set; }
public string DisplayName { get; set; }
public string Avatar { get; set; }
public bool IsExternal { get; set; }
public string? Domain { get; set; }
}

View File

@@ -1,26 +1,159 @@
using Knot.Shared.Kernel.Configuration;
using System.Collections.Generic;
namespace Knot.Modules.Settings.Application.Config.DTOs;
public record PublicConfigDto
{
public bool EnableCalls { get; init; }
public bool EnableKlipy { get; init; }
public int MaxFileSizeMb { get; init; }
public int MaxGroupMembers { get; init; }
public bool EnableConfederation { get; init; }
public bool EnableRegistration { get; init; }
public SystemConfigDto System { get; init; } = new();
public StoriesConfigDto Stories { get; init; } = new();
public ChatsConfigDto Chats { get; init; } = new();
public MessagesConfigDto Messages { get; init; } = new();
public WebRtcConfigDto WebRtc { get; init; } = new();
public KlipyConfigDto Klipy { get; init; } = new();
public ImportConfigDto Import { get; init; } = new();
public FederationConfigDto Federation { get; init; } = new();
public static PublicConfigDto FromSettings(SystemSettingsDto settings)
{
return new PublicConfigDto
{
EnableCalls = settings.EnableCalls,
EnableKlipy = settings.EnableKlipy,
MaxFileSizeMb = settings.MaxFileSizeMb,
MaxGroupMembers = settings.MaxGroupMembers,
EnableConfederation = settings.EnableConfederation,
EnableRegistration = settings.EnableRegistration
System = new SystemConfigDto
{
DomainUrl = settings.System.DomainUrl,
EnableRegistration = settings.System.EnableRegistration
},
Stories = new StoriesConfigDto
{
Enabled = settings.Stories.Enabled,
MaxStoriesPerPeriod = settings.Stories.MaxStoriesPerPeriod,
StoryLifetimeHours = settings.Stories.StoryLifetimeHours,
TextStoriesEnabled = settings.Stories.TextStoriesEnabled,
TextStoryDurationSeconds = settings.Stories.TextStoryDurationSeconds,
MediaStoryMaxDurationSeconds = settings.Stories.MediaStoryMaxDurationSeconds,
MaxMediaSizeBytes = settings.Stories.MaxMediaSizeBytes
},
Chats = new ChatsConfigDto
{
SupportGroups = settings.Chats.SupportGroups,
MaxGroupParticipants = settings.Chats.MaxGroupParticipants,
AllowChatToGroupConversion = settings.Chats.AllowChatToGroupConversion,
EnableFolders = settings.Chats.EnableFolders
},
Messages = new MessagesConfigDto
{
DailyMessageLimitPerUser = settings.Messages.DailyMessageLimitPerUser,
ChatMessageLimit = settings.Messages.ChatMessageLimit,
AllowMedia = settings.Messages.AllowMedia,
MaxMediaSizeBytes = settings.Messages.MaxMediaSizeBytes,
AllowedMediaTypes = settings.Messages.AllowedMediaTypes,
AllowVoiceMessages = settings.Messages.AllowVoiceMessages,
AllowForwarding = settings.Messages.AllowForwarding,
AllowReactions = settings.Messages.AllowReactions,
AllowReplies = settings.Messages.AllowReplies,
AllowQuoting = settings.Messages.AllowQuoting,
AllowMessageDeletion = settings.Messages.AllowMessageDeletion,
ForbidCopying = settings.Messages.ForbidCopying,
AllowLinks = settings.Messages.AllowLinks,
AllowPolls = settings.Messages.AllowPolls,
AllowPinning = settings.Messages.AllowPinning
},
WebRtc = new WebRtcConfigDto
{
Enabled = settings.WebRtc.Enabled,
EnableVoiceCalls = settings.WebRtc.EnableVoiceCalls,
EnableVideoCalls = settings.WebRtc.EnableVideoCalls,
EnableScreenSharing = settings.WebRtc.EnableScreenSharing,
TurnHost = settings.WebRtc.TurnHost,
TurnPort = settings.WebRtc.TurnPort
},
Klipy = new KlipyConfigDto
{
Enabled = settings.Klipy.Enabled,
AppName = settings.Klipy.AppName
},
Import = new ImportConfigDto
{
EnableTelegramImport = settings.Import.EnableTelegramImport
},
Federation = new FederationConfigDto
{
Enabled = settings.Federation.Enabled,
ServerDescription = settings.Federation.ServerDescription,
AllowedDomains = settings.Federation.AllowedDomains
}
};
}
}
public record SystemConfigDto
{
public string DomainUrl { get; init; } = string.Empty;
public bool EnableRegistration { get; init; }
}
public record StoriesConfigDto
{
public bool Enabled { get; init; }
public int MaxStoriesPerPeriod { get; init; }
public int StoryLifetimeHours { get; init; }
public bool TextStoriesEnabled { get; init; }
public int TextStoryDurationSeconds { get; init; }
public int MediaStoryMaxDurationSeconds { get; init; }
public int MaxMediaSizeBytes { get; init; }
}
public record ChatsConfigDto
{
public bool SupportGroups { get; init; }
public int MaxGroupParticipants { get; init; }
public bool AllowChatToGroupConversion { get; init; }
public bool EnableFolders { get; init; }
}
public record MessagesConfigDto
{
public int DailyMessageLimitPerUser { get; init; }
public int ChatMessageLimit { get; init; }
public bool AllowMedia { get; init; }
public int MaxMediaSizeBytes { get; init; }
public List<string> AllowedMediaTypes { get; init; } = new();
public bool AllowVoiceMessages { get; init; }
public bool AllowForwarding { get; init; }
public bool AllowReactions { get; init; }
public bool AllowReplies { get; init; }
public bool AllowQuoting { get; init; }
public bool AllowMessageDeletion { get; init; }
public bool ForbidCopying { get; init; }
public bool AllowLinks { get; init; }
public bool AllowPolls { get; init; }
public bool AllowPinning { get; init; }
}
public record WebRtcConfigDto
{
public bool Enabled { get; init; }
public bool EnableVoiceCalls { get; init; }
public bool EnableVideoCalls { get; init; }
public bool EnableScreenSharing { get; init; }
public string TurnHost { get; init; } = string.Empty;
public int TurnPort { get; init; }
}
public record KlipyConfigDto
{
public bool Enabled { get; init; }
public string AppName { get; init; } = string.Empty;
}
public record ImportConfigDto
{
public bool EnableTelegramImport { get; init; }
}
public record FederationConfigDto
{
public bool Enabled { get; init; }
public string ServerDescription { get; init; } = string.Empty;
public List<FederationDomainConfig> AllowedDomains { get; init; } = new();
}

View File

@@ -6,6 +6,10 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
using Knot.Shared.Kernel.Configuration;
using System.Text;
using System.Security.Cryptography;
namespace Knot.Host.Presentation.Endpoints;
public sealed class FilesEndpoints : ICarterModule
@@ -16,6 +20,7 @@ public sealed class FilesEndpoints : ICarterModule
group.MapGet("{id}", async (string id, [FromQuery] bool download, IFileStorageService fileStorage, IMemoryCache cache, CancellationToken ct) =>
{
// (Текущая логика локальных файлов остается без изменений)
try
{
var cacheKey = $"file_{id}";
@@ -49,5 +54,34 @@ public sealed class FilesEndpoints : ICarterModule
return Results.NotFound(new { error = "Файл не найден или ошибка доступа.", message = ex.Message });
}
});
// Новый эндпоинт для удаленных (федеративных) файлов
group.MapGet("/remote/{domain}/{id}", async (string domain, string id, HttpClient httpClient, ISettingsService settingsService, CancellationToken ct) =>
{
var settings = await settingsService.GetSettingsAsync(ct);
if (!settings.Federation.Enabled) return Results.Forbid();
// Формируем подписанный запрос к удаленному серверу
var targetUrl = $"{domain.TrimEnd('/')}/api/federation/v1/proxy/{id}";
var request = new HttpRequestMessage(HttpMethod.Get, targetUrl);
request.Headers.Add("X-Knot-Domain", settings.System.DomainUrl);
// Генерируем подпись запроса своим приватным ключом
using(var rsa = RSA.Create()) {
rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(settings.Federation.PrivateKey!), out _);
var dataToSign = Encoding.UTF8.GetBytes(id + settings.System.DomainUrl);
var signature = Convert.ToBase64String(rsa.SignData(dataToSign, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
request.Headers.Add("X-Knot-Signature", signature);
}
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
if (!response.IsSuccessStatusCode) return Results.NotFound();
var remoteStream = await response.Content.ReadAsStreamAsync(ct);
var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
return Results.Stream(remoteStream, contentType, enableRangeProcessing: true);
});
}
}

View File

@@ -33,6 +33,16 @@ internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQ
public async Task<Result<StoryGroupDto>> Handle(GetUserStoriesQuery request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.TargetUserId, cancellationToken);
if (user == null)
return Result.Failure<StoryGroupDto>(Knot.Modules.Stories.Domain.IdentityErrors.UserNotFound);
// Если пользователь внешний — идем в федерацию
if (user.IsExternal && !string.IsNullOrEmpty(user.Domain))
{
return await GetFederatedStories(user, request.CurrentUserId, cancellationToken);
}
var stories = await _storyRepository.GetActiveUserStoriesAsync(request.TargetUserId, cancellationToken);
var storyIds = stories.Select(s => s.Id).ToList();
@@ -41,10 +51,6 @@ internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQ
.Select(v => v.StoryId)
.ToListAsync(cancellationToken);
var user = await _userRepository.GetByIdAsync(request.TargetUserId, cancellationToken);
if (user == null)
return Result.Failure<StoryGroupDto>(Knot.Modules.Stories.Domain.IdentityErrors.UserNotFound);
var result = new StoryGroupDto(
new StoryUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
stories.Select(s => new StoryDto(
@@ -62,5 +68,12 @@ internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQ
return Result.Success(result);
}
private async Task<Result<StoryGroupDto>> GetFederatedStories(Knot.Modules.Relations.Domain.UserReplica user, Guid currentUserId, CancellationToken ct)
{
// В будущем: Реальный вызов через HttpClient к удаленному домену
// return await _federationGateway.FetchStoriesAsync(user.Domain, user.Id);
return Result.Failure<StoryGroupDto>(new Error("Federation.StoriesNotImplemented", "Stories from this domain are temporarily unavailable."));
}
}

View File

@@ -16,9 +16,9 @@ public record GetIceServersQuery : IQuery<IceServersResultDto>;
internal sealed class GetIceServersQueryHandler : IQueryHandler<GetIceServersQuery, IceServersResultDto>
{
private readonly IConfiguration _configuration;
private readonly ISettingsService _settingsService;
private readonly IWebRtcSettings _settingsService;
public GetIceServersQueryHandler(IConfiguration configuration, ISettingsService settingsService)
public GetIceServersQueryHandler(IConfiguration configuration, IWebRtcSettings settingsService)
{
_configuration = configuration;
_settingsService = settingsService;
@@ -27,7 +27,7 @@ internal sealed class GetIceServersQueryHandler : IQueryHandler<GetIceServersQue
public Task<Result<IceServersResultDto>> Handle(GetIceServersQuery request, CancellationToken cancellationToken)
{
var settings = _settingsService.Current;
if (!settings.EnableCalls)
if (!settings.Enabled)
{
return Task.FromResult(Result.Failure<IceServersResultDto>(new Error(
Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin,