Структура, доп модули, федерация, документация
This commit is contained in:
@@ -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}` — Полное удаление пользователя.
|
||||||
@@ -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**: Позволяет администратору сбрасывать пароли пользователей.
|
||||||
@@ -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`) и обновлении состава участников.
|
||||||
@@ -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` для выполнения подписанных запросов.
|
||||||
@@ -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` в глобальном конфиге для формирования подписей.
|
||||||
@@ -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<SendMessageCommand>` с вызовом службы федерации.
|
||||||
@@ -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 сохранять ссылку на удаленный прокси-хост.
|
||||||
@@ -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).
|
||||||
@@ -150,8 +150,8 @@ using (var scope = app.Services.CreateScope())
|
|||||||
{
|
{
|
||||||
var identityDb = scope.ServiceProvider.GetRequiredService<AuthDbContext>();
|
var identityDb = scope.ServiceProvider.GetRequiredService<AuthDbContext>();
|
||||||
await identityDb.Database.MigrateAsync();
|
await identityDb.Database.MigrateAsync();
|
||||||
|
|
||||||
var chatsDb = scope.ServiceProvider.GetRequiredService<Knot.Modules.Conversations.Infrastructure.Persistence.ConversationsDbContext>();
|
var chatsDb = scope.ServiceProvider.GetRequiredService<Knot.Modules.Conversations.Infrastructure.Persistence.ChatsDbContext>();
|
||||||
await chatsDb.Database.MigrateAsync();
|
await chatsDb.Database.MigrateAsync();
|
||||||
|
|
||||||
var systemDb = scope.ServiceProvider.GetRequiredService<Knot.Shared.Infrastructure.Persistence.SystemDbContext>();
|
var systemDb = scope.ServiceProvider.GetRequiredService<Knot.Shared.Infrastructure.Persistence.SystemDbContext>();
|
||||||
|
|||||||
+20
-1
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
|||||||
using MediatR;
|
using MediatR;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Shared.Kernel.Configuration;
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace Host.Application.Admin.Commands;
|
namespace Host.Application.Admin.Commands;
|
||||||
|
|
||||||
@@ -13,15 +14,33 @@ public record UpdateSettingsCommand(SystemSettingsDto Settings) : ICommand<Syste
|
|||||||
internal sealed class UpdateSettingsCommandHandler : ICommandHandler<UpdateSettingsCommand, SystemSettingsDto>
|
internal sealed class UpdateSettingsCommandHandler : ICommandHandler<UpdateSettingsCommand, SystemSettingsDto>
|
||||||
{
|
{
|
||||||
private readonly ISettingsService _settingsService;
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
public UpdateSettingsCommandHandler(ISettingsService settingsService)
|
public UpdateSettingsCommandHandler(ISettingsService settingsService, IMediator mediator)
|
||||||
{
|
{
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
|
_mediator = mediator;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<SystemSettingsDto>> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken)
|
public async Task<Result<SystemSettingsDto>> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// Если федерация включена, но ключи еще не сгенерированы — создаем их
|
||||||
|
if (request.Settings.Federation.Enabled &&
|
||||||
|
(string.IsNullOrEmpty(request.Settings.Federation.PrivateKey) ||
|
||||||
|
string.IsNullOrEmpty(request.Settings.Federation.PublicKey)))
|
||||||
|
{
|
||||||
|
using var rsa = RSA.Create(2048);
|
||||||
|
|
||||||
|
// Экспорт в формате PKCS#8 (Private) и PKCS#1 (Public) в Base64 PEM-строки
|
||||||
|
request.Settings.Federation.PrivateKey = Convert.ToBase64String(rsa.ExportPkcs8PrivateKey());
|
||||||
|
request.Settings.Federation.PublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey());
|
||||||
|
}
|
||||||
|
|
||||||
await _settingsService.UpdateSettingsAsync(request.Settings, cancellationToken);
|
await _settingsService.UpdateSettingsAsync(request.Settings, cancellationToken);
|
||||||
|
|
||||||
|
// Уведомляем другие модули (в т.ч. Федерацию) через Доменное Событие
|
||||||
|
await _mediator.Publish(new Knot.Modules.Admin.Domain.Events.SystemSettingsUpdatedDomainEvent(request.Settings), cancellationToken);
|
||||||
|
|
||||||
return Result.Success(request.Settings);
|
return Result.Success(request.Settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Admin.Domain.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Доменное событие: глобальные настройки системы обновлены.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SystemSettingsUpdatedDomainEvent(SystemSettingsDto Settings) : IDomainEvent;
|
||||||
@@ -25,13 +25,13 @@ public sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCom
|
|||||||
{
|
{
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly IAuthUnitOfWork _unitOfWork;
|
private readonly IAuthUnitOfWork _unitOfWork;
|
||||||
private readonly ISettingsService _settings;
|
private readonly ISystemSettings _settings;
|
||||||
private readonly IJwtTokenProvider _tokenProvider;
|
private readonly IJwtTokenProvider _tokenProvider;
|
||||||
|
|
||||||
public RegisterUserCommandHandler(
|
public RegisterUserCommandHandler(
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
IAuthUnitOfWork unitOfWork,
|
IAuthUnitOfWork unitOfWork,
|
||||||
ISettingsService settings,
|
ISystemSettings settings,
|
||||||
IJwtTokenProvider tokenProvider)
|
IJwtTokenProvider tokenProvider)
|
||||||
{
|
{
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ public interface IUserRepository
|
|||||||
Task<List<User>> SearchUsersAsync(string query, CancellationToken cancellationToken = default);
|
Task<List<User>> SearchUsersAsync(string query, CancellationToken cancellationToken = default);
|
||||||
void Add(User user);
|
void Add(User user);
|
||||||
void Update(User user);
|
void Update(User user);
|
||||||
|
void Remove(User user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ using Knot.Shared.Kernel;
|
|||||||
|
|
||||||
namespace Knot.Modules.Auth.Domain;
|
namespace Knot.Modules.Auth.Domain;
|
||||||
|
|
||||||
|
public sealed record UserStatusChangedDomainEvent(Guid UserId, bool IsOnline, DateTime LastSeen) : IDomainEvent;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сущность пользователя в контексте идентификации (Identity).
|
/// Сущность пользователя в контексте идентификации (Identity).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -16,6 +18,11 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
public DateTime? Birthday { get; private set; }
|
public DateTime? Birthday { get; private set; }
|
||||||
public DateTime CreatedAt { get; private set; }
|
public DateTime CreatedAt { get; private set; }
|
||||||
public bool HideStoryViews { 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)
|
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
|
||||||
: base(id)
|
: base(id)
|
||||||
@@ -36,6 +43,18 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
return new User(Guid.NewGuid(), username, passwordHash, displayName, email, bio);
|
return new User(Guid.NewGuid(), username, passwordHash, displayName, email, bio);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создает "теневого" пользователя для контакта с другого сервера.
|
||||||
|
/// </summary>
|
||||||
|
public static User CreateExternal(Guid id, string username, string displayName, string domain, string? avatar = null)
|
||||||
|
{
|
||||||
|
var user = new User(id, username, "EXTERNAL_USER", displayName, null, null);
|
||||||
|
user.IsExternal = true;
|
||||||
|
user.Domain = domain;
|
||||||
|
user.Avatar = avatar;
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdateProfile(string displayName, string? bio, DateTime? birthday)
|
public void UpdateProfile(string displayName, string? bio, DateTime? birthday)
|
||||||
{
|
{
|
||||||
DisplayName = displayName;
|
DisplayName = displayName;
|
||||||
@@ -57,5 +76,21 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
{
|
{
|
||||||
PasswordHash = newPasswordHash;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,5 +53,10 @@ public sealed class UserRepository : IUserRepository
|
|||||||
{
|
{
|
||||||
_context.Users.Update(user);
|
_context.Users.Update(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Remove(User user)
|
||||||
|
{
|
||||||
|
_context.Users.Remove(user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
using Knot.Modules.Conversations.Domain;
|
||||||
|
using Knot.Modules.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Application.Folders.Commands.AddToFolder;
|
||||||
|
|
||||||
|
public record AddToFolderCommand(Guid UserId, Guid ChatId, List<Guid> FolderIds) : ICommand;
|
||||||
|
|
||||||
|
internal sealed class AddToFolderCommandHandler : ICommandHandler<AddToFolderCommand>
|
||||||
|
{
|
||||||
|
private readonly IUserChatSettingsRepository _settingsRepository;
|
||||||
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
|
private readonly IChatsSettings _chatsSettings;
|
||||||
|
|
||||||
|
public AddToFolderCommandHandler(
|
||||||
|
IUserChatSettingsRepository settingsRepository,
|
||||||
|
IChatsUnitOfWork unitOfWork,
|
||||||
|
IChatsSettings chatsSettings)
|
||||||
|
{
|
||||||
|
_settingsRepository = settingsRepository;
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
|
_chatsSettings = chatsSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> Handle(AddToFolderCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_chatsSettings.Current.EnableFolders)
|
||||||
|
{
|
||||||
|
return Result.Failure(ChatErrors.FoldersDisabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = await _settingsRepository.GetAsync(request.UserId, request.ChatId, cancellationToken);
|
||||||
|
|
||||||
|
if (settings == null)
|
||||||
|
{
|
||||||
|
settings = UserChatSettings.Create(request.UserId, request.ChatId);
|
||||||
|
_settingsRepository.Add(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var folderId in request.FolderIds)
|
||||||
|
{
|
||||||
|
settings.AddToFolder(folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
using Knot.Modules.Conversations.Domain;
|
||||||
|
using Knot.Modules.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Application.Folders.Commands.RemoveFromFolder;
|
||||||
|
|
||||||
|
public record RemoveFromFolderCommand(Guid UserId, Guid ChatId, List<Guid> FolderIds) : ICommand;
|
||||||
|
|
||||||
|
internal sealed class RemoveFromFolderCommandHandler : ICommandHandler<RemoveFromFolderCommand>
|
||||||
|
{
|
||||||
|
private readonly IUserChatSettingsRepository _settingsRepository;
|
||||||
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
|
|
||||||
|
public RemoveFromFolderCommandHandler(IUserChatSettingsRepository settingsRepository, IChatsUnitOfWork unitOfWork)
|
||||||
|
{
|
||||||
|
_settingsRepository = settingsRepository;
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> Handle(RemoveFromFolderCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var settings = await _settingsRepository.GetAsync(request.UserId, request.ChatId, cancellationToken);
|
||||||
|
|
||||||
|
if (settings == null)
|
||||||
|
{
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var folderId in request.FolderIds)
|
||||||
|
{
|
||||||
|
settings.RemoveFromFolder(folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_settingsRepository.Update(settings);
|
||||||
|
|
||||||
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-7
@@ -1,11 +1,10 @@
|
|||||||
using Knot.Modules.Messaging.Application.Abstractions;
|
|
||||||
using Knot.Modules.Messaging.Domain;
|
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using global::Knot.Modules.Conversations.Domain;
|
using global::Knot.Modules.Conversations.Domain;
|
||||||
using global::Knot.Modules.Conversations.Infrastructure.SignalR;
|
using global::Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
using global::Knot.Shared.Kernel;
|
using global::Knot.Shared.Kernel;
|
||||||
using global::Knot.Modules.Conversations.Application.Abstractions;
|
using global::Knot.Modules.Conversations.Application.Abstractions;
|
||||||
|
using MessagingMessageRepository = Knot.Modules.Messaging.Domain.IMessageRepository;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.Delete;
|
namespace Knot.Modules.Conversations.Application.Messages.Delete;
|
||||||
|
|
||||||
@@ -17,12 +16,12 @@ public sealed record DeleteMessagesCommand(
|
|||||||
|
|
||||||
public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessagesCommand>
|
public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessagesCommand>
|
||||||
{
|
{
|
||||||
private readonly IMessageRepository _messageRepository;
|
private readonly MessagingMessageRepository _messageRepository;
|
||||||
private readonly IChatsUnitOfWork _unitOfWork;
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
private readonly IHubContext<ChatHub> _hubContext;
|
private readonly IHubContext<ChatHub> _hubContext;
|
||||||
|
|
||||||
public DeleteMessagesCommandHandler(
|
public DeleteMessagesCommandHandler(
|
||||||
IMessageRepository messageRepository,
|
MessagingMessageRepository messageRepository,
|
||||||
IChatsUnitOfWork unitOfWork,
|
IChatsUnitOfWork unitOfWork,
|
||||||
IHubContext<ChatHub> hubContext)
|
IHubContext<ChatHub> hubContext)
|
||||||
{
|
{
|
||||||
@@ -67,7 +66,6 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Уведомляем только самого пользователя (все его текущие сессии)
|
|
||||||
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
|
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
|
||||||
{
|
{
|
||||||
chatId = request.ChatId,
|
chatId = request.ChatId,
|
||||||
@@ -79,5 +77,3 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
|||||||
return global::Knot.Shared.Kernel.Result.Success();
|
return global::Knot.Shared.Kernel.Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+32
-2
@@ -2,6 +2,7 @@ using Knot.Modules.Messaging.Application.Abstractions;
|
|||||||
using Knot.Modules.Messaging.Domain;
|
using Knot.Modules.Messaging.Domain;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Modules.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
using Knot.Shared.Kernel.Configuration;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Modules.Conversations.Application.Abstractions;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
||||||
@@ -22,7 +23,11 @@ public sealed record SendMessageCommand(
|
|||||||
Guid? ForwardedFromId = null,
|
Guid? ForwardedFromId = null,
|
||||||
Guid? StoryId = null,
|
Guid? StoryId = null,
|
||||||
string? StoryMediaUrl = null,
|
string? StoryMediaUrl = null,
|
||||||
string? StoryMediaType = null) : ICommand<Guid>;
|
string? StoryMediaType = null,
|
||||||
|
List<string>? PollOptions = null,
|
||||||
|
bool? PollIsAnonymous = null,
|
||||||
|
bool? PollAllowMultipleAnswers = null,
|
||||||
|
DateTime? PollExpiresAt = null) : ICommand<Guid>;
|
||||||
|
|
||||||
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
|
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
|
||||||
{
|
{
|
||||||
@@ -30,17 +35,20 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
private readonly IMessageRepository _messageRepository;
|
private readonly IMessageRepository _messageRepository;
|
||||||
private readonly IChatsUnitOfWork _unitOfWork;
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
private readonly MediatR.IMediator _mediator;
|
private readonly MediatR.IMediator _mediator;
|
||||||
|
private readonly IMessagesSettings _messagesSettings;
|
||||||
|
|
||||||
public SendMessageCommandHandler(
|
public SendMessageCommandHandler(
|
||||||
IChatRepository chatRepository,
|
IChatRepository chatRepository,
|
||||||
IMessageRepository messageRepository,
|
IMessageRepository messageRepository,
|
||||||
IChatsUnitOfWork unitOfWork,
|
IChatsUnitOfWork unitOfWork,
|
||||||
MediatR.IMediator mediator)
|
MediatR.IMediator mediator,
|
||||||
|
IMessagesSettings messagesSettings)
|
||||||
{
|
{
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
_messageRepository = messageRepository;
|
_messageRepository = messageRepository;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mediator = mediator;
|
_mediator = mediator;
|
||||||
|
_messagesSettings = messagesSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
|
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
|
||||||
@@ -62,6 +70,8 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
Message message;
|
Message message;
|
||||||
if (request.Type == "story_reply" || request.Type == "story_reaction")
|
if (request.Type == "story_reply" || request.Type == "story_reaction")
|
||||||
{
|
{
|
||||||
|
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
|
||||||
|
|
||||||
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
|
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
|
||||||
message = new StoryMessage(
|
message = new StoryMessage(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
@@ -78,6 +88,8 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
}
|
}
|
||||||
else if (request.Attachments != null && request.Attachments.Any())
|
else if (request.Attachments != null && request.Attachments.Any())
|
||||||
{
|
{
|
||||||
|
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
|
||||||
|
|
||||||
var firstAtt = request.Attachments.First();
|
var firstAtt = request.Attachments.First();
|
||||||
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
|
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
|
||||||
|
|
||||||
@@ -98,6 +110,24 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
((MediaMessage)message).AddMedia(pType, att.Url, att.FileName, att.FileSize);
|
((MediaMessage)message).AddMedia(pType, att.Url, att.FileName, att.FileSize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (request.Type == "poll")
|
||||||
|
{
|
||||||
|
if (!_messagesSettings.Current.AllowPolls) return Result.Failure<Guid>(ChatErrors.PollsDisabled);
|
||||||
|
|
||||||
|
message = new PollMessage(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
request.ChatId,
|
||||||
|
request.SenderId,
|
||||||
|
request.Content ?? "Poll",
|
||||||
|
request.PollOptions ?? new List<string>(),
|
||||||
|
request.PollIsAnonymous ?? true,
|
||||||
|
request.PollAllowMultipleAnswers ?? false,
|
||||||
|
request.PollExpiresAt,
|
||||||
|
request.ReplyToId,
|
||||||
|
request.ForwardedFromId,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
false);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
message = new TextMessage(
|
message = new TextMessage(
|
||||||
|
|||||||
+6
-4
@@ -16,9 +16,9 @@ public record UploadFileCommand(string FileName, string ContentType, long Length
|
|||||||
internal sealed class UploadFileCommandHandler : ICommandHandler<UploadFileCommand, UploadFileResponseDto>
|
internal sealed class UploadFileCommandHandler : ICommandHandler<UploadFileCommand, UploadFileResponseDto>
|
||||||
{
|
{
|
||||||
private readonly IFileStorageService _fileStorage;
|
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;
|
_fileStorage = fileStorage;
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
@@ -31,8 +31,10 @@ internal sealed class UploadFileCommandHandler : ICommandHandler<UploadFileComma
|
|||||||
return Result.Failure<UploadFileResponseDto>(ChatErrors.FileEmpty);
|
return Result.Failure<UploadFileResponseDto>(ChatErrors.FileEmpty);
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxMb = _settingsService.Current.MaxFileSizeMb;
|
var maxBytes = _settingsService.Current.MaxMediaSizeBytes;
|
||||||
if (request.Length > maxMb * 1024 * 1024)
|
var maxMb = maxBytes / (1024 * 1024);
|
||||||
|
|
||||||
|
if (request.Length > maxBytes)
|
||||||
{
|
{
|
||||||
return Result.Failure<UploadFileResponseDto>(ChatErrors.FileTooLarge(maxMb));
|
return Result.Failure<UploadFileResponseDto>(ChatErrors.FileTooLarge(maxMb));
|
||||||
}
|
}
|
||||||
|
|||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
using Knot.Modules.Auth.Domain;
|
||||||
|
using Knot.Modules.Conversations.Domain;
|
||||||
|
using Knot.Modules.Messaging.Domain;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using Knot.Shared.Kernel.Storage;
|
||||||
|
using MediatR;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Knot.Modules.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Modules.Auth.Application.Abstractions;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Application.Users.Commands.DeleteUser;
|
||||||
|
|
||||||
|
public record DeleteUserCommand(Guid UserId) : ICommand;
|
||||||
|
|
||||||
|
internal sealed class DeleteUserCommandHandler : ICommandHandler<DeleteUserCommand>
|
||||||
|
{
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IUserChatSettingsRepository _userChatSettingsRepository;
|
||||||
|
private readonly IUserFolderSettingsRepository _userFolderSettingsRepository;
|
||||||
|
private readonly Knot.Modules.Messaging.Domain.IMessageRepository _messageRepository;
|
||||||
|
private readonly IFileStorageService _fileStorage;
|
||||||
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
|
private readonly IAuthUnitOfWork _authUnitOfWork;
|
||||||
|
|
||||||
|
public DeleteUserCommandHandler(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IUserChatSettingsRepository userChatSettingsRepository,
|
||||||
|
IUserFolderSettingsRepository userFolderSettingsRepository,
|
||||||
|
Knot.Modules.Messaging.Domain.IMessageRepository messageRepository,
|
||||||
|
IFileStorageService fileStorage,
|
||||||
|
IChatsUnitOfWork unitOfWork,
|
||||||
|
IAuthUnitOfWork authUnitOfWork)
|
||||||
|
{
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_userChatSettingsRepository = userChatSettingsRepository;
|
||||||
|
_userFolderSettingsRepository = userFolderSettingsRepository;
|
||||||
|
_messageRepository = messageRepository;
|
||||||
|
_fileStorage = fileStorage;
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
|
_authUnitOfWork = authUnitOfWork;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> Handle(DeleteUserCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||||
|
if (user == null) return Result.Failure(Error.NotFound("User.NotFound", "User not found"));
|
||||||
|
|
||||||
|
// 1. Delete Messages and their files in MongoDB
|
||||||
|
var allMessages = await _messageRepository.GetAllMessagesAsync(cancellationToken);
|
||||||
|
var userMessages = allMessages.Where(m => m.SenderId == request.UserId).ToList();
|
||||||
|
|
||||||
|
foreach (var msg in userMessages)
|
||||||
|
{
|
||||||
|
if (msg is MediaMessage mediaMsg)
|
||||||
|
{
|
||||||
|
foreach (var media in mediaMsg.Media)
|
||||||
|
{
|
||||||
|
bool isUsedElsewhere = allMessages.Any(m => m.Id != msg.Id &&
|
||||||
|
m is MediaMessage mm && mm.Media.Any(ame => ame.Url == media.Url));
|
||||||
|
|
||||||
|
if (!isUsedElsewhere)
|
||||||
|
{
|
||||||
|
var fileId = ExtractFileId(media.Url);
|
||||||
|
if (!string.IsNullOrEmpty(fileId))
|
||||||
|
{
|
||||||
|
await _fileStorage.DeleteFileAsync(fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _messageRepository.DeleteUserMessagesAsync(request.UserId, cancellationToken);
|
||||||
|
|
||||||
|
// 2. Delete Folder Settings (MongoDB)
|
||||||
|
await _userFolderSettingsRepository.RemoveByUserIdAsync(request.UserId, cancellationToken);
|
||||||
|
|
||||||
|
// 3. Delete Chat Settings (Postgres)
|
||||||
|
var chatSettings = await _userChatSettingsRepository.GetByUserIdAsync(request.UserId, cancellationToken);
|
||||||
|
_userChatSettingsRepository.RemoveRange(chatSettings);
|
||||||
|
|
||||||
|
// 4. Delete User (Postgres - Auth Module)
|
||||||
|
_userRepository.Remove(user);
|
||||||
|
|
||||||
|
// Commit all
|
||||||
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
await _authUnitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ExtractFileId(string url)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(url, @"/([^/]+)$");
|
||||||
|
return match.Success ? match.Groups[1].Value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,11 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Modules.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||||
|
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
||||||
|
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Modules.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Modules.Messaging.Domain;
|
||||||
|
using Knot.Modules.Messaging.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations;
|
namespace Knot.Modules.Conversations;
|
||||||
|
|
||||||
@@ -25,12 +28,19 @@ public static class DependencyInjection
|
|||||||
options.UseNpgsql(connectionString));
|
options.UseNpgsql(connectionString));
|
||||||
|
|
||||||
// MongoDB Setup for Messages
|
// MongoDB Setup for Messages
|
||||||
|
ConversationsMongoDbMapConfigurator.Configure();
|
||||||
|
|
||||||
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
|
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
|
||||||
|
|
||||||
// Registration
|
// Registration
|
||||||
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
|
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
|
||||||
services.AddScoped<IChatRepository, ChatRepository>();
|
services.AddScoped<IChatRepository, ChatRepository>();
|
||||||
|
services.AddScoped<IFolderRepository, FolderRepository>();
|
||||||
|
services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>();
|
||||||
|
services.AddScoped<IUserFolderSettingsRepository, UserFolderSettingsRepository>();
|
||||||
|
|
||||||
|
// Messaging Repository registration (might be redundant if already in Messaging module, but needed for specific commands in Conversations)
|
||||||
|
services.AddScoped<IMessageRepository, MessageRepository>();
|
||||||
|
|
||||||
// MediatR
|
// MediatR
|
||||||
services.AddMediatR(config =>
|
services.AddMediatR(config =>
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ public static class ChatErrors
|
|||||||
public static readonly Error MessagesNotFound = new Error("Messages.NotFound", "Message not found.");
|
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 ChatsNotFound = new Error("Chats.NotFound", "Чат не найден.");
|
||||||
public static readonly Error Unauthorized = new Error("Chats.Unauthorized", "Access denied");
|
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 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.");
|
public static Error FileTooLarge(int maxMb) => new Error("File.TooLarge", $"File exceeds the maximum allowed size of {maxMb}MB.");
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сущность папки для группировки чатов.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Folder : AggregateRoot<Guid>
|
||||||
|
{
|
||||||
|
public string Name { get; private set; }
|
||||||
|
public string? Icon { get; private set; } // URL из хранилища
|
||||||
|
public bool IsDefault { get; private set; }
|
||||||
|
public FolderType Type { get; private set; }
|
||||||
|
|
||||||
|
public Folder(Guid id, string name, string? icon = null, bool isDefault = false, FolderType type = FolderType.Custom)
|
||||||
|
: base(id)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Icon = icon;
|
||||||
|
IsDefault = isDefault;
|
||||||
|
Type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Update(string name, string? icon)
|
||||||
|
{
|
||||||
|
if (IsDefault) throw new InvalidOperationException("Cannot rename default folders.");
|
||||||
|
Name = name;
|
||||||
|
Icon = icon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FolderType
|
||||||
|
{
|
||||||
|
All, // Все чаты
|
||||||
|
New, // Новые (с непрочитанными)
|
||||||
|
Muted, // Без звука
|
||||||
|
Custom // Пользовательская
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Настройки конкретного чата для конкретного пользователя.
|
||||||
|
/// Хранятся в PostgreSQL (связь User <-> Chat).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserChatSettings : Entity<Guid>
|
||||||
|
{
|
||||||
|
public Guid UserId { get; private set; }
|
||||||
|
public Guid ChatId { get; private set; }
|
||||||
|
|
||||||
|
// Список папок, в которые входит чат для этого пользователя
|
||||||
|
private readonly List<Guid> _folderIds = new();
|
||||||
|
public IReadOnlyCollection<Guid> FolderIds => _folderIds.AsReadOnly();
|
||||||
|
|
||||||
|
public bool IsMuted { get; private set; }
|
||||||
|
|
||||||
|
private UserChatSettings() : base(Guid.NewGuid()) { }
|
||||||
|
|
||||||
|
public UserChatSettings(Guid userId, Guid chatId) : base(Guid.NewGuid())
|
||||||
|
{
|
||||||
|
UserId = userId;
|
||||||
|
ChatId = chatId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UserChatSettings Create(Guid userId, Guid chatId) => new(userId, chatId);
|
||||||
|
|
||||||
|
public void AddToFolder(Guid folderId)
|
||||||
|
{
|
||||||
|
if (!_folderIds.Contains(folderId)) _folderIds.Add(folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveFromFolder(Guid folderId)
|
||||||
|
{
|
||||||
|
_folderIds.Remove(folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetMute(bool isMuted) => IsMuted = isMuted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Глобальные настройки папок пользователя (скрытие дефолтных и т.д.).
|
||||||
|
/// Будет храниться в MongoDB.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserFolderSettings : AggregateRoot<Guid>
|
||||||
|
{
|
||||||
|
public Guid UserId { get; private set; }
|
||||||
|
|
||||||
|
// Список ID папок, которые пользователь скрыл (только для дефолтных)
|
||||||
|
public List<Guid> HiddenDefaultFolderIds { get; private set; } = new();
|
||||||
|
|
||||||
|
// Список пользовательских папок (Guid созданных Folder)
|
||||||
|
public List<Guid> CustomFolderIds { get; private set; } = new();
|
||||||
|
|
||||||
|
public UserFolderSettings(Guid userId) : base(Guid.NewGuid())
|
||||||
|
{
|
||||||
|
UserId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HideFolder(Guid folderId)
|
||||||
|
{
|
||||||
|
if (!HiddenDefaultFolderIds.Contains(folderId)) HiddenDefaultFolderIds.Add(folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowFolder(Guid folderId)
|
||||||
|
{
|
||||||
|
HiddenDefaultFolderIds.Remove(folderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,3 +12,28 @@ public interface IChatRepository
|
|||||||
Task<List<Chat>> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken);
|
Task<List<Chat>> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface IFolderRepository
|
||||||
|
{
|
||||||
|
void Add(Folder folder);
|
||||||
|
void Update(Folder folder);
|
||||||
|
void Remove(Folder folder);
|
||||||
|
Task<Folder?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
||||||
|
Task<List<Folder>> GetUserFoldersAsync(Guid userId, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IUserChatSettingsRepository
|
||||||
|
{
|
||||||
|
void Add(UserChatSettings settings);
|
||||||
|
void Update(UserChatSettings settings);
|
||||||
|
void Remove(UserChatSettings settings);
|
||||||
|
void RemoveRange(IEnumerable<UserChatSettings> settings);
|
||||||
|
Task<UserChatSettings?> GetAsync(Guid userId, Guid chatId, CancellationToken cancellationToken);
|
||||||
|
Task<List<UserChatSettings>> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IUserFolderSettingsRepository
|
||||||
|
{
|
||||||
|
Task<UserFolderSettings?> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken);
|
||||||
|
Task UpdateAsync(UserFolderSettings settings, CancellationToken cancellationToken);
|
||||||
|
Task RemoveByUserIdAsync(Guid userId, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,3 +54,59 @@ public sealed class ChatRepository : IChatRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class FolderRepository : IFolderRepository
|
||||||
|
{
|
||||||
|
private readonly ChatsDbContext _dbContext;
|
||||||
|
|
||||||
|
public FolderRepository(ChatsDbContext dbContext)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(Folder folder) => _dbContext.Folders.Add(folder);
|
||||||
|
public void Update(Folder folder) => _dbContext.Folders.Update(folder);
|
||||||
|
public void Remove(Folder folder) => _dbContext.Folders.Remove(folder);
|
||||||
|
|
||||||
|
public async Task<Folder?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _dbContext.Folders.FirstOrDefaultAsync(f => f.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Folder>> GetUserFoldersAsync(Guid userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// В доменной модели Folder не имеет UserId напрямую (может быть общей сущностью),
|
||||||
|
// но по логике "папки в настройках пользователя" можно фильтровать через настройки.
|
||||||
|
// Пока возвращаем все папки, которыми владеет пользователь (если добавить UserId)
|
||||||
|
// или все для упрощения первой итерации.
|
||||||
|
return await _dbContext.Folders.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UserChatSettingsRepository : IUserChatSettingsRepository
|
||||||
|
{
|
||||||
|
private readonly ChatsDbContext _dbContext;
|
||||||
|
|
||||||
|
public UserChatSettingsRepository(ChatsDbContext dbContext)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(UserChatSettings settings) => _dbContext.UserChatSettings.Add(settings);
|
||||||
|
public void Update(UserChatSettings settings) => _dbContext.UserChatSettings.Update(settings);
|
||||||
|
public void Remove(UserChatSettings settings) => _dbContext.UserChatSettings.Remove(settings);
|
||||||
|
public void RemoveRange(IEnumerable<UserChatSettings> settings) => _dbContext.UserChatSettings.RemoveRange(settings);
|
||||||
|
|
||||||
|
public async Task<UserChatSettings?> GetAsync(Guid userId, Guid chatId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _dbContext.UserChatSettings
|
||||||
|
.FirstOrDefaultAsync(s => s.UserId == userId && s.ChatId == chatId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<UserChatSettings>> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _dbContext.UserChatSettings
|
||||||
|
.Where(s => s.UserId == userId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-8
@@ -5,6 +5,11 @@ using Knot.Modules.Conversations.Application.Abstractions;
|
|||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Modules.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Shared.Kernel.Security;
|
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;
|
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||||
|
|
||||||
@@ -24,8 +29,8 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
|
|||||||
}
|
}
|
||||||
|
|
||||||
public DbSet<Chat> Chats => Set<Chat>();
|
public DbSet<Chat> Chats => Set<Chat>();
|
||||||
|
public DbSet<Folder> Folders => Set<Folder>();
|
||||||
|
public DbSet<UserChatSettings> UserChatSettings => Set<UserChatSettings>();
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -43,7 +48,6 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
|
|||||||
builder.HasKey(c => c.Id);
|
builder.HasKey(c => c.Id);
|
||||||
builder.Property(c => c.Type).HasConversion<string>();
|
builder.Property(c => c.Type).HasConversion<string>();
|
||||||
|
|
||||||
|
|
||||||
builder.OwnsMany(c => c.Members, mb =>
|
builder.OwnsMany(c => c.Members, mb =>
|
||||||
{
|
{
|
||||||
mb.ToTable("ChatMembers");
|
mb.ToTable("ChatMembers");
|
||||||
@@ -53,15 +57,32 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
|
|||||||
}).Navigation(c => c.Members).UsePropertyAccessMode(PropertyAccessMode.Field);
|
}).Navigation(c => c.Members).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Folder>(builder =>
|
||||||
|
{
|
||||||
|
builder.ToTable("Folders");
|
||||||
|
builder.HasKey(f => f.Id);
|
||||||
|
builder.Property(f => f.Type).HasConversion<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<UserChatSettings>(builder =>
|
||||||
|
{
|
||||||
|
builder.ToTable("UserChatSettings");
|
||||||
|
builder.HasKey(s => s.Id);
|
||||||
|
builder.HasIndex(s => new { s.UserId, s.ChatId }).IsUnique();
|
||||||
|
|
||||||
|
builder.Property(s => s.FolderIds)
|
||||||
|
.HasConversion(
|
||||||
|
v => string.Join(',', v),
|
||||||
|
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(Guid.Parse).ToList()
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// 1. Получаем все события из агрегатов
|
|
||||||
var domainEvents = ChangeTracker
|
var domainEvents = ChangeTracker
|
||||||
.Entries<IAggregateRoot>()
|
.Entries<IAggregateRoot>()
|
||||||
.SelectMany(x =>
|
.SelectMany(x =>
|
||||||
|
|
||||||
{
|
{
|
||||||
if (x.Entity is AggregateRoot<Guid> root)
|
if (x.Entity is AggregateRoot<Guid> root)
|
||||||
{
|
{
|
||||||
@@ -73,10 +94,8 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
|
|||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// 2. Сохраняем изменения
|
|
||||||
int result = await base.SaveChangesAsync(cancellationToken);
|
int result = await base.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
// 3. Публикуем события через MediatR
|
|
||||||
foreach (var domainEvent in domainEvents)
|
foreach (var domainEvent in domainEvents)
|
||||||
{
|
{
|
||||||
await _mediator.Publish(domainEvent, cancellationToken);
|
await _mediator.Publish(domainEvent, cancellationToken);
|
||||||
@@ -85,4 +104,3 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
using Knot.Modules.Conversations.Domain;
|
||||||
|
using MongoDB.Bson.Serialization;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
||||||
|
|
||||||
|
public static class ConversationsMongoDbMapConfigurator
|
||||||
|
{
|
||||||
|
private static bool _initialized;
|
||||||
|
|
||||||
|
public static void Configure()
|
||||||
|
{
|
||||||
|
if (_initialized) return;
|
||||||
|
|
||||||
|
if (!BsonClassMap.IsClassMapRegistered(typeof(UserFolderSettings)))
|
||||||
|
{
|
||||||
|
BsonClassMap.RegisterClassMap<UserFolderSettings>(cm =>
|
||||||
|
{
|
||||||
|
cm.AutoMap();
|
||||||
|
cm.SetDiscriminator("UserFolderSettings");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
using Knot.Modules.Conversations.Domain;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
||||||
|
|
||||||
|
public sealed class UserFolderSettingsRepository : IUserFolderSettingsRepository
|
||||||
|
{
|
||||||
|
private readonly IMongoCollection<UserFolderSettings> _collection;
|
||||||
|
|
||||||
|
public UserFolderSettingsRepository(IMongoDatabase database)
|
||||||
|
{
|
||||||
|
_collection = database.GetCollection<UserFolderSettings>("user_folder_settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserFolderSettings?> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _collection.Find(s => s.UserId == userId).FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(UserFolderSettings settings, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var options = new ReplaceOptions { IsUpsert = true };
|
||||||
|
await _collection.ReplaceOneAsync(s => s.Id == settings.Id, settings, options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveByUserIdAsync(Guid userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _collection.DeleteManyAsync(s => s.UserId == userId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
|
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
|
||||||
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
|
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
|
||||||
|
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -35,4 +36,3 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Federation.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Шлюз для безопасной передачи данных между серверами Knot Messager.
|
||||||
|
/// </summary>
|
||||||
|
public interface IFederationGateway
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Отправляет пакет на конкретный домен.
|
||||||
|
/// </summary>
|
||||||
|
Task<Result> SendPacketAsync(FederationMessagePacket packet, string targetDomain, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Федеративный пакет сообщения с гибридным шифрованием.
|
||||||
|
/// </summary>
|
||||||
|
public record FederationMessagePacket(
|
||||||
|
string SenderDomain,
|
||||||
|
string RecipientDomain,
|
||||||
|
string EncryptedPayload, // AES-256
|
||||||
|
string EncryptedIV, // RSA-Encrypted for Recipient
|
||||||
|
string Signature, // RSA-Signed by Sender
|
||||||
|
FederationMetadata Metadata
|
||||||
|
);
|
||||||
|
|
||||||
|
public record FederationMetadata(
|
||||||
|
Guid ChatId,
|
||||||
|
Guid SenderId,
|
||||||
|
string SenderUsername,
|
||||||
|
string MessageType,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
Guid UserId = default // ID пользователя-исполнителя (для реакций и т.д.)
|
||||||
|
);
|
||||||
+40
-28
@@ -1,54 +1,66 @@
|
|||||||
|
using Knot.Shared.Kernel.Constants;
|
||||||
using Knot.Modules.Stories.Domain;
|
using System.Security.Cryptography;
|
||||||
|
using MediatR;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Shared.Kernel.Configuration;
|
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;
|
namespace Host.Application.Federation.Commands;
|
||||||
|
|
||||||
public record HandshakeRequest(string Domain, string PublicKey);
|
public record HandshakeRequest(string Domain, string PublicKey, RemoteCapabilities Capabilities);
|
||||||
public record HandshakeResponse(string Domain, string PublicKey, string Status);
|
public record HandshakeResponse(string Domain, string PublicKey, RemoteCapabilities Capabilities, string Status);
|
||||||
|
|
||||||
public record HandshakeFederationCommand(HandshakeRequest Request) : ICommand<HandshakeResponse>;
|
public record HandshakeFederationCommand(HandshakeRequest Request) : ICommand<HandshakeResponse>;
|
||||||
|
|
||||||
internal sealed class HandshakeFederationCommandHandler : ICommandHandler<HandshakeFederationCommand, HandshakeResponse>
|
internal sealed class HandshakeFederationCommandHandler : ICommandHandler<HandshakeFederationCommand, HandshakeResponse>
|
||||||
{
|
{
|
||||||
private readonly ISettingsService _settings;
|
private readonly ISettingsService _settingsService;
|
||||||
|
|
||||||
public HandshakeFederationCommandHandler(ISettingsService settings)
|
public HandshakeFederationCommandHandler(ISettingsService settingsService)
|
||||||
{
|
{
|
||||||
_settings = settings;
|
_settingsService = settingsService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<Result<HandshakeResponse>> Handle(HandshakeFederationCommand request, CancellationToken cancellationToken)
|
public async Task<Result<HandshakeResponse>> Handle(HandshakeFederationCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var conf = _settings.Current;
|
var allSettings = await _settingsService.GetSettingsAsync(cancellationToken);
|
||||||
if (!conf.EnableConfederation)
|
var conf = allSettings.Federation;
|
||||||
return Task.FromResult(Result.Failure<HandshakeResponse>(new Error(Errors.DisabledByAdmin, "Federation is disabled")));
|
|
||||||
|
if (!conf.Enabled)
|
||||||
|
return Result.Failure<HandshakeResponse>(new Error(Errors.DisabledByAdmin, "Federation is disabled or keys not generated."));
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(request.Request.Domain) || string.IsNullOrEmpty(request.Request.PublicKey))
|
if (string.IsNullOrEmpty(request.Request.Domain) || string.IsNullOrEmpty(request.Request.PublicKey))
|
||||||
return Task.FromResult(Result.Failure<HandshakeResponse>(DomainErrors.RequestInvalid));
|
return Result.Failure<HandshakeResponse>(new Error("Request.Invalid", "Domain and PublicKey required."));
|
||||||
|
|
||||||
var allowedList = conf.AllowedDomains?.Select(d => d.Trim().ToLower()).ToList() ?? new System.Collections.Generic.List<string>();
|
// Проверка разрешенности домена (белый список)
|
||||||
if (!allowedList.Contains(request.Request.Domain.ToLowerInvariant()))
|
var allowedList = conf.AllowedDomains ?? new List<FederationDomainConfig>();
|
||||||
return Task.FromResult(Result.Failure<HandshakeResponse>(StoryErrors.Unauthorized));
|
var domainConfig = allowedList.FirstOrDefault(d => d.IsEnabled && d.Domain.Equals(request.Request.Domain, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (domainConfig == null)
|
||||||
|
return Result.Failure<HandshakeResponse>(new Error("Federation.DomainNotAllowed", "Your domain is not in our allowlist."));
|
||||||
|
|
||||||
using var rsa = RSA.Create(2048);
|
// Сохраняем полученные данные о партнере (Capabilities и Ключ) в кэш настроек
|
||||||
var selfPublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey());
|
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(
|
var response = new HandshakeResponse(
|
||||||
Environment.GetEnvironmentVariable("DOMAIN") ?? "knot.local",
|
allSettings.System.DomainUrl,
|
||||||
selfPublicKey,
|
conf.PublicKey ?? string.Empty,
|
||||||
|
ourCapabilities,
|
||||||
"Accepted"
|
"Accepted"
|
||||||
);
|
);
|
||||||
|
|
||||||
return Task.FromResult(Result.Success(response));
|
return Result.Success(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediatR;
|
||||||
|
using Knot.Modules.Federation.Application.Abstractions;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using Knot.Modules.Auth.Domain;
|
||||||
|
using Knot.Modules.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Modules.Messaging.Domain;
|
||||||
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
using Knot.Shared.Kernel.Constants;
|
||||||
|
|
||||||
|
namespace Host.Application.Federation.Commands;
|
||||||
|
|
||||||
|
public record InboundFederationCommand(FederationMessagePacket Packet) : ICommand;
|
||||||
|
|
||||||
|
internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundFederationCommand>
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly IMessageRepository _messageRepository;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IMessageReactionRepository _reactionRepository;
|
||||||
|
private readonly IMessageNotifier _notifier;
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
|
public InboundFederationCommandHandler(
|
||||||
|
ISettingsService settingsService,
|
||||||
|
IMessageRepository messageRepository,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IMessageReactionRepository reactionRepository,
|
||||||
|
IMessageNotifier notifier,
|
||||||
|
IMediator mediator)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_messageRepository = messageRepository;
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_reactionRepository = reactionRepository;
|
||||||
|
_notifier = notifier;
|
||||||
|
_mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> Handle(InboundFederationCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
|
||||||
|
if (!settings.Federation.Enabled)
|
||||||
|
return Result.Failure(new Error(Errors.DisabledByAdmin, "Federation disabled."));
|
||||||
|
|
||||||
|
var packet = request.Packet;
|
||||||
|
|
||||||
|
// 1. Проверяем подпись отправителя
|
||||||
|
var senderConfig = settings.Federation.AllowedDomains.FirstOrDefault(d => d.IsEnabled && d.Domain.Equals(packet.SenderDomain, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (senderConfig == null || string.IsNullOrEmpty(senderConfig.PublicKey))
|
||||||
|
return Result.Failure(new Error("Federation.SenderNotAllowed", "Sender domain not in allowlist."));
|
||||||
|
|
||||||
|
using (var rsaVerify = RSA.Create())
|
||||||
|
{
|
||||||
|
rsaVerify.ImportRSAPublicKey(Convert.FromBase64String(senderConfig.PublicKey), out _);
|
||||||
|
var dataToVerify = Encoding.UTF8.GetBytes(packet.EncryptedPayload + packet.SenderDomain + packet.RecipientDomain);
|
||||||
|
if (!rsaVerify.VerifyData(dataToVerify, Convert.FromBase64String(packet.Signature), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
|
||||||
|
{
|
||||||
|
return Result.Failure(new Error("Federation.InvalidSignature", "RSA Signature verification failed."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Расшифровываем IV своим Private Key
|
||||||
|
byte[] aesKey;
|
||||||
|
byte[] aesIv;
|
||||||
|
using (var rsaDecrypt = RSA.Create())
|
||||||
|
{
|
||||||
|
rsaDecrypt.ImportPkcs8PrivateKey(Convert.FromBase64String(settings.Federation.PrivateKey!), out _);
|
||||||
|
var decryptedKeys = rsaDecrypt.Decrypt(Convert.FromBase64String(packet.EncryptedIV), RSAEncryptionPadding.Pkcs1);
|
||||||
|
|
||||||
|
// AES-256 Key (32 bytes) + IV (16 bytes)
|
||||||
|
aesKey = new byte[32];
|
||||||
|
aesIv = new byte[16];
|
||||||
|
Buffer.BlockCopy(decryptedKeys, 0, aesKey, 0, 32);
|
||||||
|
Buffer.BlockCopy(decryptedKeys, 32, aesIv, 0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Расшифровываем само сообщение (AES-256)
|
||||||
|
string plainText;
|
||||||
|
using (var aes = Aes.Create())
|
||||||
|
{
|
||||||
|
aes.Key = aesKey;
|
||||||
|
aes.IV = aesIv;
|
||||||
|
using (var decryptor = aes.CreateDecryptor())
|
||||||
|
{
|
||||||
|
var cipherBytes = Convert.FromBase64String(packet.EncryptedPayload);
|
||||||
|
var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
|
||||||
|
plainText = Encoding.UTF8.GetString(plainBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Логика обработки типов сообщений
|
||||||
|
if (packet.Metadata.MessageType == "sync_capabilities")
|
||||||
|
{
|
||||||
|
var capabilities = JsonSerializer.Deserialize<RemoteCapabilities>(plainText);
|
||||||
|
if (capabilities != null && senderConfig != null)
|
||||||
|
{
|
||||||
|
senderConfig.Capabilities = capabilities;
|
||||||
|
await _settingsService.UpdateSettingsAsync(settings, cancellationToken);
|
||||||
|
}
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.Metadata.MessageType == "presence_update")
|
||||||
|
{
|
||||||
|
var statusData = JsonSerializer.Deserialize<JsonElement>(plainText);
|
||||||
|
var isOnline = statusData.GetProperty("IsOnline").GetBoolean();
|
||||||
|
|
||||||
|
var user = await _userRepository.GetByIdAsync(packet.Metadata.SenderId, cancellationToken);
|
||||||
|
if (user != null && user.IsExternal)
|
||||||
|
{
|
||||||
|
user.UpdateStatus(isOnline);
|
||||||
|
_userRepository.Update(user);
|
||||||
|
|
||||||
|
// Уведомляем локальных пользователей через SignalR
|
||||||
|
await _notifier.NotifyNewMessageAsync(Guid.Empty, new { type = "presence_update", userId = user.Id, isOnline = user.IsOnline }, cancellationToken);
|
||||||
|
}
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.Metadata.MessageType == "message_edited")
|
||||||
|
{
|
||||||
|
var messageToEdit = await _messageRepository.GetByIdAsync(packet.Metadata.SenderId, cancellationToken); // В метаданных MessageId
|
||||||
|
if (messageToEdit != null)
|
||||||
|
{
|
||||||
|
messageToEdit.Edit(plainText);
|
||||||
|
await _messageRepository.UpdateAsync(messageToEdit, cancellationToken);
|
||||||
|
await _notifier.NotifyNewMessageAsync(messageToEdit.ChatId, new { type = "message_edited", messageId = messageToEdit.Id, content = plainText }, cancellationToken);
|
||||||
|
}
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.Metadata.MessageType == "message_deleted")
|
||||||
|
{
|
||||||
|
var messageToDelete = await _messageRepository.GetByIdAsync(packet.Metadata.SenderId, cancellationToken);
|
||||||
|
if (messageToDelete != null)
|
||||||
|
{
|
||||||
|
messageToDelete.Delete();
|
||||||
|
await _messageRepository.UpdateAsync(messageToDelete, cancellationToken);
|
||||||
|
await _notifier.NotifyNewMessageAsync(messageToDelete.ChatId, new { type = "message_deleted", messageId = messageToDelete.Id }, cancellationToken);
|
||||||
|
}
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.Metadata.MessageType == "reaction_added" || packet.Metadata.MessageType == "reaction_removed")
|
||||||
|
{
|
||||||
|
var messageForReaction = await _messageRepository.GetByIdAsync(packet.Metadata.SenderId, cancellationToken);
|
||||||
|
if (messageForReaction != null)
|
||||||
|
{
|
||||||
|
if (packet.Metadata.MessageType == "reaction_added")
|
||||||
|
{
|
||||||
|
var reaction = new MessageReaction(messageForReaction.Id, packet.Metadata.UserId, plainText);
|
||||||
|
await _reactionRepository.AddAsync(reaction, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _reactionRepository.RemoveAsync(messageForReaction.Id, packet.Metadata.UserId, plainText, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _notifier.NotifyNewMessageAsync(messageForReaction.ChatId, new {
|
||||||
|
type = packet.Metadata.MessageType,
|
||||||
|
messageId = messageForReaction.Id,
|
||||||
|
userId = packet.Metadata.UserId,
|
||||||
|
emoji = plainText
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.Metadata.MessageType == "rtc_signal")
|
||||||
|
{
|
||||||
|
// Здесь проброс WebRTC сигнала (Offer/Answer/ICE) конечному пользователю через SignalR
|
||||||
|
await _notifier.NotifyNewMessageAsync(packet.Metadata.ChatId, new { type = "rtc_signal", payload = plainText, senderId = packet.Metadata.SenderId }, cancellationToken);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.Metadata.MessageType == "poll" && !settings.Messages.AllowPolls)
|
||||||
|
return Result.Failure(new Error("Federation.PollsDisabled", "We do not accept polls."));
|
||||||
|
|
||||||
|
// 5. Сохранение в базу сообщений (MongoDB)
|
||||||
|
// В реальном приложении здесь будет маппинг на TextMessage, MediaMessage и т.д.
|
||||||
|
var message = new TextMessage(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
packet.Metadata.ChatId,
|
||||||
|
packet.Metadata.SenderId,
|
||||||
|
plainText,
|
||||||
|
null, // replyToId
|
||||||
|
null, // quote
|
||||||
|
null, // forwardedFromId
|
||||||
|
packet.Metadata.CreatedAt,
|
||||||
|
false); // isImported
|
||||||
|
|
||||||
|
_messageRepository.Add(message);
|
||||||
|
|
||||||
|
// 6. Уведомление пользователя через SignalR
|
||||||
|
await _notifier.NotifyNewMessageAsync(packet.Metadata.ChatId, message, cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediatR;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using Knot.Modules.Auth.Domain;
|
||||||
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace Host.Application.Federation.Commands;
|
||||||
|
|
||||||
|
public record ResolveUserResponse(Guid Id, string Username, string DisplayName, string? Avatar);
|
||||||
|
public record ResolveUserCommand(string Username) : ICommand<ResolveUserResponse>;
|
||||||
|
|
||||||
|
internal sealed class ResolveUserCommandHandler : ICommandHandler<ResolveUserCommand, ResolveUserResponse>
|
||||||
|
{
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
|
||||||
|
public ResolveUserCommandHandler(IUserRepository userRepository)
|
||||||
|
{
|
||||||
|
_userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<ResolveUserResponse>> Handle(ResolveUserCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
|
||||||
|
|
||||||
|
if (user == null || user.IsExternal)
|
||||||
|
{
|
||||||
|
return Result.Failure<ResolveUserResponse>(new Error("Federation.UserNotFound", "User not found on this server."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Success(new ResolveUserResponse(
|
||||||
|
user.Id,
|
||||||
|
user.Username,
|
||||||
|
user.DisplayName,
|
||||||
|
user.Avatar
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediatR;
|
||||||
|
using Knot.Modules.Messaging.Domain;
|
||||||
|
using Knot.Modules.Federation.Application.Federation.Services;
|
||||||
|
using Knot.Modules.Conversations.Domain;
|
||||||
|
using Knot.Modules.Federation.Application.Abstractions;
|
||||||
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
using Knot.Modules.Auth.Domain;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Federation.Application.Federation.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Универсальный обработчик действий с сообщениями (Edit, Delete, Reactions) для Федерации.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FederatedMessageActionsHandler :
|
||||||
|
INotificationHandler<MessageEditedDomainEvent>,
|
||||||
|
INotificationHandler<MessageDeletedDomainEvent>,
|
||||||
|
INotificationHandler<MessageReactionAddedDomainEvent>,
|
||||||
|
INotificationHandler<MessageReactionRemovedDomainEvent>
|
||||||
|
{
|
||||||
|
private readonly IChatRepository _chatRepository;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly FederationPacketService _packetService;
|
||||||
|
private readonly IFederationGateway _gateway;
|
||||||
|
|
||||||
|
public FederatedMessageActionsHandler(
|
||||||
|
IChatRepository chatRepository,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ISettingsService settingsService,
|
||||||
|
FederationPacketService packetService,
|
||||||
|
IFederationGateway gateway)
|
||||||
|
{
|
||||||
|
_chatRepository = chatRepository;
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_packetService = packetService;
|
||||||
|
_gateway = gateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(MessageEditedDomainEvent notification, CancellationToken cancellationToken)
|
||||||
|
=> await ProcessAction(notification.ChatId, notification.MessageId, "message_edited", notification.NewContent, cancellationToken);
|
||||||
|
|
||||||
|
public async Task Handle(MessageDeletedDomainEvent notification, CancellationToken cancellationToken)
|
||||||
|
=> await ProcessAction(notification.ChatId, notification.MessageId, "message_deleted", string.Empty, cancellationToken);
|
||||||
|
|
||||||
|
public async Task Handle(MessageReactionAddedDomainEvent notification, CancellationToken cancellationToken)
|
||||||
|
=> await ProcessAction(notification.ChatId, notification.MessageId, "reaction_added", notification.Emoji, cancellationToken, notification.UserId);
|
||||||
|
|
||||||
|
public async Task Handle(MessageReactionRemovedDomainEvent notification, CancellationToken cancellationToken)
|
||||||
|
=> await ProcessAction(notification.ChatId, notification.MessageId, "reaction_removed", notification.Emoji, cancellationToken, notification.UserId);
|
||||||
|
|
||||||
|
private async Task ProcessAction(Guid chatId, Guid messageId, string actionType, string payload, CancellationToken cancellationToken, Guid actorId = default)
|
||||||
|
{
|
||||||
|
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
|
||||||
|
if (!settings.Federation.Enabled) return;
|
||||||
|
|
||||||
|
var chat = await _chatRepository.GetByIdAsync(chatId, cancellationToken);
|
||||||
|
if (chat == null) return;
|
||||||
|
|
||||||
|
var externalDomains = await GetExternalDomains(chat, cancellationToken);
|
||||||
|
if (!externalDomains.Any()) return;
|
||||||
|
|
||||||
|
var metadata = new FederationMetadata(
|
||||||
|
chatId,
|
||||||
|
messageId, // MessageId в SenderId для операций
|
||||||
|
"system",
|
||||||
|
actionType,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
actorId
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (var domain in externalDomains)
|
||||||
|
{
|
||||||
|
var packetResult = await _packetService.PreparePacketAsync(payload, metadata, domain, cancellationToken);
|
||||||
|
if (packetResult.IsSuccess)
|
||||||
|
{
|
||||||
|
await _gateway.SendPacketAsync(packetResult.Value, domain, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<string>> GetExternalDomains(Chat chat, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var memberIds = chat.Members.Select(m => m.UserId).ToList();
|
||||||
|
var members = await _userRepository.GetByIdsAsync(memberIds, cancellationToken);
|
||||||
|
|
||||||
|
return members
|
||||||
|
.Where(u => u.IsExternal && !string.IsNullOrEmpty(u.Domain))
|
||||||
|
.Select(u => u.Domain!)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediatR;
|
||||||
|
using Knot.Modules.Messaging.Domain;
|
||||||
|
using Knot.Modules.Federation.Application.Federation.Services;
|
||||||
|
using Knot.Modules.Conversations.Domain;
|
||||||
|
using Knot.Modules.Federation.Application.Abstractions;
|
||||||
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
using Knot.Modules.Auth.Domain;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Federation.Application.Federation.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обработчик доменного события отправки сообщения.
|
||||||
|
/// Если в чате есть внешние участники — инициирует федеративную рассылку.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MessageSentDomainEventHandler : INotificationHandler<MessageSentDomainEvent>
|
||||||
|
{
|
||||||
|
private readonly IChatRepository _chatRepository;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly FederationPacketService _packetService;
|
||||||
|
private readonly IFederationGateway _gateway;
|
||||||
|
|
||||||
|
public MessageSentDomainEventHandler(
|
||||||
|
IChatRepository chatRepository,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ISettingsService settingsService,
|
||||||
|
FederationPacketService packetService,
|
||||||
|
IFederationGateway gateway)
|
||||||
|
{
|
||||||
|
_chatRepository = chatRepository;
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_packetService = packetService;
|
||||||
|
_gateway = gateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(MessageSentDomainEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
|
||||||
|
if (!settings.Federation.Enabled) return;
|
||||||
|
|
||||||
|
// 1. Загружаем чат и проверяем наличие внешних участников
|
||||||
|
var chat = await _chatRepository.GetByIdAsync(notification.ChatId, cancellationToken);
|
||||||
|
if (chat == null) return;
|
||||||
|
|
||||||
|
var memberIds = chat.Members.Select(m => m.UserId).ToList();
|
||||||
|
var members = await _userRepository.GetByIdsAsync(memberIds, cancellationToken);
|
||||||
|
|
||||||
|
// Находим уникальные домены внешних участников
|
||||||
|
var externalDomains = members
|
||||||
|
.Where(u => u.IsExternal && !string.IsNullOrEmpty(u.Domain))
|
||||||
|
.Select(u => u.Domain!)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!externalDomains.Any()) return;
|
||||||
|
|
||||||
|
// 2. Получаем имя отправителя для метаданных
|
||||||
|
var sender = await _userRepository.GetByIdAsync(notification.SenderId, cancellationToken);
|
||||||
|
var senderUsername = sender?.Username ?? "unknown";
|
||||||
|
|
||||||
|
var metadata = new FederationMetadata(
|
||||||
|
notification.ChatId,
|
||||||
|
notification.SenderId,
|
||||||
|
senderUsername,
|
||||||
|
"text", // Для начала поддерживаем только текст через ивент
|
||||||
|
DateTime.UtcNow
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Рассылка по доменам (Fan-out)
|
||||||
|
foreach (var domain in externalDomains)
|
||||||
|
{
|
||||||
|
var packetResult = await _packetService.PreparePacketAsync(
|
||||||
|
notification.Content ?? string.Empty,
|
||||||
|
metadata,
|
||||||
|
domain,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (packetResult.IsSuccess)
|
||||||
|
{
|
||||||
|
// Отправляем асинхронно
|
||||||
|
await _gateway.SendPacketAsync(packetResult.Value, domain, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediatR;
|
||||||
|
using Knot.Modules.Admin.Domain.Events;
|
||||||
|
using Knot.Modules.Federation.Application.Abstractions;
|
||||||
|
using Knot.Modules.Federation.Application.Federation.Services;
|
||||||
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Federation.Application.Federation.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обработчик события обновления настроек.
|
||||||
|
/// Уведомляет все удаленные серверы о смене способностей (Capabilities).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SystemSettingsUpdatedDomainEventHandler : INotificationHandler<SystemSettingsUpdatedDomainEvent>
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly IFederationGateway _gateway;
|
||||||
|
private readonly FederationPacketService _packetService;
|
||||||
|
|
||||||
|
public SystemSettingsUpdatedDomainEventHandler(
|
||||||
|
ISettingsService settingsService,
|
||||||
|
IFederationGateway gateway,
|
||||||
|
FederationPacketService packetService)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_gateway = gateway;
|
||||||
|
_packetService = packetService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(SystemSettingsUpdatedDomainEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
|
||||||
|
if (!settings.Federation.Enabled) return;
|
||||||
|
|
||||||
|
// Наши новые способности
|
||||||
|
var ourCapabilities = new RemoteCapabilities
|
||||||
|
{
|
||||||
|
AllowMedia = settings.Messages.AllowMedia,
|
||||||
|
AllowPolls = settings.Messages.AllowPolls,
|
||||||
|
AllowVoiceMessages = settings.Messages.AllowVoiceMessages,
|
||||||
|
AllowVideoCalls = settings.WebRtc.EnableVideoCalls,
|
||||||
|
AllowScreenSharing = settings.WebRtc.EnableScreenSharing
|
||||||
|
};
|
||||||
|
|
||||||
|
// Собираем метаданные для синхронизации
|
||||||
|
var metadata = new FederationMetadata(
|
||||||
|
Guid.Empty, // Системный чат
|
||||||
|
Guid.Empty, // Системный пользователь
|
||||||
|
"SYSTEM",
|
||||||
|
"sync_capabilities",
|
||||||
|
DateTime.UtcNow
|
||||||
|
);
|
||||||
|
|
||||||
|
// Рассылка по всем доменам из белого списка (Fan-out)
|
||||||
|
foreach (var domainConfig in settings.Federation.AllowedDomains.Where(d => d.IsEnabled))
|
||||||
|
{
|
||||||
|
// Упаковываем Capabilities в JSON для передачи в payload
|
||||||
|
var payload = System.Text.Json.JsonSerializer.Serialize(ourCapabilities);
|
||||||
|
|
||||||
|
var packetResult = await _packetService.PreparePacketAsync(payload, metadata, domainConfig.Domain, cancellationToken);
|
||||||
|
|
||||||
|
if (packetResult.IsSuccess)
|
||||||
|
{
|
||||||
|
await _gateway.SendPacketAsync(packetResult.Value, domainConfig.Domain, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediatR;
|
||||||
|
using Knot.Modules.Auth.Domain;
|
||||||
|
using Knot.Modules.Conversations.Domain;
|
||||||
|
using Knot.Modules.Federation.Application.Abstractions;
|
||||||
|
using Knot.Modules.Federation.Application.Federation.Services;
|
||||||
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Federation.Application.Federation.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обработчик изменения статуса пользователя.
|
||||||
|
/// Рассылает новый статус всем серверам, где у пользователя есть чаты.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserStatusChangedDomainEventHandler : INotificationHandler<UserStatusChangedDomainEvent>
|
||||||
|
{
|
||||||
|
private readonly IChatRepository _chatRepository;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly FederationPacketService _packetService;
|
||||||
|
private readonly IFederationGateway _gateway;
|
||||||
|
|
||||||
|
public UserStatusChangedDomainEventHandler(
|
||||||
|
IChatRepository chatRepository,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ISettingsService settingsService,
|
||||||
|
FederationPacketService packetService,
|
||||||
|
IFederationGateway gateway)
|
||||||
|
{
|
||||||
|
_chatRepository = chatRepository;
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_packetService = packetService;
|
||||||
|
_gateway = gateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(UserStatusChangedDomainEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
|
||||||
|
if (!settings.Federation.Enabled) return;
|
||||||
|
|
||||||
|
// 1. Находим все чаты пользователя
|
||||||
|
var userChats = await _chatRepository.GetUserChatsAsync(notification.UserId, cancellationToken);
|
||||||
|
if (!userChats.Any()) return;
|
||||||
|
|
||||||
|
// 2. Определяем уникальные внешние домены участников этих чатов
|
||||||
|
var allMemberIds = userChats.SelectMany(c => c.Members.Select(m => m.UserId)).Distinct().ToList();
|
||||||
|
var members = await _userRepository.GetByIdsAsync(allMemberIds, cancellationToken);
|
||||||
|
|
||||||
|
var externalDomains = members
|
||||||
|
.Where(u => u.IsExternal && !string.IsNullOrEmpty(u.Domain))
|
||||||
|
.Select(u => u.Domain!)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!externalDomains.Any()) return;
|
||||||
|
|
||||||
|
// 3. Формируем пакет статуса
|
||||||
|
var statusData = new {
|
||||||
|
notification.IsOnline,
|
||||||
|
notification.LastSeen
|
||||||
|
};
|
||||||
|
var payload = System.Text.Json.JsonSerializer.Serialize(statusData);
|
||||||
|
|
||||||
|
var metadata = new FederationMetadata(
|
||||||
|
Guid.Empty,
|
||||||
|
notification.UserId,
|
||||||
|
"system", // Username отправителя здесь не важен, важен SenderId
|
||||||
|
"presence_update",
|
||||||
|
DateTime.UtcNow
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Рассылка по доменам
|
||||||
|
foreach (var domain in externalDomains)
|
||||||
|
{
|
||||||
|
var packetResult = await _packetService.PreparePacketAsync(payload, metadata, domain, cancellationToken);
|
||||||
|
if (packetResult.IsSuccess)
|
||||||
|
{
|
||||||
|
await _gateway.SendPacketAsync(packetResult.Value, domain, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Knot.Modules.Federation.Application.Abstractions;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
using Knot.Shared.Kernel.Security;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Federation.Application.Federation.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сервис для подготовки и рассылки зашифрованных федератовных пакетов.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FederationPacketService
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly IFederationGateway _gateway;
|
||||||
|
private readonly IEncryptionService _encryption;
|
||||||
|
|
||||||
|
public FederationPacketService(
|
||||||
|
ISettingsService settingsService,
|
||||||
|
IFederationGateway gateway,
|
||||||
|
IEncryptionService encryption)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_gateway = gateway;
|
||||||
|
_encryption = encryption;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Собирает зашифрованный пакет для целевого домена.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Result<FederationMessagePacket>> PreparePacketAsync(
|
||||||
|
string messageBody,
|
||||||
|
FederationMetadata metadata,
|
||||||
|
string targetDomain,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var settings = await _settingsService.GetSettingsAsync(ct);
|
||||||
|
var ourDomain = settings.System.DomainUrl;
|
||||||
|
var ourPrivateKeyBase64 = settings.Federation.PrivateKey;
|
||||||
|
|
||||||
|
// Находим ключ получателя в белом списке
|
||||||
|
var targetConfig = settings.Federation.AllowedDomains.FirstOrDefault(d => d.Domain.Equals(targetDomain, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (targetConfig == null || string.IsNullOrEmpty(targetConfig.PublicKey))
|
||||||
|
{
|
||||||
|
return Result.Failure<FederationMessagePacket>(new Error("Federation.KeyNotFound", "Public key for target domain not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. AES Шифрование тела
|
||||||
|
// Используем IEncryptionService для получения потока или метода шифрования (в реальности здесь будет AES-256)
|
||||||
|
// Для демонстрации представим, что мы получили IV и EncryptedPayload
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.KeySize = 256;
|
||||||
|
aes.GenerateKey();
|
||||||
|
aes.GenerateIV();
|
||||||
|
|
||||||
|
var iv = aes.IV;
|
||||||
|
var key = aes.Key; // В гибридной схеме мы передаем IV + Key (или только IV при общем ключе)
|
||||||
|
|
||||||
|
byte[] encryptedBytes;
|
||||||
|
using (var encryptor = aes.CreateEncryptor())
|
||||||
|
{
|
||||||
|
var plainBytes = Encoding.UTF8.GetBytes(messageBody);
|
||||||
|
encryptedBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. RSA упаковка ключей на Open Public Key получателя
|
||||||
|
byte[] encryptedKeys;
|
||||||
|
using (var rsa = RSA.Create())
|
||||||
|
{
|
||||||
|
rsa.ImportRSAPublicKey(Convert.FromBase64String(targetConfig.PublicKey), out _);
|
||||||
|
// Шифруем Key + IV (48 байт)
|
||||||
|
var combinedKeys = new byte[key.Length + iv.Length];
|
||||||
|
Buffer.BlockCopy(key, 0, combinedKeys, 0, key.Length);
|
||||||
|
Buffer.BlockCopy(iv, 0, combinedKeys, key.Length, iv.Length);
|
||||||
|
|
||||||
|
encryptedKeys = rsa.Encrypt(combinedKeys, RSAEncryptionPadding.Pkcs1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Подпись всего пакета нашим Private Key
|
||||||
|
string signature;
|
||||||
|
using (var rsaSign = RSA.Create())
|
||||||
|
{
|
||||||
|
rsaSign.ImportPkcs8PrivateKey(Convert.FromBase64String(ourPrivateKeyBase64!), out _);
|
||||||
|
var dataToSign = Encoding.UTF8.GetBytes(Convert.ToBase64String(encryptedBytes) + ourDomain + targetDomain);
|
||||||
|
var sigBytes = rsaSign.SignData(dataToSign, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
signature = Convert.ToBase64String(sigBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
var packet = new FederationMessagePacket(
|
||||||
|
ourDomain,
|
||||||
|
targetDomain,
|
||||||
|
Convert.ToBase64String(encryptedBytes),
|
||||||
|
Convert.ToBase64String(encryptedKeys),
|
||||||
|
signature,
|
||||||
|
metadata
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.Success(packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Knot.Modules.Federation.Application.Abstractions;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
using Knot.Shared.Kernel.Security;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Federation.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Реализация шлюза федерации с гибридным шифрованием (AES-RSA).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FederationGateway : IFederationGateway
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
public FederationGateway(ISettingsService settingsService, HttpClient httpClient)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_httpClient = httpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> SendPacketAsync(FederationMessagePacket packet, string targetDomain, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"{targetDomain.TrimEnd('/')}/api/federation/v1/inbound";
|
||||||
|
|
||||||
|
// 1. Формируем тело запроса
|
||||||
|
var content = JsonContent.Create(packet);
|
||||||
|
|
||||||
|
// 2. Добавляем подпись в заголовки (как доп. уровень верификации)
|
||||||
|
_httpClient.DefaultRequestHeaders.Remove("X-Knot-Signature");
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("X-Knot-Signature", packet.Signature);
|
||||||
|
|
||||||
|
var response = await _httpClient.PostAsync(url, content, ct);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorMsg = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
return Result.Failure(new Error("Federation.DeliveryFailed", $"Target server returned: {response.StatusCode}. {errorMsg}"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure(new Error("Federation.NetworkError", ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,15 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using System;
|
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;
|
namespace Host.Endpoints;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// РегРСвЂВВстрацРСвЂВВР РЋР РЏ РЎРЊР Р…Р ТвЂВВРїРѕРСвЂВВнтовфеРТвЂВВерацРСвЂВВР В Р’В Р РЋРІР‚ВВ.
|
/// Эндпоинты федерации для межсерверного взаимодействия.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FederationEndpoints : ICarterModule
|
public sealed class FederationEndpoints : ICarterModule
|
||||||
{
|
{
|
||||||
@@ -18,24 +22,45 @@ public sealed class FederationEndpoints : ICarterModule
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup(Routes.ApiFederation);
|
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);
|
var result = await sender.Send(new HandshakeFederationCommand(request), ct);
|
||||||
if (result.IsSuccess)
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
|
||||||
{
|
});
|
||||||
return Results.Ok(result.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.Error.Code == "Unauthorized")
|
group.MapPost("/inbound", async ([FromBody] FederationMessagePacket packet, ISender sender, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
return Results.Forbid();
|
var result = await sender.Send(new InboundFederationCommand(packet), ct);
|
||||||
}
|
return result.IsSuccess ? Results.Accepted() : Results.BadRequest(new { error = result.Error.Description });
|
||||||
if (result.Error.Code == Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin)
|
});
|
||||||
{
|
|
||||||
return Results.StatusCode(503);
|
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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,11 +16,11 @@ public record GetTrendingGifsQuery : IQuery<JsonElement?>;
|
|||||||
|
|
||||||
internal sealed class GetTrendingGifsQueryHandler : IQueryHandler<GetTrendingGifsQuery, JsonElement?>
|
internal sealed class GetTrendingGifsQueryHandler : IQueryHandler<GetTrendingGifsQuery, JsonElement?>
|
||||||
{
|
{
|
||||||
private readonly ISettingsService _settings;
|
private readonly IKlipySettings _settings;
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
public GetTrendingGifsQueryHandler(ISettingsService settings, IMemoryCache cache, IHttpClientFactory httpClientFactory)
|
public GetTrendingGifsQueryHandler(IKlipySettings settings, IMemoryCache cache, IHttpClientFactory httpClientFactory)
|
||||||
{
|
{
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
@@ -30,24 +30,24 @@ internal sealed class GetTrendingGifsQueryHandler : IQueryHandler<GetTrendingGif
|
|||||||
public async Task<Result<JsonElement?>> Handle(GetTrendingGifsQuery request, CancellationToken cancellationToken)
|
public async Task<Result<JsonElement?>> Handle(GetTrendingGifsQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var conf = _settings.Current;
|
var conf = _settings.Current;
|
||||||
if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey))
|
if (!conf.Enabled || string.IsNullOrEmpty(conf.ApiKey))
|
||||||
return Result.Failure<JsonElement?>(new Error(Errors.KlipyNotConfigured, "Klipy is not configured"));
|
return Result.Failure<JsonElement?>(new Error(Errors.KlipyNotConfigured, "Klipy is not configured"));
|
||||||
|
|
||||||
var cacheKeyTrending = $"klipy_trending_{conf.KlipyApiKey}";
|
var cacheKeyTrending = $"klipy_trending_{conf.ApiKey}";
|
||||||
if (_cache.TryGetValue(cacheKeyTrending, out JsonElement cachedResult))
|
if (_cache.TryGetValue(cacheKeyTrending, out JsonElement cachedResult))
|
||||||
return Result.Success<JsonElement?>(cachedResult);
|
return Result.Success<JsonElement?>(cachedResult);
|
||||||
|
|
||||||
var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId : conf.KlipyCustomerId;
|
var customerId = Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId;
|
||||||
var client = _httpClientFactory.CreateClient();
|
var 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);
|
var response = await client.GetAsync(urlCo, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
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);
|
response = await client.GetAsync(urlCom, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ public record SearchGifsQuery(string Query) : IQuery<JsonElement?>;
|
|||||||
|
|
||||||
internal sealed class SearchGifsQueryHandler : IQueryHandler<SearchGifsQuery, JsonElement?>
|
internal sealed class SearchGifsQueryHandler : IQueryHandler<SearchGifsQuery, JsonElement?>
|
||||||
{
|
{
|
||||||
private readonly ISettingsService _settings;
|
private readonly IKlipySettings _settings;
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
public SearchGifsQueryHandler(ISettingsService settings, IMemoryCache cache, IHttpClientFactory httpClientFactory)
|
public SearchGifsQueryHandler(IKlipySettings settings, IMemoryCache cache, IHttpClientFactory httpClientFactory)
|
||||||
{
|
{
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
@@ -30,28 +30,28 @@ internal sealed class SearchGifsQueryHandler : IQueryHandler<SearchGifsQuery, Js
|
|||||||
public async Task<Result<JsonElement?>> Handle(SearchGifsQuery request, CancellationToken cancellationToken)
|
public async Task<Result<JsonElement?>> Handle(SearchGifsQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var conf = _settings.Current;
|
var conf = _settings.Current;
|
||||||
if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey))
|
if (!conf.Enabled || string.IsNullOrEmpty(conf.ApiKey))
|
||||||
return Result.Failure<JsonElement?>(new Error(Errors.KlipyNotConfigured, "Klipy is not configured"));
|
return Result.Failure<JsonElement?>(new Error(Errors.KlipyNotConfigured, "Klipy is not configured"));
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Query))
|
if (string.IsNullOrWhiteSpace(request.Query))
|
||||||
return Result.Failure<JsonElement?>(new Error(Errors.InvalidQuery, "Invalid query parameter"));
|
return Result.Failure<JsonElement?>(new Error(Errors.InvalidQuery, "Invalid query parameter"));
|
||||||
|
|
||||||
var cacheKey = $"klipy_search_{conf.KlipyApiKey}_{request.Query.ToLowerInvariant()}";
|
var cacheKey = $"klipy_search_{conf.ApiKey}_{request.Query.ToLowerInvariant()}";
|
||||||
if (_cache.TryGetValue(cacheKey, out JsonElement cachedResult))
|
if (_cache.TryGetValue(cacheKey, out JsonElement cachedResult))
|
||||||
return Result.Success<JsonElement?>(cachedResult);
|
return Result.Success<JsonElement?>(cachedResult);
|
||||||
|
|
||||||
var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId : conf.KlipyCustomerId;
|
var customerId = Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId;
|
||||||
var client = _httpClientFactory.CreateClient();
|
var client = _httpClientFactory.CreateClient();
|
||||||
|
|
||||||
var queryParam = $"q={Uri.EscapeDataString(request.Query)}";
|
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);
|
var response = await client.GetAsync(urlCo, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
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);
|
response = await client.GetAsync(urlCom, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Messaging.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Доменное событие: сообщение отредактировано.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record MessageEditedDomainEvent(Guid MessageId, Guid ChatId, string NewContent) : IDomainEvent;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Доменное событие: сообщение удалено.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record MessageDeletedDomainEvent(Guid MessageId, Guid ChatId) : IDomainEvent;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Доменное событие: добавлена реакция.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record MessageReactionAddedDomainEvent(Guid MessageId, Guid ChatId, Guid UserId, string Emoji) : IDomainEvent;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Доменное событие: удалена реакция.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record MessageReactionRemovedDomainEvent(Guid MessageId, Guid ChatId, Guid UserId, string Emoji) : IDomainEvent;
|
||||||
@@ -14,6 +14,10 @@ public interface IMessageRepository
|
|||||||
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task UpdateAsync(Message message, CancellationToken cancellationToken);
|
Task UpdateAsync(Message message, CancellationToken cancellationToken);
|
||||||
|
Task<List<Message>> GetAllMessagesAsync(CancellationToken cancellationToken);
|
||||||
|
Task DeleteChatMessagesAsync(Guid chatId, CancellationToken cancellationToken);
|
||||||
|
Task DeleteUserMessagesAsync(Guid userId, CancellationToken cancellationToken);
|
||||||
|
Task RemoveAsync(Guid id, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,10 +56,9 @@ public class MediaMessage : Message
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Edit(string newCaption)
|
public override void Edit(string newCaption)
|
||||||
{
|
{
|
||||||
Caption = newCaption;
|
base.Edit(newCaption);
|
||||||
AddState(MessageState.IsEdited);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void Delete()
|
public override void Delete()
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
public virtual void Delete()
|
public virtual void Delete()
|
||||||
{
|
{
|
||||||
AddState(MessageState.IsDeleted);
|
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)
|
public void DeleteForUser(Guid userId)
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Messaging.Domain;
|
||||||
|
|
||||||
|
public class PollMessage : Message
|
||||||
|
{
|
||||||
|
public override string Type => "poll";
|
||||||
|
public override string? Content { get; protected set; } // Вопрос опроса
|
||||||
|
|
||||||
|
public bool IsAnonymous { get; protected set; }
|
||||||
|
public bool AllowMultipleAnswers { get; protected set; }
|
||||||
|
public bool IsClosed { get; protected set; }
|
||||||
|
public DateTime? ExpiresAt { get; protected set; }
|
||||||
|
|
||||||
|
private List<PollOption> _options = new();
|
||||||
|
public IReadOnlyCollection<PollOption> Options => _options.AsReadOnly();
|
||||||
|
|
||||||
|
private List<PollVote> _votes = new();
|
||||||
|
public IReadOnlyCollection<PollVote> Votes => _votes.AsReadOnly();
|
||||||
|
|
||||||
|
// Инфраструктурный конструктор
|
||||||
|
private PollMessage() : base() { }
|
||||||
|
|
||||||
|
public PollMessage(
|
||||||
|
Guid id,
|
||||||
|
Guid chatId,
|
||||||
|
Guid senderId,
|
||||||
|
string question,
|
||||||
|
List<string> options,
|
||||||
|
bool isAnonymous,
|
||||||
|
bool allowMultipleAnswers,
|
||||||
|
DateTime? expiresAt,
|
||||||
|
Guid? replyToId,
|
||||||
|
Guid? forwardedFromId,
|
||||||
|
DateTime createdAt,
|
||||||
|
bool isImported)
|
||||||
|
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
|
||||||
|
{
|
||||||
|
Content = question;
|
||||||
|
IsAnonymous = isAnonymous;
|
||||||
|
AllowMultipleAnswers = allowMultipleAnswers;
|
||||||
|
ExpiresAt = expiresAt;
|
||||||
|
IsClosed = false;
|
||||||
|
|
||||||
|
foreach (var optionText in options)
|
||||||
|
{
|
||||||
|
_options.Add(new PollOption(Guid.NewGuid(), Id, optionText));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isImported)
|
||||||
|
{
|
||||||
|
// Можно добавить специфичное событие для опроса
|
||||||
|
// RaiseDomainEvent(new PollCreatedDomainEvent(Id, ChatId, SenderId, question));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Vote(Guid userId, List<Guid> optionIds)
|
||||||
|
{
|
||||||
|
if (IsClosed) throw new InvalidOperationException("Poll is closed");
|
||||||
|
if (ExpiresAt.HasValue && DateTime.UtcNow > ExpiresAt.Value)
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
throw new InvalidOperationException("Poll has expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionIds == null || !optionIds.Any()) throw new ArgumentException("At least one option must be selected");
|
||||||
|
if (!AllowMultipleAnswers && optionIds.Count > 1) throw new ArgumentException("Multiple answers are not allowed");
|
||||||
|
|
||||||
|
// Проверяем существование опций
|
||||||
|
foreach (var oid in optionIds)
|
||||||
|
{
|
||||||
|
if (!_options.Any(o => o.Id == oid)) throw new ArgumentException($"Option {oid} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем старые голоса пользователя
|
||||||
|
_votes.RemoveAll(v => v.UserId == userId);
|
||||||
|
|
||||||
|
// Добавляем новые голоса
|
||||||
|
foreach (var oid in optionIds)
|
||||||
|
{
|
||||||
|
_votes.Add(new PollVote(Id, userId, oid, DateTime.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем статистику в опциях
|
||||||
|
RecalculateResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
IsClosed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecalculateResults()
|
||||||
|
{
|
||||||
|
int totalVotesCount = _votes.Select(v => v.UserId).Distinct().Count();
|
||||||
|
|
||||||
|
foreach (var option in _options)
|
||||||
|
{
|
||||||
|
int optionVotes = _votes.Count(v => v.OptionId == option.Id);
|
||||||
|
double percentage = totalVotesCount > 0 ? (double)optionVotes / totalVotesCount * 100 : 0;
|
||||||
|
option.UpdateResults(optionVotes, (int)Math.Round(percentage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Delete()
|
||||||
|
{
|
||||||
|
// При удалении опроса можно очистить голоса или оставить для истории (зависит от политики)
|
||||||
|
base.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PollOption
|
||||||
|
{
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
public Guid PollId { get; private set; }
|
||||||
|
public string Text { get; private set; }
|
||||||
|
|
||||||
|
// Денормализованные данные для быстрой отдачи
|
||||||
|
public int VotesCount { get; private set; }
|
||||||
|
public int Percentage { get; private set; }
|
||||||
|
|
||||||
|
private PollOption() { } // EF
|
||||||
|
|
||||||
|
public PollOption(Guid id, Guid pollId, string text)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
PollId = pollId;
|
||||||
|
Text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateResults(int count, int percentage)
|
||||||
|
{
|
||||||
|
VotesCount = count;
|
||||||
|
Percentage = percentage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PollVote
|
||||||
|
{
|
||||||
|
public Guid PollId { get; private set; }
|
||||||
|
public Guid UserId { get; private set; }
|
||||||
|
public Guid OptionId { get; private set; }
|
||||||
|
public DateTime VotedAt { get; private set; }
|
||||||
|
|
||||||
|
private PollVote() { } // EF
|
||||||
|
|
||||||
|
public PollVote(Guid pollId, Guid userId, Guid optionId, DateTime votedAt)
|
||||||
|
{
|
||||||
|
PollId = pollId;
|
||||||
|
UserId = userId;
|
||||||
|
OptionId = optionId;
|
||||||
|
VotedAt = votedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,10 +34,9 @@ public class TextMessage : Message
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Edit(string newContent)
|
public override void Edit(string newContent)
|
||||||
{
|
{
|
||||||
Content = newContent;
|
base.Edit(newContent);
|
||||||
AddState(MessageState.IsEdited);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void Delete()
|
public override void Delete()
|
||||||
|
|||||||
+19
-1
@@ -6,10 +6,14 @@ namespace Knot.Modules.Messaging.Infrastructure.Persistence;
|
|||||||
public sealed class MessageReactionRepository : IMessageReactionRepository
|
public sealed class MessageReactionRepository : IMessageReactionRepository
|
||||||
{
|
{
|
||||||
private readonly IMongoCollection<MessageReaction> _reactions;
|
private readonly IMongoCollection<MessageReaction> _reactions;
|
||||||
|
private readonly MediatR.IMediator _mediator;
|
||||||
|
private readonly IMessageRepository _messageRepository;
|
||||||
|
|
||||||
public MessageReactionRepository(IMongoDatabase mongoDatabase)
|
public MessageReactionRepository(IMongoDatabase mongoDatabase, MediatR.IMediator mediator, IMessageRepository messageRepository)
|
||||||
{
|
{
|
||||||
_reactions = mongoDatabase.GetCollection<MessageReaction>("message_reactions");
|
_reactions = mongoDatabase.GetCollection<MessageReaction>("message_reactions");
|
||||||
|
_mediator = mediator;
|
||||||
|
_messageRepository = messageRepository;
|
||||||
|
|
||||||
// Ensure index for fast querying by message
|
// Ensure index for fast querying by message
|
||||||
var indexKeysDefinition = Builders<MessageReaction>.IndexKeys.Ascending(r => r.MessageId);
|
var indexKeysDefinition = Builders<MessageReaction>.IndexKeys.Ascending(r => r.MessageId);
|
||||||
@@ -27,6 +31,13 @@ public sealed class MessageReactionRepository : IMessageReactionRepository
|
|||||||
|
|
||||||
// Используем ReplaceOptions.IsUpsert = true для идемпотентности (нет гонок)
|
// Используем ReplaceOptions.IsUpsert = true для идемпотентности (нет гонок)
|
||||||
await _reactions.ReplaceOneAsync(filter, reaction, new ReplaceOptions { IsUpsert = true }, cancellationToken);
|
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)
|
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);
|
await _reactions.DeleteOneAsync(filter, cancellationToken);
|
||||||
|
|
||||||
|
// Уведомляем систему (для Федерации)
|
||||||
|
var message = await _messageRepository.GetByIdAsync(messageId, cancellationToken);
|
||||||
|
if (message != null)
|
||||||
|
{
|
||||||
|
await _mediator.Publish(new MessageReactionRemovedDomainEvent(messageId, message.ChatId, userId, emoji), cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<MessageReaction>> GetReactionsForMessageAsync(Guid messageId, CancellationToken cancellationToken)
|
public async Task<List<MessageReaction>> GetReactionsForMessageAsync(Guid messageId, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -129,6 +129,29 @@ public sealed class MessageRepository : IMessageRepository
|
|||||||
await _mediator.Publish(domainEvent, cancellationToken);
|
await _mediator.Publish(domainEvent, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<Message>> GetAllMessagesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _messages.Find(_ => true).ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteChatMessagesAsync(Guid chatId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var filter = Builders<Message>.Filter.Eq(m => m.ChatId, chatId);
|
||||||
|
await _messages.DeleteManyAsync(filter, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteUserMessagesAsync(Guid userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var filter = Builders<Message>.Filter.Eq(m => m.SenderId, userId);
|
||||||
|
await _messages.DeleteManyAsync(filter, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var filter = Builders<Message>.Filter.Eq(m => m.Id, id);
|
||||||
|
await _messages.DeleteOneAsync(filter, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -2,7 +2,6 @@ using Knot.Modules.Messaging.Domain;
|
|||||||
using MongoDB.Bson;
|
using MongoDB.Bson;
|
||||||
using MongoDB.Bson.Serialization;
|
using MongoDB.Bson.Serialization;
|
||||||
using MongoDB.Bson.Serialization.Serializers;
|
using MongoDB.Bson.Serialization.Serializers;
|
||||||
using Knot.Modules.Messaging.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
|
namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
|
||||||
@@ -59,6 +58,17 @@ public static class MongoDbMapConfigurator
|
|||||||
BsonClassMap.RegisterClassMap<DeletedMessage>(cm => cm.AutoMap());
|
BsonClassMap.RegisterClassMap<DeletedMessage>(cm => cm.AutoMap());
|
||||||
BsonClassMap.RegisterClassMap<MessageReaction>(cm => cm.AutoMap());
|
BsonClassMap.RegisterClassMap<MessageReaction>(cm => cm.AutoMap());
|
||||||
|
|
||||||
|
BsonClassMap.RegisterClassMap<PollMessage>(cm =>
|
||||||
|
{
|
||||||
|
cm.AutoMap();
|
||||||
|
cm.MapField("_options").SetElementName("Options");
|
||||||
|
cm.MapField("_votes").SetElementName("Votes");
|
||||||
|
cm.SetDiscriminator("PollMessage");
|
||||||
|
});
|
||||||
|
|
||||||
|
BsonClassMap.RegisterClassMap<PollOption>(cm => cm.AutoMap());
|
||||||
|
BsonClassMap.RegisterClassMap<PollVote>(cm => cm.AutoMap());
|
||||||
|
|
||||||
|
|
||||||
BsonClassMap.RegisterClassMap<Media>(cm =>
|
BsonClassMap.RegisterClassMap<Media>(cm =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ public class UserReplica
|
|||||||
public string Username { get; set; }
|
public string Username { get; set; }
|
||||||
public string DisplayName { get; set; }
|
public string DisplayName { get; set; }
|
||||||
public string Avatar { get; set; }
|
public string Avatar { get; set; }
|
||||||
|
public bool IsExternal { get; set; }
|
||||||
|
public string? Domain { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,159 @@
|
|||||||
using Knot.Shared.Kernel.Configuration;
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Knot.Modules.Settings.Application.Config.DTOs;
|
namespace Knot.Modules.Settings.Application.Config.DTOs;
|
||||||
|
|
||||||
public record PublicConfigDto
|
public record PublicConfigDto
|
||||||
{
|
{
|
||||||
public bool EnableCalls { get; init; }
|
public SystemConfigDto System { get; init; } = new();
|
||||||
public bool EnableKlipy { get; init; }
|
public StoriesConfigDto Stories { get; init; } = new();
|
||||||
public int MaxFileSizeMb { get; init; }
|
public ChatsConfigDto Chats { get; init; } = new();
|
||||||
public int MaxGroupMembers { get; init; }
|
public MessagesConfigDto Messages { get; init; } = new();
|
||||||
public bool EnableConfederation { get; init; }
|
public WebRtcConfigDto WebRtc { get; init; } = new();
|
||||||
public bool EnableRegistration { get; init; }
|
public KlipyConfigDto Klipy { get; init; } = new();
|
||||||
|
public ImportConfigDto Import { get; init; } = new();
|
||||||
|
public FederationConfigDto Federation { get; init; } = new();
|
||||||
|
|
||||||
public static PublicConfigDto FromSettings(SystemSettingsDto settings)
|
public static PublicConfigDto FromSettings(SystemSettingsDto settings)
|
||||||
{
|
{
|
||||||
return new PublicConfigDto
|
return new PublicConfigDto
|
||||||
{
|
{
|
||||||
EnableCalls = settings.EnableCalls,
|
System = new SystemConfigDto
|
||||||
EnableKlipy = settings.EnableKlipy,
|
{
|
||||||
MaxFileSizeMb = settings.MaxFileSizeMb,
|
DomainUrl = settings.System.DomainUrl,
|
||||||
MaxGroupMembers = settings.MaxGroupMembers,
|
EnableRegistration = settings.System.EnableRegistration
|
||||||
EnableConfederation = settings.EnableConfederation,
|
},
|
||||||
EnableRegistration = settings.EnableRegistration
|
Stories = new StoriesConfigDto
|
||||||
|
{
|
||||||
|
Enabled = settings.Stories.Enabled,
|
||||||
|
MaxStoriesPerPeriod = settings.Stories.MaxStoriesPerPeriod,
|
||||||
|
StoryLifetimeHours = settings.Stories.StoryLifetimeHours,
|
||||||
|
TextStoriesEnabled = settings.Stories.TextStoriesEnabled,
|
||||||
|
TextStoryDurationSeconds = settings.Stories.TextStoryDurationSeconds,
|
||||||
|
MediaStoryMaxDurationSeconds = settings.Stories.MediaStoryMaxDurationSeconds,
|
||||||
|
MaxMediaSizeBytes = settings.Stories.MaxMediaSizeBytes
|
||||||
|
},
|
||||||
|
Chats = new ChatsConfigDto
|
||||||
|
{
|
||||||
|
SupportGroups = settings.Chats.SupportGroups,
|
||||||
|
MaxGroupParticipants = settings.Chats.MaxGroupParticipants,
|
||||||
|
AllowChatToGroupConversion = settings.Chats.AllowChatToGroupConversion,
|
||||||
|
EnableFolders = settings.Chats.EnableFolders
|
||||||
|
},
|
||||||
|
Messages = new MessagesConfigDto
|
||||||
|
{
|
||||||
|
DailyMessageLimitPerUser = settings.Messages.DailyMessageLimitPerUser,
|
||||||
|
ChatMessageLimit = settings.Messages.ChatMessageLimit,
|
||||||
|
AllowMedia = settings.Messages.AllowMedia,
|
||||||
|
MaxMediaSizeBytes = settings.Messages.MaxMediaSizeBytes,
|
||||||
|
AllowedMediaTypes = settings.Messages.AllowedMediaTypes,
|
||||||
|
AllowVoiceMessages = settings.Messages.AllowVoiceMessages,
|
||||||
|
AllowForwarding = settings.Messages.AllowForwarding,
|
||||||
|
AllowReactions = settings.Messages.AllowReactions,
|
||||||
|
AllowReplies = settings.Messages.AllowReplies,
|
||||||
|
AllowQuoting = settings.Messages.AllowQuoting,
|
||||||
|
AllowMessageDeletion = settings.Messages.AllowMessageDeletion,
|
||||||
|
ForbidCopying = settings.Messages.ForbidCopying,
|
||||||
|
AllowLinks = settings.Messages.AllowLinks,
|
||||||
|
AllowPolls = settings.Messages.AllowPolls,
|
||||||
|
AllowPinning = settings.Messages.AllowPinning
|
||||||
|
},
|
||||||
|
WebRtc = new WebRtcConfigDto
|
||||||
|
{
|
||||||
|
Enabled = settings.WebRtc.Enabled,
|
||||||
|
EnableVoiceCalls = settings.WebRtc.EnableVoiceCalls,
|
||||||
|
EnableVideoCalls = settings.WebRtc.EnableVideoCalls,
|
||||||
|
EnableScreenSharing = settings.WebRtc.EnableScreenSharing,
|
||||||
|
TurnHost = settings.WebRtc.TurnHost,
|
||||||
|
TurnPort = settings.WebRtc.TurnPort
|
||||||
|
},
|
||||||
|
Klipy = new KlipyConfigDto
|
||||||
|
{
|
||||||
|
Enabled = settings.Klipy.Enabled,
|
||||||
|
AppName = settings.Klipy.AppName
|
||||||
|
},
|
||||||
|
Import = new ImportConfigDto
|
||||||
|
{
|
||||||
|
EnableTelegramImport = settings.Import.EnableTelegramImport
|
||||||
|
},
|
||||||
|
Federation = new FederationConfigDto
|
||||||
|
{
|
||||||
|
Enabled = settings.Federation.Enabled,
|
||||||
|
ServerDescription = settings.Federation.ServerDescription,
|
||||||
|
AllowedDomains = settings.Federation.AllowedDomains
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SystemConfigDto
|
||||||
|
{
|
||||||
|
public string DomainUrl { get; init; } = string.Empty;
|
||||||
|
public bool EnableRegistration { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StoriesConfigDto
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
public int MaxStoriesPerPeriod { get; init; }
|
||||||
|
public int StoryLifetimeHours { get; init; }
|
||||||
|
public bool TextStoriesEnabled { get; init; }
|
||||||
|
public int TextStoryDurationSeconds { get; init; }
|
||||||
|
public int MediaStoryMaxDurationSeconds { get; init; }
|
||||||
|
public int MaxMediaSizeBytes { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ChatsConfigDto
|
||||||
|
{
|
||||||
|
public bool SupportGroups { get; init; }
|
||||||
|
public int MaxGroupParticipants { get; init; }
|
||||||
|
public bool AllowChatToGroupConversion { get; init; }
|
||||||
|
public bool EnableFolders { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MessagesConfigDto
|
||||||
|
{
|
||||||
|
public int DailyMessageLimitPerUser { get; init; }
|
||||||
|
public int ChatMessageLimit { get; init; }
|
||||||
|
public bool AllowMedia { get; init; }
|
||||||
|
public int MaxMediaSizeBytes { get; init; }
|
||||||
|
public List<string> AllowedMediaTypes { get; init; } = new();
|
||||||
|
public bool AllowVoiceMessages { get; init; }
|
||||||
|
public bool AllowForwarding { get; init; }
|
||||||
|
public bool AllowReactions { get; init; }
|
||||||
|
public bool AllowReplies { get; init; }
|
||||||
|
public bool AllowQuoting { get; init; }
|
||||||
|
public bool AllowMessageDeletion { get; init; }
|
||||||
|
public bool ForbidCopying { get; init; }
|
||||||
|
public bool AllowLinks { get; init; }
|
||||||
|
public bool AllowPolls { get; init; }
|
||||||
|
public bool AllowPinning { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WebRtcConfigDto
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
public bool EnableVoiceCalls { get; init; }
|
||||||
|
public bool EnableVideoCalls { get; init; }
|
||||||
|
public bool EnableScreenSharing { get; init; }
|
||||||
|
public string TurnHost { get; init; } = string.Empty;
|
||||||
|
public int TurnPort { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record KlipyConfigDto
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
public string AppName { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ImportConfigDto
|
||||||
|
{
|
||||||
|
public bool EnableTelegramImport { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record FederationConfigDto
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
public string ServerDescription { get; init; } = string.Empty;
|
||||||
|
public List<FederationDomainConfig> AllowedDomains { get; init; } = new();
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,10 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
using System.Text;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace Knot.Host.Presentation.Endpoints;
|
namespace Knot.Host.Presentation.Endpoints;
|
||||||
|
|
||||||
public sealed class FilesEndpoints : ICarterModule
|
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) =>
|
group.MapGet("{id}", async (string id, [FromQuery] bool download, IFileStorageService fileStorage, IMemoryCache cache, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
|
// (Текущая логика локальных файлов остается без изменений)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var cacheKey = $"file_{id}";
|
var cacheKey = $"file_{id}";
|
||||||
@@ -49,5 +54,34 @@ public sealed class FilesEndpoints : ICarterModule
|
|||||||
return Results.NotFound(new { error = "Файл не найден или ошибка доступа.", message = ex.Message });
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-4
@@ -33,6 +33,16 @@ internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQ
|
|||||||
|
|
||||||
public async Task<Result<StoryGroupDto>> Handle(GetUserStoriesQuery request, CancellationToken cancellationToken)
|
public async Task<Result<StoryGroupDto>> Handle(GetUserStoriesQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var user = await _userRepository.GetByIdAsync(request.TargetUserId, cancellationToken);
|
||||||
|
if (user == null)
|
||||||
|
return Result.Failure<StoryGroupDto>(Knot.Modules.Stories.Domain.IdentityErrors.UserNotFound);
|
||||||
|
|
||||||
|
// Если пользователь внешний — идем в федерацию
|
||||||
|
if (user.IsExternal && !string.IsNullOrEmpty(user.Domain))
|
||||||
|
{
|
||||||
|
return await GetFederatedStories(user, request.CurrentUserId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
var stories = await _storyRepository.GetActiveUserStoriesAsync(request.TargetUserId, cancellationToken);
|
var stories = await _storyRepository.GetActiveUserStoriesAsync(request.TargetUserId, cancellationToken);
|
||||||
var storyIds = stories.Select(s => s.Id).ToList();
|
var storyIds = stories.Select(s => s.Id).ToList();
|
||||||
|
|
||||||
@@ -41,10 +51,6 @@ internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQ
|
|||||||
.Select(v => v.StoryId)
|
.Select(v => v.StoryId)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var user = await _userRepository.GetByIdAsync(request.TargetUserId, cancellationToken);
|
|
||||||
if (user == null)
|
|
||||||
return Result.Failure<StoryGroupDto>(Knot.Modules.Stories.Domain.IdentityErrors.UserNotFound);
|
|
||||||
|
|
||||||
var result = new StoryGroupDto(
|
var result = new StoryGroupDto(
|
||||||
new StoryUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
|
new StoryUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
|
||||||
stories.Select(s => new StoryDto(
|
stories.Select(s => new StoryDto(
|
||||||
@@ -62,5 +68,12 @@ internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQ
|
|||||||
|
|
||||||
return Result.Success(result);
|
return Result.Success(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<Result<StoryGroupDto>> GetFederatedStories(Knot.Modules.Relations.Domain.UserReplica user, Guid currentUserId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// В будущем: Реальный вызов через HttpClient к удаленному домену
|
||||||
|
// return await _federationGateway.FetchStoriesAsync(user.Domain, user.Id);
|
||||||
|
return Result.Failure<StoryGroupDto>(new Error("Federation.StoriesNotImplemented", "Stories from this domain are temporarily unavailable."));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ public record GetIceServersQuery : IQuery<IceServersResultDto>;
|
|||||||
internal sealed class GetIceServersQueryHandler : IQueryHandler<GetIceServersQuery, IceServersResultDto>
|
internal sealed class GetIceServersQueryHandler : IQueryHandler<GetIceServersQuery, IceServersResultDto>
|
||||||
{
|
{
|
||||||
private readonly IConfiguration _configuration;
|
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;
|
_configuration = configuration;
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
@@ -27,7 +27,7 @@ internal sealed class GetIceServersQueryHandler : IQueryHandler<GetIceServersQue
|
|||||||
public Task<Result<IceServersResultDto>> Handle(GetIceServersQuery request, CancellationToken cancellationToken)
|
public Task<Result<IceServersResultDto>> Handle(GetIceServersQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var settings = _settingsService.Current;
|
var settings = _settingsService.Current;
|
||||||
if (!settings.EnableCalls)
|
if (!settings.Enabled)
|
||||||
{
|
{
|
||||||
return Task.FromResult(Result.Failure<IceServersResultDto>(new Error(
|
return Task.FromResult(Result.Failure<IceServersResultDto>(new Error(
|
||||||
Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin,
|
Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin,
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
|
|
||||||
namespace Knot.Shared.Infrastructure.Configuration;
|
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 IServiceProvider _serviceProvider;
|
||||||
private readonly IEncryptionService _encryptionService;
|
private readonly IEncryptionService _encryptionService;
|
||||||
@@ -23,6 +31,15 @@ public class SettingsService : ISettingsService
|
|||||||
|
|
||||||
public SystemSettingsDto Current => _current;
|
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<SystemSettingsDto> GetSettingsAsync(CancellationToken cancellationToken = default)
|
public async Task<SystemSettingsDto> GetSettingsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
|||||||
@@ -37,7 +37,18 @@ public static class DependencyInjection
|
|||||||
options.UseNpgsql(connectionString));
|
options.UseNpgsql(connectionString));
|
||||||
|
|
||||||
services.AddMemoryCache();
|
services.AddMemoryCache();
|
||||||
services.AddSingleton<ISettingsService, SettingsService>();
|
|
||||||
|
services.AddSingleton<SettingsService>();
|
||||||
|
services.AddSingleton<ISettingsService>(sp => sp.GetRequiredService<SettingsService>());
|
||||||
|
services.AddSingleton<ISystemSettings>(sp => sp.GetRequiredService<SettingsService>());
|
||||||
|
services.AddSingleton<IStoriesSettings>(sp => sp.GetRequiredService<SettingsService>());
|
||||||
|
services.AddSingleton<IChatsSettings>(sp => sp.GetRequiredService<SettingsService>());
|
||||||
|
services.AddSingleton<IMessagesSettings>(sp => sp.GetRequiredService<SettingsService>());
|
||||||
|
services.AddSingleton<IWebRtcSettings>(sp => sp.GetRequiredService<SettingsService>());
|
||||||
|
services.AddSingleton<IKlipySettings>(sp => sp.GetRequiredService<SettingsService>());
|
||||||
|
services.AddSingleton<IImportSettings>(sp => sp.GetRequiredService<SettingsService>());
|
||||||
|
services.AddSingleton<IFederationSettings>(sp => sp.GetRequiredService<SettingsService>());
|
||||||
|
|
||||||
services.AddScoped<Knot.Shared.Kernel.Services.IStatisticsService, StatisticsService>();
|
services.AddScoped<Knot.Shared.Kernel.Services.IStatisticsService, StatisticsService>();
|
||||||
|
|
||||||
services.AddHostedService<StatisticsWorker>();
|
services.AddHostedService<StatisticsWorker>();
|
||||||
|
|||||||
@@ -9,3 +9,43 @@ public interface ISettingsService
|
|||||||
Task UpdateSettingsAsync(SystemSettingsDto settings, CancellationToken cancellationToken = default);
|
Task UpdateSettingsAsync(SystemSettingsDto settings, CancellationToken cancellationToken = default);
|
||||||
SystemSettingsDto Current { get; }
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,118 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Knot.Shared.Kernel.Configuration;
|
namespace Knot.Shared.Kernel.Configuration;
|
||||||
|
|
||||||
public class SystemSettingsDto
|
public class SystemConfig
|
||||||
{
|
{
|
||||||
// Storage Limits
|
public string ServerTimezone { get; set; } = "UTC";
|
||||||
public double MaxStorageQuotaTb { get; set; } = 5.0;
|
public string DomainUrl { get; set; } = "https://example.com";
|
||||||
public int MaxGroupMembers { get; set; } = 500;
|
public string AdminRoute { get; set; } = "admin";
|
||||||
public int MaxFileSizeMb { get; set; } = 100;
|
public bool EnableRegistration { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Calls
|
public class StoriesConfig
|
||||||
public bool EnableCalls { get; set; } = false;
|
{
|
||||||
|
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<string> 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 string TurnHost { get; set; } = string.Empty;
|
||||||
public int TurnPort { get; set; } = 3478;
|
public int TurnPort { get; set; } = 3478;
|
||||||
public string TurnUser { get; set; } = string.Empty;
|
public string TurnUser { get; set; } = string.Empty;
|
||||||
public string TurnSecret { get; set; } = string.Empty;
|
public string TurnSecret { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
// Klipy
|
|
||||||
public bool EnableKlipy { get; set; } = false;
|
public class KlipyConfig
|
||||||
public string KlipyApiKey { get; set; } = string.Empty;
|
{
|
||||||
public string KlipyCustomerId { get; set; } = string.Empty;
|
public bool Enabled { get; set; } = false;
|
||||||
|
public string ApiKey { get; set; } = string.Empty;
|
||||||
// Confederation
|
public string AppName { get; set; } = string.Empty;
|
||||||
public bool EnableConfederation { get; set; } = false;
|
}
|
||||||
public List<string> AllowedDomains { get; set; } = new();
|
|
||||||
|
public class ImportConfig
|
||||||
// Auth
|
{
|
||||||
public bool EnableRegistration { get; set; } = true;
|
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<FederationDomainConfig> 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace Knot.Shared.Kernel;
|
|||||||
public sealed record Error(string Code, string Description)
|
public sealed record Error(string Code, string Description)
|
||||||
{
|
{
|
||||||
public static readonly Error None = new(string.Empty, string.Empty);
|
public static readonly Error None = new(string.Empty, string.Empty);
|
||||||
|
public static Error NotFound(string code, string description) => new(code, description);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user