From 7ef73b414c3cf047bcfd610ead557fe03c4151d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Fri, 27 Mar 2026 00:55:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B0,=20=D0=B4=D0=BE=D0=BF=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D0=B8,=20=D1=84=D0=B5=D0=B4=D0=B5=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F,=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Docs/admin_module_documentation.md | 77 ++++++ backend/src/Docs/auth_module_documentation.md | 68 +++++ .../conversations_module_documentation.md | 84 ++++++ .../federation_encryption_implementation.md | 42 +++ .../Docs/federation_module_documentation.md | 79 ++++++ .../Docs/federation_protocol_specification.md | 135 ++++++++++ ...federation_proxy_storage_implementation.md | 31 +++ .../src/Docs/storage_module_documentation.md | 59 +++++ backend/src/Host/Program.cs | 4 +- .../UpdateSettings/UpdateSettingsCommand.cs | 21 +- .../SystemSettingsUpdatedDomainEvent.cs | 10 + .../Register/RegisterUserCommandHandler.cs | 4 +- .../Modules/Auth/Domain/IUserRepository.cs | 1 + backend/src/Modules/Auth/Domain/User.cs | 35 +++ .../Persistence/UserRepository.cs | 5 + .../AddToFolder/AddToFolderCommand.cs | 50 ++++ .../RemoveFromFolderCommand.cs | 40 +++ .../Messages/Delete/DeleteMessagesCommand.cs | 10 +- .../Send/SendMessageCommandHandler.cs | 34 ++- .../Messages/UploadFile/UploadFileCommand.cs | 10 +- .../Commands/DeleteUser/DeleteUserCommand.cs | 97 +++++++ .../Conversations/DependencyInjection.cs | 10 + .../Conversations/Domain/ChatErrors.cs | 3 + .../Modules/Conversations/Domain/Folder.cs | 108 ++++++++ .../Conversations/Domain/IChatRepository.cs | 25 ++ .../Persistence/ChatRepository.cs | 56 ++++ .../Persistence/ConversationsDbContext.cs | 34 ++- .../ConversationsMongoDbMapConfigurator.cs | 25 ++ .../Mongo/UserFolderSettingsRepository.cs | 30 +++ .../Knot.Modules.Conversations.csproj | 2 +- .../Conversations/conversations_errors.json | 155 +++++++++++ backend/src/Modules/Conversations/errors.json | 245 ++++++++++++++++++ .../Abstractions/IFederationGateway.cs | 38 +++ .../Commands/HandshakeFederationCommand.cs | 68 +++-- .../Commands/InboundFederationCommand.cs | 206 +++++++++++++++ .../Federation/Commands/ResolveUserCommand.cs | 40 +++ .../Events/FederatedMessageActionsHandler.cs | 97 +++++++ .../Events/MessageSentDomainEventHandler.cs | 90 +++++++ ...SystemSettingsUpdatedDomainEventHandler.cs | 71 +++++ .../UserStatusChangedDomainEventHandler.cs | 86 ++++++ .../Services/FederationPacketService.cs | 107 ++++++++ .../Services/FederationGateway.cs | 58 +++++ .../Endpoints/FederationEndpoints.cs | 57 ++-- .../Klipy/Queries/GetTrendingGifsQuery.cs | 14 +- .../Klipy/Queries/SearchGifsQuery.cs | 14 +- .../Domain/FederatedMessagingEvents.cs | 24 ++ .../Messaging/Domain/IMessageRepository.cs | 4 + .../Modules/Messaging/Domain/MediaMessage.cs | 5 +- .../src/Modules/Messaging/Domain/Message.cs | 8 + .../Modules/Messaging/Domain/PollMessage.cs | 156 +++++++++++ .../Modules/Messaging/Domain/TextMessage.cs | 5 +- .../Persistence/MessageReactionRepository.cs | 20 +- .../Persistence/MessageRepository.cs | 23 ++ .../Mongo/MongoDbMapConfigurator.cs | 12 +- .../Modules/Relations/Domain/UserReplica.cs | 2 + .../Config/DTOs/PublicConfigDto.cs | 157 ++++++++++- .../Presentation/Endpoints/FilesEndpoints.cs | 34 +++ .../GetUserStories/GetUserStoriesQuery.cs | 21 +- .../WebRtc/Queries/GetIceServersQuery.cs | 6 +- .../Configuration/SettingsService.cs | 19 +- .../DependencyInjection.cs | 13 +- .../Configuration/ISettingsService.cs | 40 +++ .../Configuration/SystemSettingsDto.cs | 128 +++++++-- .../src/Shared/Knot.Shared.Kernel/Result.cs | 1 + 64 files changed, 3080 insertions(+), 133 deletions(-) create mode 100644 backend/src/Docs/admin_module_documentation.md create mode 100644 backend/src/Docs/auth_module_documentation.md create mode 100644 backend/src/Docs/conversations_module_documentation.md create mode 100644 backend/src/Docs/federation_encryption_implementation.md create mode 100644 backend/src/Docs/federation_module_documentation.md create mode 100644 backend/src/Docs/federation_protocol_specification.md create mode 100644 backend/src/Docs/federation_proxy_storage_implementation.md create mode 100644 backend/src/Docs/storage_module_documentation.md create mode 100644 backend/src/Modules/Admin/Domain/Events/SystemSettingsUpdatedDomainEvent.cs create mode 100644 backend/src/Modules/Conversations/Application/Folders/Commands/AddToFolder/AddToFolderCommand.cs create mode 100644 backend/src/Modules/Conversations/Application/Folders/Commands/RemoveFromFolder/RemoveFromFolderCommand.cs create mode 100644 backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs create mode 100644 backend/src/Modules/Conversations/Domain/Folder.cs create mode 100644 backend/src/Modules/Conversations/Infrastructure/Persistence/Mongo/ConversationsMongoDbMapConfigurator.cs create mode 100644 backend/src/Modules/Conversations/Infrastructure/Persistence/Mongo/UserFolderSettingsRepository.cs create mode 100644 backend/src/Modules/Conversations/conversations_errors.json create mode 100644 backend/src/Modules/Conversations/errors.json create mode 100644 backend/src/Modules/Federation/Application/Abstractions/IFederationGateway.cs create mode 100644 backend/src/Modules/Federation/Application/Federation/Commands/InboundFederationCommand.cs create mode 100644 backend/src/Modules/Federation/Application/Federation/Commands/ResolveUserCommand.cs create mode 100644 backend/src/Modules/Federation/Application/Federation/Events/FederatedMessageActionsHandler.cs create mode 100644 backend/src/Modules/Federation/Application/Federation/Events/MessageSentDomainEventHandler.cs create mode 100644 backend/src/Modules/Federation/Application/Federation/Events/SystemSettingsUpdatedDomainEventHandler.cs create mode 100644 backend/src/Modules/Federation/Application/Federation/Events/UserStatusChangedDomainEventHandler.cs create mode 100644 backend/src/Modules/Federation/Application/Federation/Services/FederationPacketService.cs create mode 100644 backend/src/Modules/Federation/Infrastructure/Services/FederationGateway.cs create mode 100644 backend/src/Modules/Messaging/Domain/FederatedMessagingEvents.cs create mode 100644 backend/src/Modules/Messaging/Domain/PollMessage.cs diff --git a/backend/src/Docs/admin_module_documentation.md b/backend/src/Docs/admin_module_documentation.md new file mode 100644 index 0000000..404743a --- /dev/null +++ b/backend/src/Docs/admin_module_documentation.md @@ -0,0 +1,77 @@ +# Документация модуля администрирования (Admin Module) + +Модуль администрирования предназначен для управления глобальными настройками системы, пользователями и интеграциями мессенджера ForkMessager. + +## Обзор архитектуры + +Модуль построен на принципах чистой архитектуры и использует паттерн CQRS (через MediatR). Он взаимодействует с другими модулями системы для реализации сквозной функциональности, такой как полное удаление данных пользователя. + +## Глобальные настройки системы + +Настройки разделены на логические блоки для упрощения управления и соблюдения принципа разделения ответственности (ISP). + +### 1. Системные настройки (System) +* **DomainUrl**: Базовый URL домена сервера. +* **EnableRegistration**: Переключатель возможности регистрации новых пользователей. +* **AdminRoute**: Маршрут к панели администратора (по умолчанию `admin`). + +### 2. Настройки историй (Stories) +* **Enabled**: Общий переключатель функционала историй. +* **MaxStoriesPerPeriod**: Максимальное количество историй за период жизни (1-10). +* **StoryLifetimeHours**: Время жизни истории (4-48 часов). +* **TextStoriesEnabled**: Разрешить текстовые истории. +* **MaxMediaSizeBytes**: Лимит размера файлов для медиа-историй. + +### 3. Настройки чатов (Chats) +* **SupportGroups**: Включить поддержку групповых чатов. +* **MaxGroupParticipants**: Лимит участников в одной группе. +* **EnableFolders**: Включить функционал папок для группировки чатов. + +### 4. Настройки сообщений (Messages) +Администратор может гибко управлять правами пользователей на работу с контентом: +* **AllowPolls**: Разрешить создание опросов (аналог Telegram). +* **AllowMedia**: Разрешить отправку файлов/фото/видео. +* **AllowVoiceMessages**: Разрешить отправку голосовых сообщений. +* **AllowForwarding**: Разрешить пересылку сообщений в другие чаты. +* **AllowReplies**: Разрешить ответы на сообщения. +* **AllowQuoting**: Разрешить цитирование текста. +* **AllowPinning**: Разрешить закрепление сообщений в чатах. +* **AllowMessageDeletion**: Разрешить удаление сообщений. +* **ForbidCopying**: При включении запрещает копирование текста и сохранение медиафайлов из чатов. +* **AllowLinks**: Разрешить вставку активных гиперссылок. +* **MaxMediaSizeBytes**: Глобальный лимит на размер вложения. + +### 5. Настройки WebRTC (Голос/Видео) +* **Enabled**: Общий переключатель звонков. +* **Voice/Video Calls**: Разрешить аудио и видео звонки отдельно. +* **ScreenSharing**: Разрешить демонстрацию экрана. +* **TURN Configuration**: IP, логин и пароль для TURN-сервера (необходим для обхода NAT). + +### 6. Интеграция Klipy +* **Enabled**: Включить сервис GIF и стикеров Klipy. +* **AppName / Token**: Параметры аутентификации в API Klipy. + +### 7. Федерация (Confederation) +* **Enabled**: Позволяет серверу взаимодействовать с другими серверами мессенджера. +* **ServerDescription**: Описание сервера. **Валидация: от 20 до 120 символов.** +* **AllowedDomains**: Список доверенных доменов для обмена сообщениями. + +--- + +## Административные команды + +### Полное удаление пользователя ([DeleteUserCommand](file:///e:/GIT/forkmessager/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs#13-14)) +Операция, гарантирующая удаление персональных данных: +1. **Очистка сообщений**: Удаляются сообщения пользователя из MongoDB. +2. **Очистка файлов**: Система проверяет вложения. Файл удаляется из S3, если на него нет ссылок в других чатах. +3. **Очистка профиля**: Удаление из PostgreSQL (Auth и Conversations). + +### Сброс пароля (`ResetUserPasswordCommand`) +Позволяет администратору принудительно изменить пароль любого пользователя. + +--- + +## API Эндпоинты +* `GET /api/admin/settings` — Получение текущих настроек. +* `PUT /api/admin/settings` — Обновление конфигурации. +* `DELETE /api/admin/users/{userId}` — Полное удаление пользователя. diff --git a/backend/src/Docs/auth_module_documentation.md b/backend/src/Docs/auth_module_documentation.md new file mode 100644 index 0000000..76280e8 --- /dev/null +++ b/backend/src/Docs/auth_module_documentation.md @@ -0,0 +1,68 @@ +# Документация модуля аутентификации (Auth Module) + +Модуль Auth отвечает за идентификацию, аутентификацию и управление базовыми профилями пользователей в системе Knot Messager. + +## Основные функции + +* Регистрация новых пользователей (с контролем доступности). +* Аутентификация (Login) с выдачей JWT токенов. +* Управление сессиями и контекстом текущего пользователя. +* Хранение базовой информации профиля (Username, PasswordHash, DisplayName). + +## Доменная модель + +### Пользователь (User) +Сущность [User](file:///e:/GIT/forkmessager/backend/src/Modules/Auth/Domain/User.cs#20-30) является корнем агрегата и содержит следующие данные: +* **Username**: Уникальное имя пользователя (логин). +* **PasswordHash**: Хэшированный пароль (argon2/bcrypt). +* **DisplayName**: Отображаемое имя. +* **Email**: Электронная почта (опционально). +* **Avatar**: URL аватара из хранилища. +* **Bio**: Краткое описание профиля. +* **Birthday**: Дата рождения (опционально). +* **CreatedAt**: Дата регистрации. + +## Процессы и команды + +### 1. Регистрация ([RegisterUserCommand](file:///e:/GIT/forkmessager/backend/src/Modules/Auth/Application/Users/Register/RegisterUserCommandHandler.cs#14-20)) +Регистрация новых пользователей является управляемым процессом. + +**Зависимость от модуля администрирования:** +Перед началом процесса регистрации модуль Auth проверяет глобальную настройку `EnableRegistration` из модуля [Settings](file:///e:/GIT/forkmessager/backend/src/Modules/Settings/Application/Config/DTOs/PublicConfigDto.cs#17-87) (управляется через `Admin Module`). +* Если регистрация **выключена** администратором, команда возвращает ошибку `Identity.RegistrationDisabled`. +* Если регистрация **включена**, процесс продолжается: + 1. Проверка уникальности [Username](file:///e:/GIT/forkmessager/backend/src/Modules/Auth/Infrastructure/Persistence/UserRepository.cs#28-32). + 2. Хэширование пароля с использованием алгоритма BCrypt. + 3. Создание записи в PostgreSQL. + 4. Выдача JWT-токена для немедленного входа. + +### 2. Вход в систему (`LoginUserCommand`) +Процесс проверки учетных данных: +1. Поиск пользователя по [Username](file:///e:/GIT/forkmessager/backend/src/Modules/Auth/Infrastructure/Persistence/UserRepository.cs#28-32). +2. Верификация хэша пароля. +3. Генерация JWT-токена, содержащего `sub` (UserId) и `unique_name` (Username). + +### 3. Получение данных о себе (`GetMeQuery`) +Возвращает данные текущего авторизованного пользователя на основе Token Claims. Используется для инициализации состояния клиента (профиля) после загрузки приложения. + +## API Эндпоинты + +Модуль использует **Minimal APIs** через Carter. Базовый путь: `/api/auth` + +* `POST /api/auth/register` — Регистрация нового аккаунта. Возвращает ошибку 400, если регистрация закрыта администратором. +* `POST /api/auth/login` — Вход и получение токена. +* `GET /api/auth/me` — Получение данных своего профиля (требует Authorization Header). + +## Безопасность + +1. **JWT**: Используются токены с коротким временем жизни. Секретный ключ, Issuer и Audience настраиваются в переменных окружения. +2. **Хэширование**: Пароли никогда не хранятся в открытом виде. +3. **Контроль регистрации**: Позволяет администратору полностью закрыть сервер от новых регистраций в любой момент времени. +4. **Изоляция**: Другие модули получают ID пользователя через `IUserContext`, который извлекает данные из JWT Claims без прямого обращения к БД Auth. + +## Взаимодействие с другими модулями + +* **Settings/Admin**: Предоставляет настройки доступности регистрации. +* **Conversations**: Связывает чаты с UserId. +* **Messaging**: Связывает сообщения с SenderId. +* **Admin**: Позволяет администратору сбрасывать пароли пользователей. diff --git a/backend/src/Docs/conversations_module_documentation.md b/backend/src/Docs/conversations_module_documentation.md new file mode 100644 index 0000000..ff417d7 --- /dev/null +++ b/backend/src/Docs/conversations_module_documentation.md @@ -0,0 +1,84 @@ +# Документация модуля диалогов (Conversations Module) + +Модуль Conversations является центральным узлом Knot Messager, обеспечивающим взаимодействие пользователей через чаты, управление сообщениями и организацию рабочего пространства с помощью папок. + +## Основные сущности + +### 1. Чат (Chat) +Основная единица общения. Бывает трех типов: +* **Personal**: Диалог между двумя пользователями. +* **Group**: Групповой чат с неограниченным (или лимитированным админом) количеством участников. +* **Favorites**: Спец-чат "Избранное" (заметки для самого себя). + +### 2. Участник (Member) +Связующее звено между пользователем и чатом. Хранит: +* Роль (Admin, Moderator, Member). +* **Cursors**: Указатели на последнее прочитанное и последнее доставленное сообщение для синхронизации статусов. + +### 3. Папка (Folder) +Инструмент группировки чатов. +* **Системные папки**: "Все чаты", "Новые" (с непрочитанными), "Без звука". +* **Пользовательские папки**: Создаются пользователем вручную по имени и иконке. +* *Особенность*: Один чат может находиться одновременно в нескольких папках. + +## Функциональные возможности + +### Управление чатами +* Создание личных и групповых чатов. +* Редактирование названия, описания и аватара группы (с поддержкой обрезки изображения). +* Управление участниками (добавление, удаление, выход из чата). +* Закрепление чатов (Pin) для быстрого доступа. +* Очистка истории сообщений для конкретного пользователя. + +### Работа с сообщениями +Модуль предоставляет высокоуровневый API для отправки контента: +* **Текстовые сообщения**: С поддержкой цитирования и ответов. +* **Медиа-сообщения**: Фото, видео, файлы, голосовые сообщения. +* **Опросы**: Анонимные или публичные, с выбором одного или нескольких вариантов. +* **Истории**: Ответы на истории и реакции (пересылаются в чат как спец-сообщения). +* **Пересылка (Forwarding)**: Отправка существующих сообщений в другие чаты. + +### Синхронизация и поиск +* **Курсоры чтения**: Позволяют клиенту точно знать, какие сообщения новы для пользователя. +* **Shared Media**: Быстрый доступ ко всем файлам, ссылкам или фото, когда-либо отправленным в конкретном чате. +* **Глобальный поиск**: Поиск по тексту сообщений во всех доступных чатах. + +--- + +## API Эндпоинты + +### Чаты (`api/chats`) +* `GET /` — Получение списка всех чатов пользователя. +* `POST /personal` — Создание диалога с пользователем. +* `POST /group` — Создание группы. +* `PUT /{id}` — Обновление информации о чате. +* `POST /{id}/pin` — Закрепить/открепить чат. +* `POST /{id}/avatar` — Загрузка аватара группы. + +## Зависимость от глобальных настроек (Admin Module) + +Логика модуля Conversations напрямую зависит от конфигурации, установленной администратором в `Admin Module`: + +1. **Медиа-сообщения**: Перед отправкой фото, видео или файлов проверяется флаг `AllowMedia`. Если он выключен, сервер возвращает ошибку `Media.Disabled`. +2. **Опросы**: Создание опросов возможно только при включенном флаге `AllowPolls`. В противном случае возвращается `Polls.Disabled`. +3. **Папки**: Функционал группировки чатов по папкам контролируется флагом `EnableFolders`. При его отключении команды добавления в папку ([AddToFolder](file:///e:/GIT/forkmessager/backend/src/Modules/Conversations/Domain/Folder.cs#67-71)) блокируются с ошибкой `Folders.Disabled`. +4. **Групповые чаты**: Лимит на количество участников в группе (`MaxGroupParticipants`) проверяется при создании чата и добавлении новых членов. + +## Сообщения (`api/messages`) +* `POST /chat/{chatId}` — Отправка сообщения. +* `GET /chat/{chatId}` — Получение истории (поддерживает пагинацию через курсоры). +* `POST /upload` — Предварительная загрузка файла в хранилище. +* `GET /search` — Поиск сообщений. + +### Папки (`api/folders`) +* `POST /add` — Добавление чата в папку. +* `POST /remove` — Удаление из папки. + +--- + +## Интеграция и Real-time + +Модуль тесно интегрирован с: +* **Messaging**: Для фактического хранения и доставки тел сообщений (в MongoDB). +* **Storage**: Для обработки вложений и аватаров. +* **SignalR (ChatHub)**: Для мгновенной доставки уведомлений о новых сообщениях, статусах прочтения (`message_read`) и обновлении состава участников. diff --git a/backend/src/Docs/federation_encryption_implementation.md b/backend/src/Docs/federation_encryption_implementation.md new file mode 100644 index 0000000..843170a --- /dev/null +++ b/backend/src/Docs/federation_encryption_implementation.md @@ -0,0 +1,42 @@ +# Спецификация Гибридного Шифрования (Hybrid Federation Encryption) + +Для обеспечения безопасности переписки между серверами Knot Messager используется схема AES-RSA. + +## 1. Структура Пакета (The Courier Packet) + +```json +{ + "senderDomain": "domain-a.com", + "recipientDomain": "domain-b.com", + "encryptedPayload": "base64-aes-data", + "encryptedIV": "base64-rsa-encrypted-iv", + "signature": "base64-rsa-signature", + "metadata": { + "chatId": "uuid", + "senderId": "uuid", + "messageType": "text|media|poll" + } +} +``` + +## 2. Алгоритм Отправки (Outgoing) + +1. **AES-256 Encryption**: Локальный сервер генерирует случайный `IV` и шифрует текст сообщения с помощью `AES-256-CBC`. +2. **RSA-Key Wrap**: Шифрует полученный `IV` с помощью **Public Key сервера-получателя** (полученного при Handshake). +3. **RSA-Sign**: Подписывает весь пакет (собрав хеш из payload + metadata) своим **Private Key**. +4. **Transport**: Отправляет POST-запрос на `/api/federation/v1/inbound`. + +## 3. Алгоритм Приема (Inbound) + +1. **Verify Signature**: Проверяет подпись пакета, используя **Public Key сервера-отправителя** (из белого списка). +2. **RSA-Key Unwrap**: Расшифровывает `encryptedIV`, используя свой **локальный Private Key**. +3. **AES-256 Decryption**: С помощью расшифрованного `IV` читает `encryptedPayload`. +4. **Persistence**: Сохраняет сообщение в локальную БД и уведомляет пользователя через SignalR. + +--- + +## Задачи на текущую итерацию: + +1. **Shared Kernel**: Добавить методы `EncryptWithRsa` и `DecryptWithRsa` в вспомогательные службы. +2. **Federation Application**: Реализовать `InboundFederationCommand` (логика приема и расшифровки). +3. **Federation Infrastructure**: Реализовать `FederationHttpClient` для выполнения подписанных запросов. diff --git a/backend/src/Docs/federation_module_documentation.md b/backend/src/Docs/federation_module_documentation.md new file mode 100644 index 0000000..7c17951 --- /dev/null +++ b/backend/src/Docs/federation_module_documentation.md @@ -0,0 +1,79 @@ +# Модуль Федерации (Federation Module) — Knot Messager + +Модуль федерации обеспечивает децентрализованное взаимодействие между независимыми узлами (серверами) Knot Messager. Он реализует безопасный обмен сообщениями, синхронизацию присутствия (Presence), проксирование медиафайлов и координацию системных политик. + +--- + +## 🏗 Архитектура конфедерации + +Федерация Knot Messager построена на принципах **Zero-Knowledge** и **Intersected Policies**. Ключевые аспекты: +1. **Peer-to-Peer Trust**: Узлы доверяют друг другу на основе предварительного обмена публичными ключами (Handshake). +2. **Domain Isolation**: Модуль Федерации полностью изолирован от других модулей. Взаимодействие происходит через **Domain Events** (MediatR). +3. **No Data Duplication**: Файлы не копируются на чужие сервера, а стримятся через авторизованные прокси-каналы. + +--- + +## 🔒 Безопасность и Шифрование + +Для защиты межсерверного трафика используется гибридная схема: +* **Payload Encryption (AES-256)**: Содержимое пакетов шифруется на лету уникальным ключом AES. +* **Key Wrapping (RSA-2048)**: Ключ AES и IV шифруются на публичный ключ сервера-получателя. +* **Authentication (RSA Signature)**: Каждый входящий пакет подписывается приватным ключом отправителя. Подпись проверяется получателем по белому списку доменов. + +--- + +## 📥 Входящие типы пакетов (Inbound Messages) + +Модуль обрабатывает следующие типы федеративных транзакций через [InboundFederationCommand](file:///e:/GIT/forkmessager/backend/src/Modules/Federation/Application/Federation/Commands/InboundFederationCommand.cs#19-20): + +| Тип сообщения | Описание | Действие | +| :--- | :--- | :--- | +| `text` | Обычное текстовое сообщение. | Сохранение в БД и уведомление локальных юзеров. | +| `presence_update` | Обновление статуса (Online/Offline/LastSeen). | Обновление кэша `IsOnline` для внешнего контакта. | +| `sync_capabilities` | Синхронизация системных настроек. | Обновление правил (Media, Polls, RTC) для партнера. | +| `rtc_signal` | WebRTC сигнализация (Offer/ICE). | Проброс сигнала конечному пользователю для звонка. | +| `message_edited` | Редактирование сообщения. | Обновление контента локальной копии сообщения. | +| `message_deleted` | Удаление сообщения "у всех". | Перманентное удаление сообщения из локальной БД. | +| `reaction_added` | Добавление эмодзи-реакции. | Синхронизация реакции внешнего пользователя. | + +--- + +## 📂 Проксирование медиа (Storage Proxy) + +Критически важный механизм, исключающий хранение чужих данных: +1. **Outgoing Proxy**: `/api/federation/v1/proxy/{id}` — сервер отдает файл только авторизованным серверам-партнерам. +2. **Incoming Proxy**: `/api/files/remote/{domain}/{id}` — ваш сервер выступает "транзитом", запрашивает файл у партнера и стримит его вашему клиенту. +3. **Безопасность**: Ссылки на медиа внутри сообщений всегда указывают на локальный прокси, а не на оригинальный домен отправителя. + +--- + +## ⚙ Настройка и Handshake + +Администратор добавляет домен в белый список через `Admin Module`: +1. Генерируется пара RSA-ключей для своего сервера (если нет). +2. Выполняется запрос `/handshake` к удаленному серверу. +3. Обмениваются `Public Keys` и [Capabilities](file:///e:/GIT/forkmessager/backend/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs#90-98) (возможности сервера). +4. С этого момента домен считается **Trusted Node**. + +--- + +## 📡 Реализованные API Эндпоинты + +* `POST /api/federation/v1/handshake` — Установка связи. +* `POST /api/federation/v1/inbound` — Прием зашифрованных пакетов. +* `GET /api/federation/v1/resolve/{username}` — Поиск профиля по всей сети. +* `GET /api/federation/v1/proxy/{id}` — Стриминг контента для партнеров. + +--- + +## 🧩 Взаимодействие с другими модулями (MediatR Events) + +* [MessageSentDomainEvent](file:///e:/GIT/forkmessager/backend/src/Modules/Messaging/Domain/MessageSentDomainEvent.cs#9-10) ➡ Триггерит рассылку сообщения внешним участникам. +* [UserStatusChangedDomainEvent](file:///e:/GIT/forkmessager/backend/src/Modules/Auth/Domain/User.cs#5-6) ➡ Пушит статус пользователя всем серверам-контактам. +* [SystemSettingsUpdatedDomainEvent](file:///e:/GIT/forkmessager/backend/src/Modules/Admin/Domain/Events/SystemSettingsUpdatedDomainEvent.cs#10-11) ➡ Синхронизирует возможности (Capabilities) с сетью. +* `MessageEdited/DeletedDomainEvent` ➡ Транслирует действия с сообщениями. + +--- + +> [!IMPORTANT] +> Модуль Федерации требует корректно настроенного `System:DomainUrl` в глобальном конфиге для формирования подписей. diff --git a/backend/src/Docs/federation_protocol_specification.md b/backend/src/Docs/federation_protocol_specification.md new file mode 100644 index 0000000..f775490 --- /dev/null +++ b/backend/src/Docs/federation_protocol_specification.md @@ -0,0 +1,135 @@ +# Архитектура и Протокол Конфедерации (Federation Protocol) + +Спецификация описывает механизмы взаимодействия независимых узлов (серверов) Knot Messager для обмена сообщениями, соблюдения политик безопасности и проксирования медиаконтента. + +--- + +## 1. Основные принципы (Design Principles) + +1. **Делегированное Хранение (Remote Proxy Storage)**: Медиафайлы (фото, видео, голосовые) хранятся **только на сервере отправителя**. Сервер получателя хранит лишь "прокси-ссылку" на объект. +2. **Сквозная Валидация (Cross-Server Policy)**: Если на сервере получателя отключены голосовые сообщения или опросы, сервер отправителя блокирует их отправку в этот федеративный чат на этапе валидации (до сетевого запроса). +3. **Безопасный Handshake (RSA-2048)**: Узлы доверяют друг другу на основе обмена публичными ключами и подписи каждого входящего пакета (HMAC/RSA). +4. **Zero-Knowledge Integrity**: Ключи шифрования вложений (IV) передаются получателю в зашифрованном виде (на его публичный ключ), чтобы обеспечивался E2EE даже при проксировании контента. + +--- + +## 2. Протокол взаимодействия (Federation API) + +Все запросы между серверами используют префикс `/api/federation/v1/*` и подписываются заголовком `X-Knot-Signature`. + +### 2.1 Handshake (Установка связи) +При добавлении домена в `Admin Module`, сервер инициирует [HandshakeRequest](file:///e:/GIT/forkmessager/backend/src/Modules/Federation/Application/Federation/Commands/HandshakeFederationCommand.cs#16-17): +* **Передает**: Свой домен, публичный ключ, список поддерживаемых фич (AllowMedia, AllowPolls и т.д.). +* **Получает**: Аналогичные данные от удаленного сервера. +* **Результат**: Создается пара "Доверенный узел" с кешированной политикой возможностей. + +### 2.2 Доставка Сообщений (`PushMessage`) +Когда сообщение отправляется в чат с федеративным участником: +1. Локальный сервер проверяет кэш политики удаленного сервера. +2. Если тип сообщения разрешен удаленным сервером, формируется `FederationPacket`: + ```json + { + "sender": "user@domain-a.com", + "chatId": "shared-uuid", + "payload": { "type": "text", "content": "..." }, + "signature": "rsa-sig-body" + } + ``` +3. Для медиа-сообщений в `payload` передается `ProxyUrl` вместо прямого вложения: `https://domain-a.com/api/federation/v1/proxy/{objectId}`. + +### 2.3 Discovery & Health Check (Ping) +Для поддержания актуального статуса сети используется фоновый мониторинг (Keep-Alive): +* **Endpoint**: `GET /api/federation/v1/ping`. +* **Механика**: Каждый узел раз в 2-5 минут опрашивает (пингует) известные ему домены из `AllowedDomains`. +* **Статусы**: + * `Online` — Сервер доступен. + * `Maintenance` (503) — Временно недоступен (повтор через 10 мин). + * `Disabled` (403) — Конфедерация на той стороне выключена принудительно. +* **Последствия**: Если сервер `Offline`, исходящие сообщения в его сторону ставятся в локальную очередь `FederationOutbox`. + +### 2.4 Global User Search (Поиск пользователей) +Поиск осуществляется по формату `username@domain.com`: +1. Если в строке поиска есть `@`, локальный сервер делает запрос к удаленному: `GET /api/federation/v1/users/search?q={username}`. +2. Удаленный сервер возвращает публичные данные (DisplayName, AvatarProxyUrl). +3. **Важно**: Поиск анонимен. Удаленный сервер отдает данные только если у пользователя в настройках разрешен публичный поиск через федерацию. + +### 2.5 Federated Stories (Истории между серверами) +Когда пользователь хочет посмотреть истории контакта с другого сервера: +1. **Request**: Локальный сервер запрашивает актуальный список историй по ID пользователя: `GET /api/federation/v1/stories/user/{userId}`. +2. **Response**: Удаленный сервер возвращает список метаданных историй (ID, Type, ProxyMediaUrl). +3. **Proxying**: Сами файлы историй (видео/фото) проксируются через тот же механизм `Remote Proxy Storage`, что и вложения в сообщениях. +4. **Views Logic**: Фиксация просмотра (`view_story`) также передается через федеративный пакет, чтобы автор на удаленном сервере видел счетчик просмотров правильно. + +### 2.6 Remote Presence & Statuses (Синхронизация присутствия) +1. **Status Sync**: При получении нового сообщения от контакта с другого сервера, поле `LastSeen` для этого контакта локально обновляется. +2. **Explicit Query (Пинг статуса)**: При открытии чата с удаленным пользователем, локальный сервер может сделать запрос `GET /api/federation/v1/users/{id}/status`. +3. **Response**: Возвращает `Online = true/false` и `LastSeen = timestamp`. +4. **Privacy**: Если на удаленном сервере пользователь скрыл статус "Был в сети", сервер вернет пустой `LastSeen`. + +### 2.7 Federated RTC (Звонки и Шаринг) +WebRTC соединение между пользователями разных серверов требует координации: +1. **Signaling Relay**: Серверы А и Б выступают промежуточными узлами (Relay) для обмена Offer/Answer/ICE кандидатами через федеративный канал. +2. **Policy Check**: Перед инициацией звонка сервер А запрашивает у сервера Б: `CanCall(userB)`. Если у сервера Б выключены `VoiceCalls` или `ScreenSharing`, сервер А блокирует кнопку звонка в клиенте. +3. **TURN Proxy**: Для обхода NAT в федерации рекомендуется использовать TURN-серверы, указанные в [WebRtcConfig](file:///e:/GIT/forkmessager/backend/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs#58-69). + +### 2.8 Real-time & Interactivity (Реакции и Ответы) +Любое действие с сообщением должно быть доставлено удаленному узлу: +* **Реакции**: Пакет `MessageReactionChanged` с указанием `emoji` и `messageId`. +* **Ответы на Истории**: Пакет `StoryResponse` с типом (Reply/Reaction) и метаданными истории. +* **Удаление у всех (Delete for Everyone)**: Пакет `MessageDeleted`. Сервер получателя обязан удалить локальную копию сообщения при получении подписанного пакета от сервера-владельца. +* **Редактирование (Edit)**: Пакет `MessageUpdated` с новым телом сообщения. Применяется локально на сервере получателя. + +--- + +## 3. Remote Proxy Storage (Проксирование медиа) + +## 3. Remote Proxy Storage (Проксирование медиа) + +Это критическая часть системы, позволяющая не дублировать файлы на чужих серверах. + +### Процесс скачивания файла пользователем Сервера B (отправленного Сервером A): +1. Клиент на Сервере B запрашивает файл через локальный API: `GET /api/files/{remoteId}`. +2. Модуль [Storage](file:///e:/GIT/forkmessager/backend/src/Shared/Knot.Shared.Kernel/Storage/IFileStorageService.cs#6-26) сервера B видит, что файл является "внешним" (External). +3. Сервер B делает внутренний авторизованный запрос к Серверу A: `GET /api/federation/v1/proxy/{objectId}`. +4. Сервер A стримит байты зашифрованного файла Серверу B. +5. Сервер B на лету расшифровывает контент (используя полученный ранее IV) и отдает клиенту. + +**Важно:** Сервер B никогда не сохраняет файл локально на постоянной основе (только в In-Memory кэш для скорости раздачи). + +--- + +## 4. Групповые чаты (Fan-out Architecture) + +В Knot Messager групповые чаты децентрализованы. + +1. **Рассылка (Fan-out)**: При отправке сообщения в группу, локальный сервер отправителя определяет список уникальных доменов участников и рассылает пакет каждому серверу один раз. +2. **Автономность**: Если один из серверов-участников (например, Server C) уходит в оффлайн, остальные участники (Server A, B, D) продолжают общаться. Сообщения для Server C накапливаются в очередях на других серверах. +3. **Медиа-матрица**: В одном групповом чате могут быть вложения, хранящиеся на 5 разных серверах одновременно. Каждый сервер проксирует файлы своих пользователей самостоятельно. + +--- + +## 5. Валидация политик (Policy Enforcement) + +Модуль Конфедерации Knot Messager работает по принципу **Intersected Policy** (Пересечение политик): + +1. **Rule**: Действие разрешено только если оно разрешено ОБОИМИ серверами (отправителем и получателем). +2. **Mapping**: + * Если Сервер А разрешил `ScreenSharing`, а Сервер Б запретил — шаринг экрана в этом чате **невозможен**. + * Если Сервер А запретил `Copying` (Для запрета копирования), Сервер Б **обязан** передать этот флаг своему клиенту, чтобы тот заблокировал контекстное меню для сообщений из этого чата. +3. **Audit**: Все входящие федеративные пакеты проходят через `PolicyValidator`. Если сервер А пытается прислать Опрос на сервер Б, где опросы выключены, пакет будет отклонен с кодом `403 Forbidden`. + +--- + +## 6. План реализации + +### Этап 1: Инфраструктура +* Обновить [FederationDomainConfig](file:///e:/GIT/forkmessager/backend/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs#82-87), добавив туда `PublicKey` и `RemoteCapabilities`. +* Реализовать `FederationHttpClient` для подписанных запросов между серверами. + +### Этап 2: Проксирование (Storage Proxy) +* Добавить в `Storage Module` поддержку типа `ExternalFile`. +* Реализовать эндпоинт прокси-стриминга в модуле [Federation](file:///e:/GIT/forkmessager/backend/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs#88-94). + +### Этап 3: Доставка сообщений +* Реализовать `FederationOutbox` (очередь доставки через Celery/Hangfire или простой BackgroundService). +* Интегрировать `ICommandHandler` с вызовом службы федерации. diff --git a/backend/src/Docs/federation_proxy_storage_implementation.md b/backend/src/Docs/federation_proxy_storage_implementation.md new file mode 100644 index 0000000..ce37830 --- /dev/null +++ b/backend/src/Docs/federation_proxy_storage_implementation.md @@ -0,0 +1,31 @@ +# Архитектура Федеративного Прокси-Хранилища (Remote Proxy Storage) + +Для экономии места и обеспечения суверенитета данных, медиафайлы в федерации Knot Messager **не дублируются** на сервере-получателе. + +## 1. Механизм работы + +1. **Отправка (Sender)**: + * Пользователь на `Server A` загружает файл. Файл сохраняется в S3/Local Storage сервера А. + * В федеративном пакете передается метаданные: `{ "fileId": "uuid", "proxyUrl": "https://server-a.com/api/federation/v1/proxy/uuid" }`. + +2. **Прием (Recipient)**: + * `Server B` получает сообщение. Он **не скачивает** файл себе. + * Для своего клиента Server B генерирует внутреннюю ссылку: `https://server-b.com/api/files/remote/server-a.com/uuid`. + +3. **Просмотр (Client-Recipient)**: + * Когда клиент на Server B хочет посмотреть картинку, он делает запрос на свой `Server B`. + * `Server B` выступает как **Streaming Proxy**: он делает запрос к `Server A` (подписав его системным ключом), получает поток байтов и стримит его клиенту. + +## 2. Безопасность и Валидация + +* **Auth Signature**: Прокси-запрос между серверами обязательно подписывается RSA. Без валидной подписи Server А не отдаст файл. +* **Privacy**: Только серверы участников чата могут проксировать файлы. +* **Zero-Knowledge**: Если файлы зашифрованы E2EE, проксирующий сервер все равно видит только поток зашифрованных байтов. + +--- + +## Задачи на текущую итерацию: + +1. **Federation Endpoints**: Добавить `GET /v1/proxy/{id}` (отдача своего файла другому серверу). +2. **Storage Module**: Добавить `RemoteFileController`, который умеет запрашивать файл у другого сервера и стримить его. +3. **Messaging Integration**: При получении MediaMessage сохранять ссылку на удаленный прокси-хост. diff --git a/backend/src/Docs/storage_module_documentation.md b/backend/src/Docs/storage_module_documentation.md new file mode 100644 index 0000000..346cefd --- /dev/null +++ b/backend/src/Docs/storage_module_documentation.md @@ -0,0 +1,59 @@ +# Модуль Хранилища (Storage) - Техническая Документация + +## 1. Обзор +**Модуль Storage** отвечает за централизованное управление файлами на платформе (аватары, медиа в чатах, вложения в историях). Модуль выступает абстракцией над S3-совместимым объектным хранилищем (в данный момент реализовано через **MinIO**) и интегрируется с криптографическим ядром для обеспечения безопасности файлов. + +Ключевая особенность: модуль реализует концепцию **Zero-Knowledge At-Rest Encryption** — ни один файл не попадает на жесткие диски S3-сервера в открытом виде. + +## 2. Архитектура и Процесс Загрузки + +Сервис [S3FileStorageService](file:///e:/GIT/forkmessager/backend/src/Modules/Storage/Infrastructure/Storage/S3FileStorageService.cs#21-27) (реализация [IFileStorageService](file:///e:/GIT/forkmessager/backend/src/Shared/Knot.Shared.Kernel/Storage/IFileStorageService.cs#6-26) из Shared Kernel) прогоняет файлы через строго определенный пайплайн: + +### 1. Временное буферизирование +Поскольку поток загрузки (HTTP Request Stream) зачастую нельзя читать дважды (отсутствует `Seek`), файл сначала сохраняется во временную директорию ОС. + +### 2. Абсолютная дедупликация (SHA-256) +Модуль вычисляет криптографический `SHA-256` хэш от содержимого загруженного файла. +Именно этот хэш (вместе с расширением) становится именем файла (Object Key) в корзине (Bucket) S3. +* **Результат:** Если тысяча пользователей перешлет друг другу один и тот же мем (файл), физически в S3 он будет загружен и сохранен **ровно один раз**, колоссально экономя место на серверах. + +### 3. "На лету" Шифрование (E2EE/At-Rest) +Далее применяется `IEncryptionService` (обычно AES-256-CBC). +На основе временного файла открывается `CryptoStream`, который шифрует байты файла "на лету" во второй временный файл. Случайный Вектор Инициализации (Initalization Vector, `IV`) генерируется сервисом уникально для каждого файла. + +### 4. Загрузка в S3 с Метаданными +Зашифрованный бинарник улетает в MinIO. Чувствительная информация сохраняется **совершенно безопасно** в заголовках метаданных объекта (MinIO Object MetaData): +* `ContentType`: Исходный MIME-тип (например, `image/png`). +* `OriginalFileName`: Закодированное исходное имя файла (Urlescaped). +* `IV`: Вектор инициализации (Base64), необходимый для последующей расшифровки. Без секретного мастер-ключа приложения этот IV бесполезен. +* `EncryptionAlgorithm`: Маркер алгоритма (`AES-256-CBC`). + +В конце цикла бэкенд зачищает локальные временные файлы. + +## 3. Процесс Скачивания и Кэширование (API) + +Модуль предоставляет минималистичный REST API интерфейс, реализованный через **Carter** ([FilesEndpoints](file:///e:/GIT/forkmessager/backend/src/Modules/Storage/Presentation/Endpoints/FilesEndpoints.cs#11-54)). + +### Эндпоинты +| Метод | Маршрут | Параметры | Описание | +| :--- | :--- | :--- | :--- | +| `GET` | `/api/files/{id}` | `?download=true` | Отдает расшифрованный файл. Поддерживает `Range` запросы для стриминга видео и музыки. | + +### Механизм In-Memory Caching (Производительность) +Расшифровка файлов (особенно видео) в реальном времени и постоянный запрос их из S3 съедает процессорное время и сетевой канал бэкенда. +К эндпоинту скачивания подключен `IMemoryCache`: +1. При первом запросе файла (например, аватара популярного пользователя), он скачивается из MinIO во временный файл ОС на бэкенде. +2. Файл прогоняется через `CryptoStream` (с использованием `IV` из метаданных корзины S3) для дешифровки и выгрузки в `MemoryStream`. +3. Байты готового файла, его `ContentType` и исходное имя кэшируются в RAM бэкенда на **30 минут** (Sliding Expiration). +4. Все последующие запросы отдают готовые байты прямо из оперативной памяти без сетевого похода в MinIO и без повторной расшифровки. + +### Поддержка Range-запросов +API использует метод `Results.File(..., enableRangeProcessing: true)`. Это означает, что плееры в браузере или мобильном клиенте могут запрашивать видео кусками (например, `Range: bytes=1000-2000`), и бэкенд корректно отдаст только нужный чанк (из кэша или после скачивания), что делает модуль пригодным для видео-стриминга прямо из коробки. + +--- + +## Итог +Модуль представляет собой пуленепробиваемый шлюз для работы с медиа: +* Обеспечивает полную конфиденциальность (ни один файл не хранится открытым в корзине облака). +* Защищен от дубликатов (переиспользование за счет SHA-256 хэшей). +* Высоко оптимизирован для раздачи горячего контента (In-Memory кэширование и поддержка HTTP Range Streaming). diff --git a/backend/src/Host/Program.cs b/backend/src/Host/Program.cs index 71f7da1..71e70aa 100644 --- a/backend/src/Host/Program.cs +++ b/backend/src/Host/Program.cs @@ -150,8 +150,8 @@ using (var scope = app.Services.CreateScope()) { var identityDb = scope.ServiceProvider.GetRequiredService(); await identityDb.Database.MigrateAsync(); - - var chatsDb = scope.ServiceProvider.GetRequiredService(); + + var chatsDb = scope.ServiceProvider.GetRequiredService(); await chatsDb.Database.MigrateAsync(); var systemDb = scope.ServiceProvider.GetRequiredService(); diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/UpdateSettings/UpdateSettingsCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/UpdateSettings/UpdateSettingsCommand.cs index 6fe3717..1b3b3b3 100644 --- a/backend/src/Modules/Admin/Application/Admin/Commands/UpdateSettings/UpdateSettingsCommand.cs +++ b/backend/src/Modules/Admin/Application/Admin/Commands/UpdateSettings/UpdateSettingsCommand.cs @@ -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 { 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> 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); } } \ No newline at end of file diff --git a/backend/src/Modules/Admin/Domain/Events/SystemSettingsUpdatedDomainEvent.cs b/backend/src/Modules/Admin/Domain/Events/SystemSettingsUpdatedDomainEvent.cs new file mode 100644 index 0000000..33f74e6 --- /dev/null +++ b/backend/src/Modules/Admin/Domain/Events/SystemSettingsUpdatedDomainEvent.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Configuration; + +namespace Knot.Modules.Admin.Domain.Events; + +/// +/// Доменное событие: глобальные настройки системы обновлены. +/// +public sealed record SystemSettingsUpdatedDomainEvent(SystemSettingsDto Settings) : IDomainEvent; diff --git a/backend/src/Modules/Auth/Application/Users/Register/RegisterUserCommandHandler.cs b/backend/src/Modules/Auth/Application/Users/Register/RegisterUserCommandHandler.cs index a5f966e..5d2ce71 100644 --- a/backend/src/Modules/Auth/Application/Users/Register/RegisterUserCommandHandler.cs +++ b/backend/src/Modules/Auth/Application/Users/Register/RegisterUserCommandHandler.cs @@ -25,13 +25,13 @@ public sealed class RegisterUserCommandHandler : ICommandHandler> SearchUsersAsync(string query, CancellationToken cancellationToken = default); void Add(User user); void Update(User user); + void Remove(User user); } diff --git a/backend/src/Modules/Auth/Domain/User.cs b/backend/src/Modules/Auth/Domain/User.cs index 0820d44..249bd9a 100644 --- a/backend/src/Modules/Auth/Domain/User.cs +++ b/backend/src/Modules/Auth/Domain/User.cs @@ -2,6 +2,8 @@ using Knot.Shared.Kernel; namespace Knot.Modules.Auth.Domain; +public sealed record UserStatusChangedDomainEvent(Guid UserId, bool IsOnline, DateTime LastSeen) : IDomainEvent; + /// /// Сущность пользователя в контексте идентификации (Identity). /// @@ -16,6 +18,11 @@ public sealed class User : AggregateRoot 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 return new User(Guid.NewGuid(), username, passwordHash, displayName, email, bio); } + /// + /// Создает "теневого" пользователя для контакта с другого сервера. + /// + 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 { 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; + } } diff --git a/backend/src/Modules/Auth/Infrastructure/Persistence/UserRepository.cs b/backend/src/Modules/Auth/Infrastructure/Persistence/UserRepository.cs index 91835b8..43cc96f 100644 --- a/backend/src/Modules/Auth/Infrastructure/Persistence/UserRepository.cs +++ b/backend/src/Modules/Auth/Infrastructure/Persistence/UserRepository.cs @@ -53,5 +53,10 @@ public sealed class UserRepository : IUserRepository { _context.Users.Update(user); } + + public void Remove(User user) + { + _context.Users.Remove(user); + } } diff --git a/backend/src/Modules/Conversations/Application/Folders/Commands/AddToFolder/AddToFolderCommand.cs b/backend/src/Modules/Conversations/Application/Folders/Commands/AddToFolder/AddToFolderCommand.cs new file mode 100644 index 0000000..4c7ba71 --- /dev/null +++ b/backend/src/Modules/Conversations/Application/Folders/Commands/AddToFolder/AddToFolderCommand.cs @@ -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 FolderIds) : ICommand; + +internal sealed class AddToFolderCommandHandler : ICommandHandler +{ + 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 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(); + } +} diff --git a/backend/src/Modules/Conversations/Application/Folders/Commands/RemoveFromFolder/RemoveFromFolderCommand.cs b/backend/src/Modules/Conversations/Application/Folders/Commands/RemoveFromFolder/RemoveFromFolderCommand.cs new file mode 100644 index 0000000..d136ffb --- /dev/null +++ b/backend/src/Modules/Conversations/Application/Folders/Commands/RemoveFromFolder/RemoveFromFolderCommand.cs @@ -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 FolderIds) : ICommand; + +internal sealed class RemoveFromFolderCommandHandler : ICommandHandler +{ + private readonly IUserChatSettingsRepository _settingsRepository; + private readonly IChatsUnitOfWork _unitOfWork; + + public RemoveFromFolderCommandHandler(IUserChatSettingsRepository settingsRepository, IChatsUnitOfWork unitOfWork) + { + _settingsRepository = settingsRepository; + _unitOfWork = unitOfWork; + } + + public async Task 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(); + } +} diff --git a/backend/src/Modules/Conversations/Application/Messages/Delete/DeleteMessagesCommand.cs b/backend/src/Modules/Conversations/Application/Messages/Delete/DeleteMessagesCommand.cs index b0a2563..8458b6b 100644 --- a/backend/src/Modules/Conversations/Application/Messages/Delete/DeleteMessagesCommand.cs +++ b/backend/src/Modules/Conversations/Application/Messages/Delete/DeleteMessagesCommand.cs @@ -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 { - private readonly IMessageRepository _messageRepository; + private readonly MessagingMessageRepository _messageRepository; private readonly IChatsUnitOfWork _unitOfWork; private readonly IHubContext _hubContext; public DeleteMessagesCommandHandler( - IMessageRepository messageRepository, + MessagingMessageRepository messageRepository, IChatsUnitOfWork unitOfWork, IHubContext hubContext) { @@ -67,7 +66,6 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler; + string? StoryMediaType = null, + List? PollOptions = null, + bool? PollIsAnonymous = null, + bool? PollAllowMultipleAnswers = null, + DateTime? PollExpiresAt = null) : ICommand; public sealed class SendMessageCommandHandler : ICommandHandler { @@ -30,17 +35,20 @@ public sealed class SendMessageCommandHandler : ICommandHandler> Handle(SendMessageCommand request, CancellationToken cancellationToken) @@ -62,6 +70,8 @@ public sealed class SendMessageCommandHandler : ICommandHandler(ChatErrors.MediaDisabled); + var parsedStoryMediaType = Enum.TryParse(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image; message = new StoryMessage( Guid.NewGuid(), @@ -78,6 +88,8 @@ public sealed class SendMessageCommandHandler : ICommandHandler(ChatErrors.MediaDisabled); + var firstAtt = request.Attachments.First(); var parsedType = Enum.TryParse(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File; @@ -98,6 +110,24 @@ public sealed class SendMessageCommandHandler : ICommandHandler(ChatErrors.PollsDisabled); + + message = new PollMessage( + Guid.NewGuid(), + request.ChatId, + request.SenderId, + request.Content ?? "Poll", + request.PollOptions ?? new List(), + request.PollIsAnonymous ?? true, + request.PollAllowMultipleAnswers ?? false, + request.PollExpiresAt, + request.ReplyToId, + request.ForwardedFromId, + DateTime.UtcNow, + false); + } else { message = new TextMessage( diff --git a/backend/src/Modules/Conversations/Application/Messages/UploadFile/UploadFileCommand.cs b/backend/src/Modules/Conversations/Application/Messages/UploadFile/UploadFileCommand.cs index 8303fe4..b5d836d 100644 --- a/backend/src/Modules/Conversations/Application/Messages/UploadFile/UploadFileCommand.cs +++ b/backend/src/Modules/Conversations/Application/Messages/UploadFile/UploadFileCommand.cs @@ -16,9 +16,9 @@ public record UploadFileCommand(string FileName, string ContentType, long Length internal sealed class UploadFileCommandHandler : ICommandHandler { 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(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(ChatErrors.FileTooLarge(maxMb)); } diff --git a/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs b/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs new file mode 100644 index 0000000..0c1b46d --- /dev/null +++ b/backend/src/Modules/Conversations/Application/Users/Commands/DeleteUser/DeleteUserCommand.cs @@ -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 +{ + 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 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; + } +} diff --git a/backend/src/Modules/Conversations/DependencyInjection.cs b/backend/src/Modules/Conversations/DependencyInjection.cs index 5e15c74..cca4e06 100644 --- a/backend/src/Modules/Conversations/DependencyInjection.cs +++ b/backend/src/Modules/Conversations/DependencyInjection.cs @@ -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(sp => sp.GetRequiredService()); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Messaging Repository registration (might be redundant if already in Messaging module, but needed for specific commands in Conversations) + services.AddScoped(); // MediatR services.AddMediatR(config => diff --git a/backend/src/Modules/Conversations/Domain/ChatErrors.cs b/backend/src/Modules/Conversations/Domain/ChatErrors.cs index 0877f6b..5ae9405 100644 --- a/backend/src/Modules/Conversations/Domain/ChatErrors.cs +++ b/backend/src/Modules/Conversations/Domain/ChatErrors.cs @@ -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."); diff --git a/backend/src/Modules/Conversations/Domain/Folder.cs b/backend/src/Modules/Conversations/Domain/Folder.cs new file mode 100644 index 0000000..b9a0545 --- /dev/null +++ b/backend/src/Modules/Conversations/Domain/Folder.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using Knot.Shared.Kernel; + +namespace Knot.Modules.Conversations.Domain; + +/// +/// Сущность папки для группировки чатов. +/// +public sealed class Folder : AggregateRoot +{ + 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 // Пользовательская +} + +/// +/// Настройки конкретного чата для конкретного пользователя. +/// Хранятся в PostgreSQL (связь User <-> Chat). +/// +public sealed class UserChatSettings : Entity +{ + public Guid UserId { get; private set; } + public Guid ChatId { get; private set; } + + // Список папок, в которые входит чат для этого пользователя + private readonly List _folderIds = new(); + public IReadOnlyCollection 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; +} + +/// +/// Глобальные настройки папок пользователя (скрытие дефолтных и т.д.). +/// Будет храниться в MongoDB. +/// +public sealed class UserFolderSettings : AggregateRoot +{ + public Guid UserId { get; private set; } + + // Список ID папок, которые пользователь скрыл (только для дефолтных) + public List HiddenDefaultFolderIds { get; private set; } = new(); + + // Список пользовательских папок (Guid созданных Folder) + public List 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); + } +} diff --git a/backend/src/Modules/Conversations/Domain/IChatRepository.cs b/backend/src/Modules/Conversations/Domain/IChatRepository.cs index 4eb7698..e2b5335 100644 --- a/backend/src/Modules/Conversations/Domain/IChatRepository.cs +++ b/backend/src/Modules/Conversations/Domain/IChatRepository.cs @@ -12,3 +12,28 @@ public interface IChatRepository Task> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken); } +public interface IFolderRepository +{ + void Add(Folder folder); + void Update(Folder folder); + void Remove(Folder folder); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken); + Task> GetUserFoldersAsync(Guid userId, CancellationToken cancellationToken); +} + +public interface IUserChatSettingsRepository +{ + void Add(UserChatSettings settings); + void Update(UserChatSettings settings); + void Remove(UserChatSettings settings); + void RemoveRange(IEnumerable settings); + Task GetAsync(Guid userId, Guid chatId, CancellationToken cancellationToken); + Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken); +} + +public interface IUserFolderSettingsRepository +{ + Task GetByUserIdAsync(Guid userId, CancellationToken cancellationToken); + Task UpdateAsync(UserFolderSettings settings, CancellationToken cancellationToken); + Task RemoveByUserIdAsync(Guid userId, CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Conversations/Infrastructure/Persistence/ChatRepository.cs b/backend/src/Modules/Conversations/Infrastructure/Persistence/ChatRepository.cs index 3fe4cad..06d4c8b 100644 --- a/backend/src/Modules/Conversations/Infrastructure/Persistence/ChatRepository.cs +++ b/backend/src/Modules/Conversations/Infrastructure/Persistence/ChatRepository.cs @@ -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 GetByIdAsync(Guid id, CancellationToken cancellationToken) + { + return await _dbContext.Folders.FirstOrDefaultAsync(f => f.Id == id, cancellationToken); + } + + public async Task> 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 settings) => _dbContext.UserChatSettings.RemoveRange(settings); + + public async Task GetAsync(Guid userId, Guid chatId, CancellationToken cancellationToken) + { + return await _dbContext.UserChatSettings + .FirstOrDefaultAsync(s => s.UserId == userId && s.ChatId == chatId, cancellationToken); + } + + public async Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken) + { + return await _dbContext.UserChatSettings + .Where(s => s.UserId == userId) + .ToListAsync(cancellationToken); + } +} + diff --git a/backend/src/Modules/Conversations/Infrastructure/Persistence/ConversationsDbContext.cs b/backend/src/Modules/Conversations/Infrastructure/Persistence/ConversationsDbContext.cs index 6d56121..4b73e1c 100644 --- a/backend/src/Modules/Conversations/Infrastructure/Persistence/ConversationsDbContext.cs +++ b/backend/src/Modules/Conversations/Infrastructure/Persistence/ConversationsDbContext.cs @@ -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 Chats => Set(); - - + public DbSet Folders => Set(); + public DbSet UserChatSettings => Set(); 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(); - 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(builder => + { + builder.ToTable("Folders"); + builder.HasKey(f => f.Id); + builder.Property(f => f.Type).HasConversion(); + }); + + modelBuilder.Entity(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 SaveChangesAsync(CancellationToken cancellationToken = default) { - // 1. Получаем все события из агрегатов var domainEvents = ChangeTracker .Entries() .SelectMany(x => - { if (x.Entity is AggregateRoot 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; } } - diff --git a/backend/src/Modules/Conversations/Infrastructure/Persistence/Mongo/ConversationsMongoDbMapConfigurator.cs b/backend/src/Modules/Conversations/Infrastructure/Persistence/Mongo/ConversationsMongoDbMapConfigurator.cs new file mode 100644 index 0000000..6813fdd --- /dev/null +++ b/backend/src/Modules/Conversations/Infrastructure/Persistence/Mongo/ConversationsMongoDbMapConfigurator.cs @@ -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(cm => + { + cm.AutoMap(); + cm.SetDiscriminator("UserFolderSettings"); + }); + } + + _initialized = true; + } +} diff --git a/backend/src/Modules/Conversations/Infrastructure/Persistence/Mongo/UserFolderSettingsRepository.cs b/backend/src/Modules/Conversations/Infrastructure/Persistence/Mongo/UserFolderSettingsRepository.cs new file mode 100644 index 0000000..e1d6442 --- /dev/null +++ b/backend/src/Modules/Conversations/Infrastructure/Persistence/Mongo/UserFolderSettingsRepository.cs @@ -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 _collection; + + public UserFolderSettingsRepository(IMongoDatabase database) + { + _collection = database.GetCollection("user_folder_settings"); + } + + public async Task 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); + } +} diff --git a/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj b/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj index 8d94180..2567e9c 100644 --- a/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj +++ b/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj @@ -3,6 +3,7 @@ + @@ -35,4 +36,3 @@ - diff --git a/backend/src/Modules/Conversations/conversations_errors.json b/backend/src/Modules/Conversations/conversations_errors.json new file mode 100644 index 0000000..c790269 --- /dev/null +++ b/backend/src/Modules/Conversations/conversations_errors.json @@ -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" + ] + } + } + } + } + ] +} \ No newline at end of file diff --git a/backend/src/Modules/Conversations/errors.json b/backend/src/Modules/Conversations/errors.json new file mode 100644 index 0000000..c3f517a --- /dev/null +++ b/backend/src/Modules/Conversations/errors.json @@ -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" + ] + } + } + } + } + ] +} \ No newline at end of file diff --git a/backend/src/Modules/Federation/Application/Abstractions/IFederationGateway.cs b/backend/src/Modules/Federation/Application/Abstractions/IFederationGateway.cs new file mode 100644 index 0000000..6ddcafd --- /dev/null +++ b/backend/src/Modules/Federation/Application/Abstractions/IFederationGateway.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Shared.Kernel; + +namespace Knot.Modules.Federation.Application.Abstractions; + +/// +/// Шлюз для безопасной передачи данных между серверами Knot Messager. +/// +public interface IFederationGateway +{ + /// + /// Отправляет пакет на конкретный домен. + /// + Task SendPacketAsync(FederationMessagePacket packet, string targetDomain, CancellationToken ct = default); +} + +/// +/// Федеративный пакет сообщения с гибридным шифрованием. +/// +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 пользователя-исполнителя (для реакций и т.д.) +); diff --git a/backend/src/Modules/Federation/Application/Federation/Commands/HandshakeFederationCommand.cs b/backend/src/Modules/Federation/Application/Federation/Commands/HandshakeFederationCommand.cs index ee9f336..2b8a4dd 100644 --- a/backend/src/Modules/Federation/Application/Federation/Commands/HandshakeFederationCommand.cs +++ b/backend/src/Modules/Federation/Application/Federation/Commands/HandshakeFederationCommand.cs @@ -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; internal sealed class HandshakeFederationCommandHandler : ICommandHandler { - private readonly ISettingsService _settings; + private readonly ISettingsService _settingsService; - public HandshakeFederationCommandHandler(ISettingsService settings) + public HandshakeFederationCommandHandler(ISettingsService settingsService) { - _settings = settings; + _settingsService = settingsService; } - public Task> Handle(HandshakeFederationCommand request, CancellationToken cancellationToken) + public async Task> Handle(HandshakeFederationCommand request, CancellationToken cancellationToken) { - var conf = _settings.Current; - if (!conf.EnableConfederation) - return Task.FromResult(Result.Failure(new Error(Errors.DisabledByAdmin, "Federation is disabled"))); + var allSettings = await _settingsService.GetSettingsAsync(cancellationToken); + var conf = allSettings.Federation; + + if (!conf.Enabled) + return Result.Failure(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(DomainErrors.RequestInvalid)); + return Result.Failure(new Error("Request.Invalid", "Domain and PublicKey required.")); - var allowedList = conf.AllowedDomains?.Select(d => d.Trim().ToLower()).ToList() ?? new System.Collections.Generic.List(); - if (!allowedList.Contains(request.Request.Domain.ToLowerInvariant())) - return Task.FromResult(Result.Failure(StoryErrors.Unauthorized)); + // Проверка разрешенности домена (белый список) + var allowedList = conf.AllowedDomains ?? new List(); + var domainConfig = allowedList.FirstOrDefault(d => d.IsEnabled && d.Domain.Equals(request.Request.Domain, StringComparison.OrdinalIgnoreCase)); + + if (domainConfig == null) + return Result.Failure(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); } } \ No newline at end of file diff --git a/backend/src/Modules/Federation/Application/Federation/Commands/InboundFederationCommand.cs b/backend/src/Modules/Federation/Application/Federation/Commands/InboundFederationCommand.cs new file mode 100644 index 0000000..8b5ec1c --- /dev/null +++ b/backend/src/Modules/Federation/Application/Federation/Commands/InboundFederationCommand.cs @@ -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 +{ + 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 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(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(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(); + } +} diff --git a/backend/src/Modules/Federation/Application/Federation/Commands/ResolveUserCommand.cs b/backend/src/Modules/Federation/Application/Federation/Commands/ResolveUserCommand.cs new file mode 100644 index 0000000..b616241 --- /dev/null +++ b/backend/src/Modules/Federation/Application/Federation/Commands/ResolveUserCommand.cs @@ -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; + +internal sealed class ResolveUserCommandHandler : ICommandHandler +{ + private readonly IUserRepository _userRepository; + + public ResolveUserCommandHandler(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task> Handle(ResolveUserCommand request, CancellationToken cancellationToken) + { + var user = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken); + + if (user == null || user.IsExternal) + { + return Result.Failure(new Error("Federation.UserNotFound", "User not found on this server.")); + } + + return Result.Success(new ResolveUserResponse( + user.Id, + user.Username, + user.DisplayName, + user.Avatar + )); + } +} diff --git a/backend/src/Modules/Federation/Application/Federation/Events/FederatedMessageActionsHandler.cs b/backend/src/Modules/Federation/Application/Federation/Events/FederatedMessageActionsHandler.cs new file mode 100644 index 0000000..f72af46 --- /dev/null +++ b/backend/src/Modules/Federation/Application/Federation/Events/FederatedMessageActionsHandler.cs @@ -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; + +/// +/// Универсальный обработчик действий с сообщениями (Edit, Delete, Reactions) для Федерации. +/// +public sealed class FederatedMessageActionsHandler : + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler +{ + 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> 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(); + } +} diff --git a/backend/src/Modules/Federation/Application/Federation/Events/MessageSentDomainEventHandler.cs b/backend/src/Modules/Federation/Application/Federation/Events/MessageSentDomainEventHandler.cs new file mode 100644 index 0000000..dcc262c --- /dev/null +++ b/backend/src/Modules/Federation/Application/Federation/Events/MessageSentDomainEventHandler.cs @@ -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; + +/// +/// Обработчик доменного события отправки сообщения. +/// Если в чате есть внешние участники — инициирует федеративную рассылку. +/// +public sealed class MessageSentDomainEventHandler : INotificationHandler +{ + 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); + } + } + } +} diff --git a/backend/src/Modules/Federation/Application/Federation/Events/SystemSettingsUpdatedDomainEventHandler.cs b/backend/src/Modules/Federation/Application/Federation/Events/SystemSettingsUpdatedDomainEventHandler.cs new file mode 100644 index 0000000..fe36c19 --- /dev/null +++ b/backend/src/Modules/Federation/Application/Federation/Events/SystemSettingsUpdatedDomainEventHandler.cs @@ -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; + +/// +/// Обработчик события обновления настроек. +/// Уведомляет все удаленные серверы о смене способностей (Capabilities). +/// +public sealed class SystemSettingsUpdatedDomainEventHandler : INotificationHandler +{ + 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); + } + } + } +} diff --git a/backend/src/Modules/Federation/Application/Federation/Events/UserStatusChangedDomainEventHandler.cs b/backend/src/Modules/Federation/Application/Federation/Events/UserStatusChangedDomainEventHandler.cs new file mode 100644 index 0000000..37d8572 --- /dev/null +++ b/backend/src/Modules/Federation/Application/Federation/Events/UserStatusChangedDomainEventHandler.cs @@ -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; + +/// +/// Обработчик изменения статуса пользователя. +/// Рассылает новый статус всем серверам, где у пользователя есть чаты. +/// +public sealed class UserStatusChangedDomainEventHandler : INotificationHandler +{ + 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); + } + } + } +} diff --git a/backend/src/Modules/Federation/Application/Federation/Services/FederationPacketService.cs b/backend/src/Modules/Federation/Application/Federation/Services/FederationPacketService.cs new file mode 100644 index 0000000..0bd47e1 --- /dev/null +++ b/backend/src/Modules/Federation/Application/Federation/Services/FederationPacketService.cs @@ -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; + +/// +/// Сервис для подготовки и рассылки зашифрованных федератовных пакетов. +/// +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; + } + + /// + /// Собирает зашифрованный пакет для целевого домена. + /// + public async Task> 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(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); + } +} diff --git a/backend/src/Modules/Federation/Infrastructure/Services/FederationGateway.cs b/backend/src/Modules/Federation/Infrastructure/Services/FederationGateway.cs new file mode 100644 index 0000000..ab98482 --- /dev/null +++ b/backend/src/Modules/Federation/Infrastructure/Services/FederationGateway.cs @@ -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; + +/// +/// Реализация шлюза федерации с гибридным шифрованием (AES-RSA). +/// +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 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)); + } + } +} diff --git a/backend/src/Modules/Federation/Presentation/Endpoints/FederationEndpoints.cs b/backend/src/Modules/Federation/Presentation/Endpoints/FederationEndpoints.cs index a976e15..181a3de 100644 --- a/backend/src/Modules/Federation/Presentation/Endpoints/FederationEndpoints.cs +++ b/backend/src/Modules/Federation/Presentation/Endpoints/FederationEndpoints.cs @@ -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; /// -/// Регистрация эндпоинтов федерации. +/// Эндпоинты федерации для межсерверного взаимодействия. /// 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(); + } }); } } \ No newline at end of file diff --git a/backend/src/Modules/Klipy/Application/Klipy/Queries/GetTrendingGifsQuery.cs b/backend/src/Modules/Klipy/Application/Klipy/Queries/GetTrendingGifsQuery.cs index 862acc6..d8af201 100644 --- a/backend/src/Modules/Klipy/Application/Klipy/Queries/GetTrendingGifsQuery.cs +++ b/backend/src/Modules/Klipy/Application/Klipy/Queries/GetTrendingGifsQuery.cs @@ -16,11 +16,11 @@ public record GetTrendingGifsQuery : IQuery; internal sealed class GetTrendingGifsQueryHandler : IQueryHandler { - 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> 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(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(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); } diff --git a/backend/src/Modules/Klipy/Application/Klipy/Queries/SearchGifsQuery.cs b/backend/src/Modules/Klipy/Application/Klipy/Queries/SearchGifsQuery.cs index bbb8bd8..007bc59 100644 --- a/backend/src/Modules/Klipy/Application/Klipy/Queries/SearchGifsQuery.cs +++ b/backend/src/Modules/Klipy/Application/Klipy/Queries/SearchGifsQuery.cs @@ -16,11 +16,11 @@ public record SearchGifsQuery(string Query) : IQuery; internal sealed class SearchGifsQueryHandler : IQueryHandler { - 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> 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(new Error(Errors.KlipyNotConfigured, "Klipy is not configured")); if (string.IsNullOrWhiteSpace(request.Query)) return Result.Failure(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(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); } diff --git a/backend/src/Modules/Messaging/Domain/FederatedMessagingEvents.cs b/backend/src/Modules/Messaging/Domain/FederatedMessagingEvents.cs new file mode 100644 index 0000000..463dd6d --- /dev/null +++ b/backend/src/Modules/Messaging/Domain/FederatedMessagingEvents.cs @@ -0,0 +1,24 @@ +using System; +using Knot.Shared.Kernel; + +namespace Knot.Modules.Messaging.Domain; + +/// +/// Доменное событие: сообщение отредактировано. +/// +public sealed record MessageEditedDomainEvent(Guid MessageId, Guid ChatId, string NewContent) : IDomainEvent; + +/// +/// Доменное событие: сообщение удалено. +/// +public sealed record MessageDeletedDomainEvent(Guid MessageId, Guid ChatId) : IDomainEvent; + +/// +/// Доменное событие: добавлена реакция. +/// +public sealed record MessageReactionAddedDomainEvent(Guid MessageId, Guid ChatId, Guid UserId, string Emoji) : IDomainEvent; + +/// +/// Доменное событие: удалена реакция. +/// +public sealed record MessageReactionRemovedDomainEvent(Guid MessageId, Guid ChatId, Guid UserId, string Emoji) : IDomainEvent; diff --git a/backend/src/Modules/Messaging/Domain/IMessageRepository.cs b/backend/src/Modules/Messaging/Domain/IMessageRepository.cs index 45c2062..84cf29f 100644 --- a/backend/src/Modules/Messaging/Domain/IMessageRepository.cs +++ b/backend/src/Modules/Messaging/Domain/IMessageRepository.cs @@ -14,6 +14,10 @@ public interface IMessageRepository Task GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken); Task UpdateAsync(Message message, CancellationToken cancellationToken); + Task> GetAllMessagesAsync(CancellationToken cancellationToken); + Task DeleteChatMessagesAsync(Guid chatId, CancellationToken cancellationToken); + Task DeleteUserMessagesAsync(Guid userId, CancellationToken cancellationToken); + Task RemoveAsync(Guid id, CancellationToken cancellationToken); } diff --git a/backend/src/Modules/Messaging/Domain/MediaMessage.cs b/backend/src/Modules/Messaging/Domain/MediaMessage.cs index 42a8856..e250327 100644 --- a/backend/src/Modules/Messaging/Domain/MediaMessage.cs +++ b/backend/src/Modules/Messaging/Domain/MediaMessage.cs @@ -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() diff --git a/backend/src/Modules/Messaging/Domain/Message.cs b/backend/src/Modules/Messaging/Domain/Message.cs index 7acf6d0..1f8db94 100644 --- a/backend/src/Modules/Messaging/Domain/Message.cs +++ b/backend/src/Modules/Messaging/Domain/Message.cs @@ -74,6 +74,14 @@ public abstract class Message : AggregateRoot 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) diff --git a/backend/src/Modules/Messaging/Domain/PollMessage.cs b/backend/src/Modules/Messaging/Domain/PollMessage.cs new file mode 100644 index 0000000..da5b2a8 --- /dev/null +++ b/backend/src/Modules/Messaging/Domain/PollMessage.cs @@ -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 _options = new(); + public IReadOnlyCollection Options => _options.AsReadOnly(); + + private List _votes = new(); + public IReadOnlyCollection Votes => _votes.AsReadOnly(); + + // Инфраструктурный конструктор + private PollMessage() : base() { } + + public PollMessage( + Guid id, + Guid chatId, + Guid senderId, + string question, + List 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 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; + } +} diff --git a/backend/src/Modules/Messaging/Domain/TextMessage.cs b/backend/src/Modules/Messaging/Domain/TextMessage.cs index 2c3a487..554de0b 100644 --- a/backend/src/Modules/Messaging/Domain/TextMessage.cs +++ b/backend/src/Modules/Messaging/Domain/TextMessage.cs @@ -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() diff --git a/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageReactionRepository.cs b/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageReactionRepository.cs index 19498e5..6e6d2a3 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageReactionRepository.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageReactionRepository.cs @@ -6,10 +6,14 @@ namespace Knot.Modules.Messaging.Infrastructure.Persistence; public sealed class MessageReactionRepository : IMessageReactionRepository { private readonly IMongoCollection _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("message_reactions"); + _mediator = mediator; + _messageRepository = messageRepository; // Ensure index for fast querying by message var indexKeysDefinition = Builders.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> GetReactionsForMessageAsync(Guid messageId, CancellationToken cancellationToken) diff --git a/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs b/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs index 605721a..767f35e 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs @@ -129,6 +129,29 @@ public sealed class MessageRepository : IMessageRepository await _mediator.Publish(domainEvent, cancellationToken); } } + + public async Task> GetAllMessagesAsync(CancellationToken cancellationToken) + { + return await _messages.Find(_ => true).ToListAsync(cancellationToken); + } + + public async Task DeleteChatMessagesAsync(Guid chatId, CancellationToken cancellationToken) + { + var filter = Builders.Filter.Eq(m => m.ChatId, chatId); + await _messages.DeleteManyAsync(filter, cancellationToken); + } + + public async Task DeleteUserMessagesAsync(Guid userId, CancellationToken cancellationToken) + { + var filter = Builders.Filter.Eq(m => m.SenderId, userId); + await _messages.DeleteManyAsync(filter, cancellationToken); + } + + public async Task RemoveAsync(Guid id, CancellationToken cancellationToken) + { + var filter = Builders.Filter.Eq(m => m.Id, id); + await _messages.DeleteOneAsync(filter, cancellationToken); + } } diff --git a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs index 26c7077..5a13865 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs @@ -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(cm => cm.AutoMap()); BsonClassMap.RegisterClassMap(cm => cm.AutoMap()); + BsonClassMap.RegisterClassMap(cm => + { + cm.AutoMap(); + cm.MapField("_options").SetElementName("Options"); + cm.MapField("_votes").SetElementName("Votes"); + cm.SetDiscriminator("PollMessage"); + }); + + BsonClassMap.RegisterClassMap(cm => cm.AutoMap()); + BsonClassMap.RegisterClassMap(cm => cm.AutoMap()); + BsonClassMap.RegisterClassMap(cm => { diff --git a/backend/src/Modules/Relations/Domain/UserReplica.cs b/backend/src/Modules/Relations/Domain/UserReplica.cs index 452886a..04c68d5 100644 --- a/backend/src/Modules/Relations/Domain/UserReplica.cs +++ b/backend/src/Modules/Relations/Domain/UserReplica.cs @@ -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; } } diff --git a/backend/src/Modules/Settings/Application/Config/DTOs/PublicConfigDto.cs b/backend/src/Modules/Settings/Application/Config/DTOs/PublicConfigDto.cs index e13d239..71bd0f2 100644 --- a/backend/src/Modules/Settings/Application/Config/DTOs/PublicConfigDto.cs +++ b/backend/src/Modules/Settings/Application/Config/DTOs/PublicConfigDto.cs @@ -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 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 AllowedDomains { get; init; } = new(); } \ No newline at end of file diff --git a/backend/src/Modules/Storage/Presentation/Endpoints/FilesEndpoints.cs b/backend/src/Modules/Storage/Presentation/Endpoints/FilesEndpoints.cs index fe174fb..8e106d6 100644 --- a/backend/src/Modules/Storage/Presentation/Endpoints/FilesEndpoints.cs +++ b/backend/src/Modules/Storage/Presentation/Endpoints/FilesEndpoints.cs @@ -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); + }); } } diff --git a/backend/src/Modules/Stories/Application/Stories/Queries/GetUserStories/GetUserStoriesQuery.cs b/backend/src/Modules/Stories/Application/Stories/Queries/GetUserStories/GetUserStoriesQuery.cs index 8865437..51f267f 100644 --- a/backend/src/Modules/Stories/Application/Stories/Queries/GetUserStories/GetUserStoriesQuery.cs +++ b/backend/src/Modules/Stories/Application/Stories/Queries/GetUserStories/GetUserStoriesQuery.cs @@ -33,6 +33,16 @@ internal sealed class GetUserStoriesQueryHandler : IQueryHandler> Handle(GetUserStoriesQuery request, CancellationToken cancellationToken) { + var user = await _userRepository.GetByIdAsync(request.TargetUserId, cancellationToken); + if (user == null) + return Result.Failure(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 v.StoryId) .ToListAsync(cancellationToken); - var user = await _userRepository.GetByIdAsync(request.TargetUserId, cancellationToken); - if (user == null) - return Result.Failure(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> GetFederatedStories(Knot.Modules.Relations.Domain.UserReplica user, Guid currentUserId, CancellationToken ct) + { + // В будущем: Реальный вызов через HttpClient к удаленному домену + // return await _federationGateway.FetchStoriesAsync(user.Domain, user.Id); + return Result.Failure(new Error("Federation.StoriesNotImplemented", "Stories from this domain are temporarily unavailable.")); + } } diff --git a/backend/src/Modules/WebRtc/Application/WebRtc/Queries/GetIceServersQuery.cs b/backend/src/Modules/WebRtc/Application/WebRtc/Queries/GetIceServersQuery.cs index ff15eea..f5b55c7 100644 --- a/backend/src/Modules/WebRtc/Application/WebRtc/Queries/GetIceServersQuery.cs +++ b/backend/src/Modules/WebRtc/Application/WebRtc/Queries/GetIceServersQuery.cs @@ -16,9 +16,9 @@ public record GetIceServersQuery : IQuery; internal sealed class GetIceServersQueryHandler : IQueryHandler { 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> Handle(GetIceServersQuery request, CancellationToken cancellationToken) { var settings = _settingsService.Current; - if (!settings.EnableCalls) + if (!settings.Enabled) { return Task.FromResult(Result.Failure(new Error( Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin, diff --git a/backend/src/Shared/Knot.Shared.Infrastructure/Configuration/SettingsService.cs b/backend/src/Shared/Knot.Shared.Infrastructure/Configuration/SettingsService.cs index 0fc3755..3a2c431 100644 --- a/backend/src/Shared/Knot.Shared.Infrastructure/Configuration/SettingsService.cs +++ b/backend/src/Shared/Knot.Shared.Infrastructure/Configuration/SettingsService.cs @@ -8,7 +8,15 @@ using Microsoft.Extensions.DependencyInjection; namespace Knot.Shared.Infrastructure.Configuration; -public class SettingsService : ISettingsService +public class SettingsService : ISettingsService, + ISystemSettings, + IStoriesSettings, + IChatsSettings, + IMessagesSettings, + IWebRtcSettings, + IKlipySettings, + IImportSettings, + IFederationSettings { private readonly IServiceProvider _serviceProvider; private readonly IEncryptionService _encryptionService; @@ -23,6 +31,15 @@ public class SettingsService : ISettingsService public SystemSettingsDto Current => _current; + SystemConfig ISystemSettings.Current => _current.System; + StoriesConfig IStoriesSettings.Current => _current.Stories; + ChatsConfig IChatsSettings.Current => _current.Chats; + MessagesConfig IMessagesSettings.Current => _current.Messages; + WebRtcConfig IWebRtcSettings.Current => _current.WebRtc; + KlipyConfig IKlipySettings.Current => _current.Klipy; + ImportConfig IImportSettings.Current => _current.Import; + FederationConfig IFederationSettings.Current => _current.Federation; + public async Task GetSettingsAsync(CancellationToken cancellationToken = default) { using var scope = _serviceProvider.CreateScope(); diff --git a/backend/src/Shared/Knot.Shared.Infrastructure/DependencyInjection.cs b/backend/src/Shared/Knot.Shared.Infrastructure/DependencyInjection.cs index cdfa80c..bb518f5 100644 --- a/backend/src/Shared/Knot.Shared.Infrastructure/DependencyInjection.cs +++ b/backend/src/Shared/Knot.Shared.Infrastructure/DependencyInjection.cs @@ -37,7 +37,18 @@ public static class DependencyInjection options.UseNpgsql(connectionString)); services.AddMemoryCache(); - services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddScoped(); services.AddHostedService(); diff --git a/backend/src/Shared/Knot.Shared.Kernel/Configuration/ISettingsService.cs b/backend/src/Shared/Knot.Shared.Kernel/Configuration/ISettingsService.cs index 0dc967e..c053d7a 100644 --- a/backend/src/Shared/Knot.Shared.Kernel/Configuration/ISettingsService.cs +++ b/backend/src/Shared/Knot.Shared.Kernel/Configuration/ISettingsService.cs @@ -9,3 +9,43 @@ public interface ISettingsService Task UpdateSettingsAsync(SystemSettingsDto settings, CancellationToken cancellationToken = default); SystemSettingsDto Current { get; } } + +public interface ISystemSettings +{ + SystemConfig Current { get; } +} + +public interface IStoriesSettings +{ + StoriesConfig Current { get; } +} + +public interface IChatsSettings +{ + ChatsConfig Current { get; } +} + +public interface IMessagesSettings +{ + MessagesConfig Current { get; } +} + +public interface IWebRtcSettings +{ + WebRtcConfig Current { get; } +} + +public interface IKlipySettings +{ + KlipyConfig Current { get; } +} + +public interface IImportSettings +{ + ImportConfig Current { get; } +} + +public interface IFederationSettings +{ + FederationConfig Current { get; } +} diff --git a/backend/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs b/backend/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs index 7314e02..7ed1776 100644 --- a/backend/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs +++ b/backend/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs @@ -1,28 +1,118 @@ +using System.Collections.Generic; + namespace Knot.Shared.Kernel.Configuration; -public class SystemSettingsDto +public class SystemConfig { - // Storage Limits - public double MaxStorageQuotaTb { get; set; } = 5.0; - public int MaxGroupMembers { get; set; } = 500; - public int MaxFileSizeMb { get; set; } = 100; + public string ServerTimezone { get; set; } = "UTC"; + public string DomainUrl { get; set; } = "https://example.com"; + public string AdminRoute { get; set; } = "admin"; + public bool EnableRegistration { get; set; } = true; +} - // Calls - public bool EnableCalls { get; set; } = false; +public class StoriesConfig +{ + public bool Enabled { get; set; } = true; + public int MaxStoriesPerPeriod { get; set; } = 5; // 1 to 10 + public int StoryLifetimeHours { get; set; } = 24; // 4 to 48 + + // Text Stories + public bool TextStoriesEnabled { get; set; } = true; + public int TextStoryDurationSeconds { get; set; } = 15; // 5 to 30 + + // Media Stories + public int MediaStoryMaxDurationSeconds { get; set; } = 30; // 5 to 60 + public int MaxMediaSizeBytes { get; set; } = 15 * 1024 * 1024; // 15MB +} + +public class ChatsConfig +{ + public bool SupportGroups { get; set; } = true; + public int MaxGroupParticipants { get; set; } = 200000; // 5 to 1,000,000 + public bool AutoCleanChats { get; set; } = false; + public bool AllowChatToGroupConversion { get; set; } = true; + public bool EnableFolders { get; set; } = true; +} + +public class MessagesConfig +{ + public int DailyMessageLimitPerUser { get; set; } = 0; // 0 = unlimited + public int ChatMessageLimit { get; set; } = 0; // 0 = unlimited + + public bool AllowMedia { get; set; } = true; + public int MaxMediaSizeBytes { get; set; } = 50 * 1024 * 1024; // 50MB + public List AllowedMediaTypes { get; set; } = new() { "image/jpeg", "image/png", "video/mp4", "image/gif" }; + + public bool AllowVoiceMessages { get; set; } = true; + public bool AllowForwarding { get; set; } = true; + public bool AllowReactions { get; set; } = true; + public bool AllowReplies { get; set; } = true; + public bool AllowQuoting { get; set; } = true; + public bool AllowMessageDeletion { get; set; } = true; + public bool ForbidCopying { get; set; } = false; + public bool AllowLinks { get; set; } = true; + public bool AllowPolls { get; set; } = true; + public bool AllowPinning { get; set; } = true; +} + +public class WebRtcConfig +{ + public bool Enabled { get; set; } = false; + public bool EnableVoiceCalls { get; set; } = true; + public bool EnableVideoCalls { get; set; } = true; + public bool EnableScreenSharing { get; set; } = true; public string TurnHost { get; set; } = string.Empty; public int TurnPort { get; set; } = 3478; public string TurnUser { get; set; } = string.Empty; public string TurnSecret { get; set; } = string.Empty; - - // Klipy - public bool EnableKlipy { get; set; } = false; - public string KlipyApiKey { get; set; } = string.Empty; - public string KlipyCustomerId { get; set; } = string.Empty; - - // Confederation - public bool EnableConfederation { get; set; } = false; - public List AllowedDomains { get; set; } = new(); - - // Auth - public bool EnableRegistration { get; set; } = true; +} + +public class KlipyConfig +{ + public bool Enabled { get; set; } = false; + public string ApiKey { get; set; } = string.Empty; + public string AppName { get; set; } = string.Empty; +} + +public class ImportConfig +{ + public bool EnableTelegramImport { get; set; } = false; +} + +public class FederationDomainConfig +{ + public string Domain { get; set; } = string.Empty; + public bool IsEnabled { get; set; } = true; + public string? PublicKey { get; set; } + public RemoteCapabilities? Capabilities { get; set; } +} + +public class RemoteCapabilities +{ + public bool AllowMedia { get; set; } + public bool AllowPolls { get; set; } + public bool AllowVoiceMessages { get; set; } + public bool AllowVideoCalls { get; set; } + public bool AllowScreenSharing { get; set; } +} + +public class FederationConfig +{ + public bool Enabled { get; set; } = false; + public string ServerDescription { get; set; } = string.Empty; // 20 to 120 chars + public string? PrivateKey { get; set; } // RSA PEM + public string? PublicKey { get; set; } // RSA PEM + public List AllowedDomains { get; set; } = new(); +} + +public class SystemSettingsDto +{ + public SystemConfig System { get; set; } = new(); + public StoriesConfig Stories { get; set; } = new(); + public ChatsConfig Chats { get; set; } = new(); + public MessagesConfig Messages { get; set; } = new(); + public WebRtcConfig WebRtc { get; set; } = new(); + public KlipyConfig Klipy { get; set; } = new(); + public ImportConfig Import { get; set; } = new(); + public FederationConfig Federation { get; set; } = new(); } diff --git a/backend/src/Shared/Knot.Shared.Kernel/Result.cs b/backend/src/Shared/Knot.Shared.Kernel/Result.cs index 4ab7843..9ce8ede 100644 --- a/backend/src/Shared/Knot.Shared.Kernel/Result.cs +++ b/backend/src/Shared/Knot.Shared.Kernel/Result.cs @@ -6,6 +6,7 @@ namespace Knot.Shared.Kernel; public sealed record Error(string Code, string Description) { public static readonly Error None = new(string.Empty, string.Empty); + public static Error NotFound(string code, string description) => new(code, description); } ///