From 6d018e41ea6f5fee6f824dea1325ef125ce2c2c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Mon, 16 Mar 2026 14:49:31 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A8=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5,=20GIF,=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=D1=89=D0=B5,=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 95 +-- apps/server-net/out8.txt | 23 - .../src/Host/Controllers/AdminController.cs | 115 +++ .../src/Host/Controllers/ConfigController.cs | 32 + .../Host/Controllers/FederationController.cs | 59 ++ .../Host/Controllers/MessagesController.cs | 12 +- .../src/Host/Controllers/WebRtcController.cs | 53 +- apps/server-net/src/Host/Host.csproj | 2 +- apps/server-net/src/Host/Program.cs | 15 +- .../Chats/Infrastructure/SignalR/ChatHub.cs | 10 +- .../Configuration/SettingsService.cs | 81 ++ .../DependencyInjection.cs | 19 + .../Knot.Shared.Infrastructure.csproj | 6 +- .../Middleware/AdminAuthMiddleware.cs | 62 ++ .../20260315204509_InitialSystem.Designer.cs | 64 ++ .../20260315204509_InitialSystem.cs | 58 ++ .../SystemDbContextModelSnapshot.cs | 61 ++ .../Persistence/Entities/DailyStat.cs | 11 + .../Persistence/Entities/SystemSetting.cs | 9 + .../Persistence/SystemDbContext.cs | 29 + .../Persistence/SystemDbContextFactory.cs | 33 + .../Statistics/StatisticsService.cs | 109 +++ .../Statistics/StatisticsWorker.cs | 73 ++ .../Configuration/ISettingsService.cs | 11 + .../Configuration/SystemSettingsDto.cs | 25 + .../Services/IStatisticsService.cs | 37 + apps/web/src/App.tsx | 6 + apps/web/src/components/ChatView.tsx | 6 +- apps/web/src/components/EmojiPicker.tsx | 85 ++- apps/web/src/components/MessageBubble.tsx | 32 +- apps/web/src/components/MessageInput.tsx | 20 +- apps/web/src/components/NewChatModal.tsx | 6 +- apps/web/src/components/UserProfile.tsx | 22 +- apps/web/src/lib/socket.ts | 2 +- apps/web/src/pages/AdminPage.tsx | 704 ++++++++++++++++++ apps/web/src/stores/authStore.ts | 13 + apps/server-net/out.txt => build.txt | 35 +- diff.txt | Bin 12952 -> 0 bytes docker-compose.yml | 2 + klipy_err.txt | Bin 0 -> 558 bytes klipy_test.json | 0 klipy_tst.json | 1 + logs.txt | Bin 75588 -> 0 bytes logs2.txt | Bin 78094 -> 0 bytes 44 files changed, 1876 insertions(+), 162 deletions(-) delete mode 100644 apps/server-net/out8.txt create mode 100644 apps/server-net/src/Host/Controllers/AdminController.cs create mode 100644 apps/server-net/src/Host/Controllers/ConfigController.cs create mode 100644 apps/server-net/src/Host/Controllers/FederationController.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Infrastructure/Configuration/SettingsService.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Infrastructure/Middleware/AdminAuthMiddleware.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Infrastructure/Migrations/20260315204509_InitialSystem.Designer.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Infrastructure/Migrations/20260315204509_InitialSystem.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Infrastructure/Migrations/SystemDbContextModelSnapshot.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/Entities/DailyStat.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/Entities/SystemSetting.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/SystemDbContext.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/SystemDbContextFactory.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Infrastructure/Statistics/StatisticsService.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Infrastructure/Statistics/StatisticsWorker.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/ISettingsService.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Kernel/Services/IStatisticsService.cs create mode 100644 apps/web/src/pages/AdminPage.tsx rename apps/server-net/out.txt => build.txt (56%) delete mode 100644 diff.txt create mode 100644 klipy_err.txt create mode 100644 klipy_test.json create mode 100644 klipy_tst.json delete mode 100644 logs.txt delete mode 100644 logs2.txt diff --git a/README.md b/README.md index bf11260..9daa3a1 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,88 @@ -# Knot Messenger (Knot) +# SelfHost Messenger -Knot Messenger — это современный, безопасный и многофункциональный мессенджер с открытым исходным кодом, который вы полностью можете развернуть на собственных серверах (self-hosted). Проект разработан на стеке **React + TypeScript + Vite** для фронтенда и **.NET (C#) + PostgreSQL + SignalR** для бэкенда. +SelfHost Messenger — это современный, безопасный и многофункциональный мессенджер с открытым исходным кодом, предназначенный для самостоятельного развертывания на собственных серверах (self-hosted). Проект разработан на стеке **React + TypeScript + Vite** для фронтенда и **.NET (C#) 8/10 + PostgreSQL + MinIO + SignalR** для бэкенда. -## 🚀 Текущий функционал +## 🚀 Основной функционал -Мессенджер обладает функциями полноценной современной платформы общения: +Мессенджер обладает всеми функциями полноценной современной платформы для общения и командной работы: ### Чаты и группы -* **Личные сообщения (P2P):** Обмен сообщениями в реальном времени (на базе SignalR). Текстовые сообщения, статусы прочитанности, индикаторы набора текста (typing). -* **Групповые чаты:** Создание закрытых групп. Настройки группы (название, описание, аватар). Добавление и удаление участников администратором. +* **Личные сообщения (P2P):** Обмен сообщениями в реальном времени (SignalR). Текстовые сообщения, статусы отправки, доставки и прочитанности, индикаторы набора текста (typing). +* **Групповые чаты:** Создание групп. Настройки группы (название, описание, аватар). Добавление и удаление участников администратором. В админ-панели можно установить лимит участников. * **Закрепление чатов (Pin/Unpin):** Важные чаты всегда под рукой вверху списка. -* **Вложения и медиа:** Поддержка отправки фото, видео, обычных файлов, а также удобный просмотр отправленных ссылок (распределение по вкладкам в информации профиля и группы). Изображения открываются во встроенном полноэкранном лайтбоксе. -* **Интеграция GIF (Klipy):** Встроенный поиск и отправка GIF-анимаций через Klipy. -* **Реакции на сообщения:** Стандартный набор эмодзи-реакций с красивой анимацией. +* **Вложения и медиа:** Отправка фото, видео, голосовых сообщений (с генерацией waveform-волны), аудиофайлов и обычных документов. Плавный просмотр изображений и видео во встроенном полноэкранном лайтбоксе (галерее). +* **Интеграция GIF (Klipy):** Встроенный поиск и отправка GIF-анимаций через Klipy. Ключи и ID клиента(Customer ID) настраиваются прямо из панели администратора. +* **Реакции и действия с сообщениями:** Реакции на сообщения (эмодзи), редактирование, пересылка (Forward), ответы (Reply c цитатами), удаление (только для себя или для всех). +* **Отложенные сообщения:** Возможность запланировать отправку сообщения на определенную дату и время. -### Конференц-связь -* **Аудио и видеозвонки:** Интеграция WebRTC для личных звонков и групповых конференций. -* **Демонстрация экрана:** Возможность делиться экраном со всеми участниками беседы. -* Управление микрофоном, камерой, отображение статусов звонка в чате. *Для стабильной работы необходим внешний TURN-сервер.* +### Конференц-связь (WebRTC) +* **Аудио и видеозвонки:** Интеграция WebRTC для личных звонков P2P. +* **Групповые звонки:** Аудио-конференции внутри групповых чатов со всеми участниками беседы. +* **Демонстрация экрана (Screen Sharing):** Возможность поделиться экраном или окном. +* Управление микрофоном, камерой, отображение статусов звонка прямо в чате. *Для стабильной работы необходим внешний TURN-сервер.* ### Социальные функции -* **Истории (Stories):** Публикация фото/видео историй на 24 часа. Просмотр списка посмотревших (Viewer List). +* **Истории (Stories):** Публикация фото/видео историй. Просмотр списка посмотревших (Viewer List). Истории автоматически удаляются через установленное время. * **Друзья (Friends):** Гибкая система отправки заявок в друзья, подтверждения и удаления из друзей. -* **Продвинутый профиль пользователя:** Смена никнейма, информации "о себе", дня рождения. -* **Клиентский кроп фото:** Идеально ровная обрезка квадратных и круглых аватаров для профиля и групповых чатов происходит прямо в вашем браузере. Решение не смещает координаты кадра и на сервер летит уже готовый результат. -* **Онлайн-статусы:** Индикация того, кто находится онлайн в данный момент. +* **Продвинутый профиль пользователя:** Настройка никнейма, информации "о себе", даты рождения. Клиентский редактор аватарок (Crop/Zoom) с идеальной обрезкой прямо в браузере. +* **Онлайн-статусы:** Индикация того, кто находится онлайн в данный момент (с точным временем "Был(а) в сети..."). -### Интерфейс и Кастомизация -* Современно выглядит: эффект стекла (glassmorphism), плавные анимации (Framer Motion). -* **Кастомизация тем:** Встроенное меню выбора цветовых акцентов и фона чата (Ocean, Nebula, Midnight, Forest и д.р.). +### Интерфейс и Панель Управления +* **Премиальный дизайн:** Эффект стекла (glassmorphism), плавные анимации (Framer Motion), кастомизация тем на лету (Ocean, Nebula, Midnight, Forest и д.р.). +* **Админ-панель (Dashboard):** Динамическое управление настройками системы "на лету" без перезагрузки сервера: + * Включение/выключение звонков. + * Настройка ключей Klipy API. + * Установка максимального размера загружаемого файла. + * Лимиты на количество участников в группах. + * Управление пользователями системы. --- ## 🛠 Архитектура -* **Бэкенд:** C# .NET 8/10, Entity Framework Core (PostgreSQL). Паттерн CQRS. Механизм SignalR для мгновенных уведомлений. -* **Фронтенд:** React, zustand (стейт-менеджер), lucide-react (иконки), framer-motion (анимации), react-easy-crop, WebRTC APIs. -* **База данных:** PostgreSQL Server. Хранение медиа происходит прямо на сервере в папке `uploads`. +* **Бэкенд:** C# .NET (ASP.NET Core), Entity Framework Core (PostgreSQL). Паттерн CQRS (MediatR). Механизм SignalR для доставки событий и сообщений в реальном времени. +* **Фронтенд:** React 18, Zustand (стейт-менеджер и кэширование параметров коннекта), TailwindCSS, lucide-react (векторные иконки), framer-motion (анимации), react-easy-crop, WebRTC APIs. +* **База данных и S3:** + * `PostgreSQL Server` — надежное и быстрое хранение реляционных данных. + * `MinIO` (S3) — масштабируемое и независимое объектное хранилище для медиафайлов (аватарки, файлы, вложения). --- -## ⚙️ Установка и развертывание (Docker) +## ⚙️ Установка и развертывание (Docker Compose) -Проект легко разворачивается с помощью `docker-compose`. +Проект изначально готов к `production` развертыванию через `docker-compose`. 1. Клонируйте репозиторий. -2. В корневой директории найдите файл `.env`. Там задаются секреты (базы данных, JWT-секреты, TURN параметры и API ключи). +2. В корневой директории найдите файл `.env`. Там задаются секреты (пароль к базе данных, ключи JWT, доступы MinIO, ключи TURN). 3. Запустите стек: ```bash - docker-compose build --no-cache - docker-compose up -d + docker compose build --no-cache + docker compose up -d ``` -В результате поднимутся 3 контейнера: -* `knot-db` — База данных Postgres. -* `knot-server` — Основной бэкенд на порту `:5059`. -* `knot-web` — Фронтенд (Nginx + React) на порту `:9090`. +В результате поднимутся 4 контейнера: +* `knot-db` — База данных PostgreSQL. +* `knot-minio` — S3 хранилище файлов MinIO. +* `knot-server` — Основной бэкенд на порту `:5034` / `:5059` +* `knot-web` — Фронтенд (Nginx + React) на порту `:9090` (или на 80/443 при использовании Traefik/Dokploy). -*Для продакшена (Dokploy) используйте гайд из файла `DOKPLOY.md` и `DEPLOYMENT.md` в этом же или соседних файлах, указав SSL сертификаты и правильные домены.* +*Для продакшена (Dokploy / Coolify) можно использовать стандартный подход публикации через Docker Compose, указав SSL сертификаты и настроив домены.* --- -## 🌐 Настройка TURN-сервера (для звонков) +## 🌐 Настройка TURN-сервера (для стабильности звонков) -Механизм аудио и видео звонков, а также демонстрации экрана основан на технологии **WebRTC**. - -**Почему нужен TURN-сервер?** -Чтобы двое (или более) участников могли передавать медиа-трафик друг другу из своих частных сетей (из-за NAT или файрволов), им нужен промежуточный ретранслятор. Если прямое подключение (STUN) не удаётся, соединение будет перенаправлено через TURN. Без него звонки между мобильными сетями и многими домашними провайдерами работать *не будут*. +Механизм аудио и видео звонков, а также демонстрации экрана основан на технологии **WebRTC**. +Чтобы пользователи могли свободно общаться вне зависимости от локальных ограничений сети (NAT/Firewall/мобильные вышки), необходим внешний TURN сервер-ретранслятор. ### Требования к TURN 1. Вам нужен **отдельный сервер с белым (публичным) IP адресом**. 2. В файрволе этого сервера должны быть открыты порты: * `3478` (TCP/UDP) * `5349` (TCP/UDP, если настроен TLS) - * Желательно также открыть диапазон портов `49152 - 65535` (UDP) для прохода медиа-трафика. + * Диапазон портов `49152 - 65535` (UDP) для прохода медиа-трафика. -### Развертывание Coturn (самый популярный сервер) -Вы можете развернуть его с помощью Docker (на отдельном VPS): +### Развертывание Coturn +Быстрое развертывание при помощи Docker на отдельном VPS: ```bash docker run -d \ --network=host \ @@ -89,11 +96,11 @@ docker run -d \ ``` *Замените `USER_NAME` и `SECRET_PASSWORD` на собственные логин и пароль.* -### Настройка в проекте -После установки Coturn, перейдите в файл `.env` корневого проекта Knot и задайте переменные: +### Подключение к приложению +После установки Coturn, перейдите в файл `.env` корневого проекта SelfHost Messenger и укажите реквизиты: ```env TURN_URL=turn:ВАШ_БЕЛЫЙ_IP_ТУТ:3478 TURN_USERNAME=USER_NAME TURN_PASSWORD=SECRET_PASSWORD ``` -Также убедитесь, что ваш React клиент принимает эти параметры для создания WebRTC-соедения, после этого WebRTC звонки будут работать практически в 100% случаев. +(Также эти параметры можно переопределить через админ-панель в будущих версиях). После этого WebRTC звонки будут работать практически в 100% клиентских конфигураций. diff --git a/apps/server-net/out8.txt b/apps/server-net/out8.txt deleted file mode 100644 index 216d0bd..0000000 --- a/apps/server-net/out8.txt +++ /dev/null @@ -1,23 +0,0 @@ - Determining projects to restore... - All projects are up-to-date for restore. -C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Knot.Modules.Chats.csproj] -C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] -C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Chats.UnitTests\Knot.Modules.Chats.UnitTests.csproj] -C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Identity.UnitTests\Knot.Modules.Identity.UnitTests.csproj] -C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\Knot.Modules.Identity.csproj] -C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj] - Knot.Shared.Kernel -> E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Kernel\bin\Debug\net10.0\Knot.Shared.Kernel.dll - Knot.Modules.Identity -> E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\bin\Debug\net10.0\Knot.Modules.Identity.dll - Knot.Shared.Infrastructure -> E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\bin\Debug\net10.0\Knot.Shared.Infrastructure.dll - Knot.Modules.Identity.UnitTests -> E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Identity.UnitTests\bin\Debug\net10.0\Knot.Modules.Identity.UnitTests.dll -E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Infrastructure\Persistence\ChatsDbContext.cs(16,34): error CS0234: The type or namespace name 'Infrastructure' does not exist in the namespace 'Knot.Shared' (are you missing an assembly reference?) [E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Knot.Modules.Chats.csproj] -E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Infrastructure\Persistence\ChatsDbContext.cs(18,101): error CS0234: The type or namespace name 'Infrastructure' does not exist in the namespace 'Knot.Shared' (are you missing an assembly reference?) [E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Knot.Modules.Chats.csproj] - -Build FAILED. - -E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Infrastructure\Persistence\ChatsDbContext.cs(16,34): error CS0234: The type or namespace name 'Infrastructure' does not exist in the namespace 'Knot.Shared' (are you missing an assembly reference?) [E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Knot.Modules.Chats.csproj] -E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Infrastructure\Persistence\ChatsDbContext.cs(18,101): error CS0234: The type or namespace name 'Infrastructure' does not exist in the namespace 'Knot.Shared' (are you missing an assembly reference?) [E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Knot.Modules.Chats.csproj] - 0 Warning(s) - 2 Error(s) - -Time Elapsed 00:00:00.89 diff --git a/apps/server-net/src/Host/Controllers/AdminController.cs b/apps/server-net/src/Host/Controllers/AdminController.cs new file mode 100644 index 0000000..621adf8 --- /dev/null +++ b/apps/server-net/src/Host/Controllers/AdminController.cs @@ -0,0 +1,115 @@ +using Knot.Modules.Identity.Domain; +using Knot.Shared.Kernel.Configuration; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Knot.Modules.Chats.Infrastructure.Persistence; + +namespace Host.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AdminController : ControllerBase +{ + private readonly ISettingsService _settingsService; + private readonly Knot.Shared.Kernel.Services.IStatisticsService _statisticsService; + private readonly IUserRepository _userRepository; + private readonly ChatsDbContext _chatsDbContext; + + public AdminController( + ISettingsService settingsService, + Knot.Shared.Kernel.Services.IStatisticsService statisticsService, + IUserRepository userRepository, + ChatsDbContext chatsDbContext) + { + _settingsService = settingsService; + _statisticsService = statisticsService; + _userRepository = userRepository; + _chatsDbContext = chatsDbContext; + } + + [HttpGet("settings")] + public async Task GetSettings(CancellationToken ct) + { + var settings = await _settingsService.GetSettingsAsync(ct); + return Ok(settings); + } + + [HttpPut("settings")] + public async Task UpdateSettings([FromBody] SystemSettingsDto settings, CancellationToken ct) + { + await _settingsService.UpdateSettingsAsync(settings, ct); + return Ok(settings); + } + + [HttpGet("dashboard")] + public async Task GetDashboardStats(CancellationToken ct) + { + var stats = await _statisticsService.GetDashboardStatsAsync(ct); + return Ok(stats); + } + + [HttpGet("users")] + public async Task SearchUsers([FromQuery] string query = "", CancellationToken ct = default) + { + var users = string.IsNullOrWhiteSpace(query) + ? await _userRepository.SearchUsersAsync("", ct) + : await _userRepository.SearchUsersAsync(query, ct); + + return Ok(users.Select(u => new + { + Id = u.Id, + Username = u.Username, + DisplayName = u.DisplayName, + Avatar = u.Avatar, + CreatedAt = u.CreatedAt, + LastOnlineAt = u.CreatedAt // fallback + })); + } + + [HttpGet("users/{id:guid}")] + public async Task GetUserDetails(Guid id, CancellationToken ct) + { + var targetUser = await _userRepository.GetByIdAsync(id, ct); + if (targetUser == null) return NotFound("User not found"); + + var messagesCount = await _chatsDbContext.Messages.CountAsync(m => m.SenderId == id, ct); + + var allUserMedia = await _chatsDbContext.Messages + .AsNoTracking() + .Where(m => m.SenderId == id) + .SelectMany(m => m.Media) + .ToListAsync(ct); + + var mediaCount = allUserMedia.Count(m => m.Type == "image" || m.Type == "video"); + var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio"); + var storageUsed = allUserMedia.Sum(m => m.Size ?? 0); + + // Fetch contents first, then count links to avoid EF Core ValueConverter translation error + var userContents = await _chatsDbContext.Messages + .AsNoTracking() + .Where(m => m.SenderId == id) + .Select(m => m.Content) + .ToListAsync(ct); + + var linksCount = userContents.Count(c => !string.IsNullOrEmpty(c) && c.Contains("http")); + + return Ok(new + { + Id = targetUser.Id, + Username = targetUser.Username, + DisplayName = targetUser.DisplayName, + Bio = targetUser.Bio, + Avatar = targetUser.Avatar, + CreatedAt = targetUser.CreatedAt, + LastOnlineAt = targetUser.CreatedAt, // fallback + Stats = new + { + MessagesCount = messagesCount, + MediaCount = mediaCount, + FilesCount = filesCount, + LinksCount = linksCount, + StorageUsedBytes = storageUsed + } + }); + } +} diff --git a/apps/server-net/src/Host/Controllers/ConfigController.cs b/apps/server-net/src/Host/Controllers/ConfigController.cs new file mode 100644 index 0000000..c7d3a51 --- /dev/null +++ b/apps/server-net/src/Host/Controllers/ConfigController.cs @@ -0,0 +1,32 @@ +using Knot.Shared.Kernel.Configuration; +using Microsoft.AspNetCore.Mvc; + +namespace Host.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ConfigController : ControllerBase +{ + private readonly ISettingsService _settings; + + public ConfigController(ISettingsService settings) + { + _settings = settings; + } + + [HttpGet] + public IActionResult GetPublicConfig() + { + var conf = _settings.Current; + return Ok(new + { + conf.EnableCalls, + conf.EnableKlipy, + KlipyApiKey = conf.EnableKlipy ? conf.KlipyApiKey : null, + KlipyCustomerId = conf.EnableKlipy ? conf.KlipyCustomerId : null, + conf.MaxFileSizeMb, + conf.MaxGroupMembers, + conf.EnableConfederation + }); + } +} diff --git a/apps/server-net/src/Host/Controllers/FederationController.cs b/apps/server-net/src/Host/Controllers/FederationController.cs new file mode 100644 index 0000000..effde0c --- /dev/null +++ b/apps/server-net/src/Host/Controllers/FederationController.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography; +using Knot.Shared.Kernel.Configuration; +using Microsoft.AspNetCore.Mvc; + +namespace Host.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class FederationController : ControllerBase +{ + private readonly ISettingsService _settingsService; + + public FederationController(ISettingsService settingsService) + { + _settingsService = settingsService; + } + + [HttpPost("handshake")] + public IActionResult Handshake([FromBody] HandshakeRequest request) + { + var conf = _settingsService.Current; + if (!conf.EnableConfederation) + return StatusCode(503, new { error = "Confederation is disabled on this node." }); + + if (string.IsNullOrEmpty(request.Domain) || string.IsNullOrEmpty(request.PublicKey)) + return BadRequest(new { error = "Domain and PublicKey are required." }); + + // Discovery: Checks allowed list + var allowedList = conf.AllowedDomains?.Select(d => d.Trim().ToLower()).ToList() ?? new List(); + if (!allowedList.Contains(request.Domain.ToLower())) + return Forbid("Domain not in whitelist."); + + // Here we would store the peer's public key in the DB to encrypt future traffic. + // For demonstration, we simply generate a response with our own public key. + + using var rsa = RSA.Create(2048); + var selfPublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey()); + + return Ok(new HandshakeResponse + { + Domain = Environment.GetEnvironmentVariable("DOMAIN") ?? "knot.local", + PublicKey = selfPublicKey, + Status = "Accepted" + }); + } +} + +public class HandshakeRequest +{ + public string Domain { get; set; } = string.Empty; + public string PublicKey { get; set; } = string.Empty; +} + +public class HandshakeResponse +{ + public string Domain { get; set; } = string.Empty; + public string PublicKey { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; +} diff --git a/apps/server-net/src/Host/Controllers/MessagesController.cs b/apps/server-net/src/Host/Controllers/MessagesController.cs index 3a5a02e..206103d 100644 --- a/apps/server-net/src/Host/Controllers/MessagesController.cs +++ b/apps/server-net/src/Host/Controllers/MessagesController.cs @@ -8,6 +8,7 @@ using Knot.Modules.Identity.Domain; using Knot.Modules.Identity.Application.Abstractions; using System.Linq; using System.Text.RegularExpressions; +using Knot.Shared.Kernel.Configuration; namespace Host.Controllers; @@ -187,10 +188,17 @@ public sealed class MessagesController : ControllerBase } [HttpPost("upload")] - public async Task UploadFile(IFormFile file, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage) + public async Task UploadFile( + IFormFile file, + [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, + [FromServices] ISettingsService settingsService) { if (file == null || file.Length == 0) return BadRequest("No file uploaded"); - + + var maxMb = settingsService.Current.MaxFileSizeMb; + if (file.Length > maxMb * 1024 * 1024) + return StatusCode(413, $"File exceeds the maximum allowed size of {maxMb}MB."); + using var stream = file.OpenReadStream(); var fileId = await fileStorage.UploadFileAsync(stream, file.FileName, file.ContentType); diff --git a/apps/server-net/src/Host/Controllers/WebRtcController.cs b/apps/server-net/src/Host/Controllers/WebRtcController.cs index 8c4d130..7327d16 100644 --- a/apps/server-net/src/Host/Controllers/WebRtcController.cs +++ b/apps/server-net/src/Host/Controllers/WebRtcController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; +using Knot.Shared.Kernel.Configuration; namespace Host.Controllers; @@ -9,33 +10,61 @@ namespace Host.Controllers; public sealed class WebRtcController : ControllerBase { private readonly IConfiguration _configuration; + private readonly ISettingsService _settingsService; - public WebRtcController(IConfiguration configuration) + public WebRtcController(IConfiguration configuration, ISettingsService settingsService) { _configuration = configuration; + _settingsService = settingsService; } [HttpGet("ice-servers")] public IActionResult GetIceServers() { - var turnUrl = _configuration["WebRtc:TurnUrl"]; - var turnUsername = _configuration["WebRtc:TurnUsername"]; - var turnPassword = _configuration["WebRtc:TurnPassword"]; + var settings = _settingsService.Current; + if (!settings.EnableCalls) + { + return StatusCode(503, new { error = "Calls module is disabled by administrator." }); + } + + var turnUrl = !string.IsNullOrEmpty(settings.TurnHost) + ? $"turn:{settings.TurnHost}:{settings.TurnPort}" + : _configuration["WebRtc:TurnUrl"]; + + + var turnUsername = !string.IsNullOrEmpty(settings.TurnUser) + ? settings.TurnUser + : _configuration["WebRtc:TurnUsername"]; + + var turnSecret = !string.IsNullOrEmpty(settings.TurnSecret) + ? settings.TurnSecret + : _configuration["WebRtc:TurnPassword"]; var iceServers = new List(); - if (!string.IsNullOrEmpty(turnUrl) && !string.IsNullOrEmpty(turnUsername) && !string.IsNullOrEmpty(turnPassword)) + if (!string.IsNullOrEmpty(turnUrl)) { - // Use the VPS server as both STUN and TURN + // Use the VPS server as STUN var stunUrl = turnUrl.Replace("turn:", "stun:"); - iceServers.Add(new { urls = new[] { stunUrl } }); - iceServers.Add(new + + // Allow TURN even without username/password + if (!string.IsNullOrEmpty(turnUsername)) { - urls = new[] { turnUrl, turnUrl + "?transport=tcp" }, - username = turnUsername, - credential = turnPassword, - }); + iceServers.Add(new + { + urls = new[] { turnUrl, turnUrl + "?transport=tcp" }, + username = turnUsername, + credential = !string.IsNullOrEmpty(turnSecret) ? turnSecret : turnUsername + }); + } + else + { + iceServers.Add(new + { + urls = new[] { turnUrl, turnUrl + "?transport=tcp" } + }); + } } return Ok(new { iceServers }); diff --git a/apps/server-net/src/Host/Host.csproj b/apps/server-net/src/Host/Host.csproj index 2d777d1..1885ac4 100644 --- a/apps/server-net/src/Host/Host.csproj +++ b/apps/server-net/src/Host/Host.csproj @@ -17,7 +17,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/apps/server-net/src/Host/Program.cs b/apps/server-net/src/Host/Program.cs index 78db633..4f5ab94 100644 --- a/apps/server-net/src/Host/Program.cs +++ b/apps/server-net/src/Host/Program.cs @@ -36,7 +36,9 @@ var envMappings = new Dictionary ["S3_ENDPOINT"] = builder.Configuration["S3_ENDPOINT"], ["S3_ACCESS_KEY"] = builder.Configuration["S3_ACCESS_KEY"], ["S3_SECRET_KEY"] = builder.Configuration["S3_SECRET_KEY"], - ["S3_BUCKET"] = builder.Configuration["S3_BUCKET"] + ["S3_BUCKET"] = builder.Configuration["S3_BUCKET"], + ["KNOT_ADMIN_USER"] = builder.Configuration["KNOT_ADMIN_USER"], + ["KNOT_ADMIN_PASSWORD"] = builder.Configuration["KNOT_ADMIN_PASSWORD"] }; // Добавляем только те, что реально заданы в ENV @@ -137,10 +139,21 @@ using (var scope = app.Services.CreateScope()) var chatsDb = scope.ServiceProvider.GetRequiredService(); await chatsDb.Database.MigrateAsync(); + + var systemDb = scope.ServiceProvider.GetRequiredService(); + await systemDb.Database.MigrateAsync(); + + // Initialize Global Settings Cache + var settingsService = scope.ServiceProvider.GetRequiredService(); + if (settingsService is Knot.Shared.Infrastructure.Configuration.SettingsService concreteSettings) + { + concreteSettings.Initialize(); + } } // Настройка конвейера запросов app.UseMiddleware(); +app.UseMiddleware(); app.UseCors(); diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs b/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs index a829306..0f674d8 100644 --- a/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs +++ b/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using Knot.Modules.Chats.Application.Messages.Send; using Knot.Modules.Chats.Domain; using Knot.Shared.Kernel; +using Microsoft.Extensions.Caching.Memory; namespace Knot.Modules.Chats.Infrastructure.SignalR; @@ -21,17 +22,21 @@ public sealed class ChatHub : Hub // chatId → (userId → ParticipantInfo) private static readonly ConcurrentDictionary> _groupCallParticipants = new(); + public static int OnlineUsersCount => _userConnections.Count; + private readonly ISender _sender; private readonly IUserContext _userContext; private readonly IChatRepository _chatRepository; private readonly ILogger _logger; + private readonly IMemoryCache _cache; - public ChatHub(ISender sender, IUserContext userContext, IChatRepository chatRepository, ILogger logger) + public ChatHub(ISender sender, IUserContext userContext, IChatRepository chatRepository, ILogger logger, IMemoryCache cache) { _sender = sender; _userContext = userContext; _chatRepository = chatRepository; _logger = logger; + _cache = cache; } public override async Task OnConnectedAsync() @@ -45,6 +50,8 @@ public sealed class ChatHub : Hub (_, set) => { lock (set) { set.Add(Context.ConnectionId); } return set; } ); + _cache.Set("Global_OnlineUsersCount", _userConnections.Count); + var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted); foreach (var chat in userChats) { @@ -74,6 +81,7 @@ public sealed class ChatHub : Hub await Clients.Others.SendAsync("user_offline", new { userId, lastSeen = DateTime.UtcNow }); } } + _cache.Set("Global_OnlineUsersCount", _userConnections.Count); _logger.LogInformation("User {UserId} disconnected", userId); } await base.OnDisconnectedAsync(exception); diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Configuration/SettingsService.cs b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Configuration/SettingsService.cs new file mode 100644 index 0000000..0fc3755 --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Configuration/SettingsService.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using Knot.Shared.Infrastructure.Persistence; +using Knot.Shared.Infrastructure.Persistence.Entities; +using Knot.Shared.Kernel.Configuration; +using Knot.Shared.Kernel.Security; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Knot.Shared.Infrastructure.Configuration; + +public class SettingsService : ISettingsService +{ + private readonly IServiceProvider _serviceProvider; + private readonly IEncryptionService _encryptionService; + private SystemSettingsDto _current; + + public SettingsService(IServiceProvider serviceProvider, IEncryptionService encryptionService) + { + _serviceProvider = serviceProvider; + _encryptionService = encryptionService; + _current = new SystemSettingsDto(); // Default + } + + public SystemSettingsDto Current => _current; + + public async Task GetSettingsAsync(CancellationToken cancellationToken = default) + { + using var scope = _serviceProvider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var setting = await db.Settings.FirstOrDefaultAsync(s => s.Key == "Global", cancellationToken); + if (setting == null) + { + return new SystemSettingsDto(); + } + + try + { + var json = _encryptionService.DecryptMessage(setting.EncryptedValue); + var dto = JsonSerializer.Deserialize(json); + if (dto != null) + { + _current = dto; + return dto; + } + } + catch + { + // If decryption fails or JSON is invalid, return default + } + + return new SystemSettingsDto(); + } + + public async Task UpdateSettingsAsync(SystemSettingsDto settings, CancellationToken cancellationToken = default) + { + using var scope = _serviceProvider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var json = JsonSerializer.Serialize(settings); + var encrypted = _encryptionService.EncryptMessage(json); + + var setting = await db.Settings.FirstOrDefaultAsync(s => s.Key == "Global", cancellationToken); + if (setting == null) + { + db.Settings.Add(new SystemSetting { Key = "Global", EncryptedValue = encrypted }); + } + else + { + setting.EncryptedValue = encrypted; + } + + await db.SaveChangesAsync(cancellationToken); + _current = settings; // Update in-memory reference + } + + public void Initialize() + { + _current = GetSettingsAsync().GetAwaiter().GetResult(); + } +} diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/DependencyInjection.cs b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/DependencyInjection.cs index 30110d0..b720123 100644 --- a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/DependencyInjection.cs +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/DependencyInjection.cs @@ -3,6 +3,11 @@ using Microsoft.Extensions.Configuration; using Knot.Shared.Kernel; using Knot.Shared.Kernel.Security; using Knot.Shared.Kernel.Storage; +using Knot.Shared.Infrastructure.Persistence; +using Knot.Shared.Infrastructure.Configuration; +using Knot.Shared.Infrastructure.Statistics; +using Knot.Shared.Kernel.Configuration; +using Microsoft.EntityFrameworkCore; using Minio; using System; @@ -40,6 +45,20 @@ public static class DependencyInjection return new S3FileStorageService(minioClient, encService, s3Bucket); }); + // Настройка System Database + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? configuration["DATABASE_URL"] + ?? throw new ArgumentNullException("Database connection string not found."); + + services.AddDbContext(options => + options.UseNpgsql(connectionString)); + + services.AddMemoryCache(); + services.AddSingleton(); + services.AddScoped(); + + services.AddHostedService(); + return services; } } diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Knot.Shared.Infrastructure.csproj b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Knot.Shared.Infrastructure.csproj index 6dd0df2..fd81551 100644 --- a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Knot.Shared.Infrastructure.csproj +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Knot.Shared.Infrastructure.csproj @@ -12,8 +12,12 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + - + diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Middleware/AdminAuthMiddleware.cs b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Middleware/AdminAuthMiddleware.cs new file mode 100644 index 0000000..fbcfa8e --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Middleware/AdminAuthMiddleware.cs @@ -0,0 +1,62 @@ +using System.Net.Http.Headers; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; + +namespace Knot.Shared.Infrastructure.Middleware; + +public class AdminAuthMiddleware +{ + private readonly RequestDelegate _next; + + public AdminAuthMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, IConfiguration configuration) + { + if (context.Request.Path.StartsWithSegments("/api/admin")) + { + var expectedUser = configuration["KNOT_ADMIN_USER"]; + var expectedPass = configuration["KNOT_ADMIN_PASSWORD"]; + + if (string.IsNullOrEmpty(expectedUser) || string.IsNullOrEmpty(expectedPass)) + { + context.Response.StatusCode = 500; + await context.Response.WriteAsync("Admin credentials not configured on server."); + return; + } + + if (!context.Request.Headers.ContainsKey("Authorization")) + { + context.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"Admin Area\""); + context.Response.StatusCode = 401; + return; + } + + try + { + var authHeader = AuthenticationHeaderValue.Parse(context.Request.Headers["Authorization"]); + var credentialBytes = Convert.FromBase64String(authHeader.Parameter ?? string.Empty); + var credentials = Encoding.UTF8.GetString(credentialBytes).Split(':', 2); + + var username = credentials[0]; + var password = credentials[1]; + + if (username != expectedUser || password != expectedPass) + { + context.Response.StatusCode = 403; + return; + } + } + catch + { + context.Response.StatusCode = 401; + return; + } + } + + await _next(context); + } +} diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Migrations/20260315204509_InitialSystem.Designer.cs b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Migrations/20260315204509_InitialSystem.Designer.cs new file mode 100644 index 0000000..315f8e8 --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Migrations/20260315204509_InitialSystem.Designer.cs @@ -0,0 +1,64 @@ +// +using System; +using Knot.Shared.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Knot.Shared.Infrastructure.Migrations +{ + [DbContext(typeof(SystemDbContext))] + [Migration("20260315204509_InitialSystem")] + partial class InitialSystem + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("system") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Knot.Shared.Infrastructure.Persistence.Entities.DailyStat", b => + { + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("ActiveUsers") + .HasColumnType("bigint"); + + b.Property("TotalFilesSize") + .HasColumnType("bigint"); + + b.Property("TotalMessages") + .HasColumnType("bigint"); + + b.HasKey("Date"); + + b.ToTable("DailyStats", "system"); + }); + + modelBuilder.Entity("Knot.Shared.Infrastructure.Persistence.Entities.SystemSetting", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("Settings", "system"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Migrations/20260315204509_InitialSystem.cs b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Migrations/20260315204509_InitialSystem.cs new file mode 100644 index 0000000..d0a1529 --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Migrations/20260315204509_InitialSystem.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Knot.Shared.Infrastructure.Migrations +{ + /// + public partial class InitialSystem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "system"); + + migrationBuilder.CreateTable( + name: "DailyStats", + schema: "system", + columns: table => new + { + Date = table.Column(type: "timestamp with time zone", nullable: false), + TotalMessages = table.Column(type: "bigint", nullable: false), + TotalFilesSize = table.Column(type: "bigint", nullable: false), + ActiveUsers = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DailyStats", x => x.Date); + }); + + migrationBuilder.CreateTable( + name: "Settings", + schema: "system", + columns: table => new + { + Key = table.Column(type: "text", nullable: false), + EncryptedValue = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Settings", x => x.Key); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DailyStats", + schema: "system"); + + migrationBuilder.DropTable( + name: "Settings", + schema: "system"); + } + } +} diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Migrations/SystemDbContextModelSnapshot.cs b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Migrations/SystemDbContextModelSnapshot.cs new file mode 100644 index 0000000..4a134a5 --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Migrations/SystemDbContextModelSnapshot.cs @@ -0,0 +1,61 @@ +// +using System; +using Knot.Shared.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Knot.Shared.Infrastructure.Migrations +{ + [DbContext(typeof(SystemDbContext))] + partial class SystemDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("system") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Knot.Shared.Infrastructure.Persistence.Entities.DailyStat", b => + { + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("ActiveUsers") + .HasColumnType("bigint"); + + b.Property("TotalFilesSize") + .HasColumnType("bigint"); + + b.Property("TotalMessages") + .HasColumnType("bigint"); + + b.HasKey("Date"); + + b.ToTable("DailyStats", "system"); + }); + + modelBuilder.Entity("Knot.Shared.Infrastructure.Persistence.Entities.SystemSetting", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("Settings", "system"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/Entities/DailyStat.cs b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/Entities/DailyStat.cs new file mode 100644 index 0000000..2ab474a --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/Entities/DailyStat.cs @@ -0,0 +1,11 @@ +using System; + +namespace Knot.Shared.Infrastructure.Persistence.Entities; + +public class DailyStat +{ + public DateTime Date { get; set; } + public long TotalMessages { get; set; } + public long TotalFilesSize { get; set; } + public long ActiveUsers { get; set; } +} diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/Entities/SystemSetting.cs b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/Entities/SystemSetting.cs new file mode 100644 index 0000000..2bbbd17 --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/Entities/SystemSetting.cs @@ -0,0 +1,9 @@ +using System; + +namespace Knot.Shared.Infrastructure.Persistence.Entities; + +public class SystemSetting +{ + public string Key { get; set; } = string.Empty; + public string EncryptedValue { get; set; } = string.Empty; +} diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/SystemDbContext.cs b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/SystemDbContext.cs new file mode 100644 index 0000000..f3fb6f0 --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/SystemDbContext.cs @@ -0,0 +1,29 @@ +using Knot.Shared.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Knot.Shared.Infrastructure.Persistence; + +public class SystemDbContext : DbContext +{ + public SystemDbContext(DbContextOptions options) : base(options) { } + + public DbSet Settings => Set(); + public DbSet DailyStats => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("system"); + + modelBuilder.Entity(builder => + { + builder.ToTable("Settings"); + builder.HasKey(s => s.Key); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("DailyStats"); + builder.HasKey(s => s.Date); + }); + } +} diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/SystemDbContextFactory.cs b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/SystemDbContextFactory.cs new file mode 100644 index 0000000..b43a94b --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Persistence/SystemDbContextFactory.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; +using System.IO; + +namespace Knot.Shared.Infrastructure.Persistence; + +public class SystemDbContextFactory : IDesignTimeDbContextFactory +{ + public SystemDbContext CreateDbContext(string[] args) + { + var basePath = Path.Combine(Directory.GetCurrentDirectory(), "..", "Host"); + if (!Directory.Exists(basePath)) + { + basePath = Directory.GetCurrentDirectory(); // fallback + } + + var configuration = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + var builder = new DbContextOptionsBuilder(); + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? configuration["DATABASE_URL"] + ?? "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass"; + + builder.UseNpgsql(connectionString); + + return new SystemDbContext(builder.Options); + } +} diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Statistics/StatisticsService.cs b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Statistics/StatisticsService.cs new file mode 100644 index 0000000..5426df5 --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Statistics/StatisticsService.cs @@ -0,0 +1,109 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Knot.Shared.Kernel.Services; +using Knot.Shared.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace Knot.Shared.Infrastructure.Statistics; + +public class StatisticsService : IStatisticsService +{ + private readonly SystemDbContext _db; + private readonly IMemoryCache _cache; + + private const string CacheKey = "dashboard_stats"; + + public StatisticsService(SystemDbContext db, IMemoryCache cache) + { + _db = db; + _cache = cache; + } + + public async Task GetDashboardStatsAsync(CancellationToken cancellationToken = default) + { + // 10 second caching to allow fast UI updates while still preventing DB overload + if (_cache.TryGetValue(CacheKey, out DashboardStatsDto cached)) + { + return cached!; + } + + var latestStat = await _db.DailyStats + .OrderByDescending(s => s.Date) + .FirstOrDefaultAsync(cancellationToken); + + var history = await _db.DailyStats + .OrderByDescending(s => s.Date) + .Take(30) + .Select(s => new ActivityStatDto + { + Date = s.Date, + Messages = s.TotalMessages, + FilesSize = s.TotalFilesSize + }) + .ToListAsync(cancellationToken); + + var totalUsers = await _db.Database.GetDbConnection().CreateCommand().QueryTotalUsersAsync(); // Faked for simplicity without direct Identity reference + + long totalDiskSpace = 5L * 1024 * 1024 * 1024 * 1024; // Fallback + long freeSpace = 0; + try + { + var drive = new System.IO.DriveInfo("/"); + if(drive.IsReady) + { + totalDiskSpace = drive.TotalSize; + freeSpace = drive.AvailableFreeSpace; + } + else + { + drive = new System.IO.DriveInfo(System.IO.Directory.GetCurrentDirectory()); + totalDiskSpace = drive.TotalSize; + freeSpace = drive.AvailableFreeSpace; + } + } + catch { } + + var usedSpace = latestStat?.TotalFilesSize ?? 0; + + long onlineUsersCount = _cache.TryGetValue("Global_OnlineUsersCount", out int count) ? count : 0; + long offlineUsersCount = totalUsers - onlineUsersCount; + if(offlineUsersCount < 0) offlineUsersCount = 0; + + var stats = new DashboardStatsDto + { + StorageUsedBytes = usedSpace, + StorageLimitBytes = freeSpace > 0 ? usedSpace + freeSpace : totalDiskSpace, + OnlineUsers = onlineUsersCount, + OfflineUsers = offlineUsersCount, + TotalUsers = totalUsers, + ActivityTimeline = history + }; + + _cache.Set(CacheKey, stats, TimeSpan.FromSeconds(10)); + + return stats; + } +} + +// Temporary internal extensions for cross-module Db fetching during demonstration +internal static class SqlExtensions +{ + public static async Task QueryTotalUsersAsync(this System.Data.IDbCommand cmd) + { + try + { + cmd.CommandText = "SELECT COUNT(*) FROM identity.\"Users\""; + if (cmd.Connection?.State != System.Data.ConnectionState.Open) + await ((System.Data.Common.DbConnection)cmd.Connection!).OpenAsync(); + var result = await ((System.Data.Common.DbCommand)cmd).ExecuteScalarAsync(); + return Convert.ToInt64(result); + } + catch + { + return 0; // Fallback + } + } +} diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Statistics/StatisticsWorker.cs b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Statistics/StatisticsWorker.cs new file mode 100644 index 0000000..21fcc1a --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Statistics/StatisticsWorker.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Shared.Infrastructure.Persistence; +using Knot.Shared.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Knot.Shared.Infrastructure.Statistics; + +public class StatisticsWorker : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + + public StatisticsWorker(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Simplified gathering for daily stats + var today = DateTime.UtcNow.Date; + var stat = await db.DailyStats.FirstOrDefaultAsync(s => s.Date == today, stoppingToken); + + if (stat == null) + { + stat = new DailyStat { Date = today }; + db.DailyStats.Add(stat); + } + + var conn = db.Database.GetDbConnection(); + var cmd = conn.CreateCommand(); + + stat.ActiveUsers = await cmd.QueryTotalUsersAsync(); + + long dbSize = 0; + long filesSize = 0; + try + { + // 1. Database size itself + cmd.CommandText = "SELECT pg_database_size(current_database());"; + var dbSizeResult = await cmd.ExecuteScalarAsync(); + dbSize = dbSizeResult != DBNull.Value ? Convert.ToInt64(dbSizeResult) : 0; + + // 2. Sum of all uploaded files (which live in MinIO, but we track size in MessageMedia) + cmd.CommandText = "SELECT SUM(\"Size\") FROM chats.\"MessageMedia\";"; + var mediaSizeResult = await cmd.ExecuteScalarAsync(); + filesSize = mediaSizeResult != DBNull.Value ? Convert.ToInt64(mediaSizeResult) : 0; + } + catch { } + + stat.TotalFilesSize = dbSize + filesSize; // Database size + MinIO files size + + await db.SaveChangesAsync(stoppingToken); + } + catch + { + // Background service swallows errors to not crash the app + } + + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + } + } +} diff --git a/apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/ISettingsService.cs b/apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/ISettingsService.cs new file mode 100644 index 0000000..0dc967e --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/ISettingsService.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Knot.Shared.Kernel.Configuration; + +public interface ISettingsService +{ + Task GetSettingsAsync(CancellationToken cancellationToken = default); + Task UpdateSettingsAsync(SystemSettingsDto settings, CancellationToken cancellationToken = default); + SystemSettingsDto Current { get; } +} diff --git a/apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs b/apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs new file mode 100644 index 0000000..fd1fcf8 --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs @@ -0,0 +1,25 @@ +namespace Knot.Shared.Kernel.Configuration; + +public class SystemSettingsDto +{ + // Storage Limits + public double MaxStorageQuotaTb { get; set; } = 5.0; + public int MaxGroupMembers { get; set; } = 500; + public int MaxFileSizeMb { get; set; } = 100; + + // Calls + public bool EnableCalls { get; set; } = true; + public string TurnHost { get; set; } = string.Empty; + public int TurnPort { get; set; } = 3478; + public string TurnUser { get; set; } = string.Empty; + public string TurnSecret { get; set; } = string.Empty; + + // Klipy + public bool EnableKlipy { get; set; } = true; + public string KlipyApiKey { get; set; } = string.Empty; + public string KlipyCustomerId { get; set; } = string.Empty; + + // Confederation + public bool EnableConfederation { get; set; } = false; + public List AllowedDomains { get; set; } = new(); +} diff --git a/apps/server-net/src/Shared/Knot.Shared.Kernel/Services/IStatisticsService.cs b/apps/server-net/src/Shared/Knot.Shared.Kernel/Services/IStatisticsService.cs new file mode 100644 index 0000000..2a59d87 --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Kernel/Services/IStatisticsService.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Knot.Shared.Kernel.Services; + +public interface IStatisticsService +{ + Task GetDashboardStatsAsync(CancellationToken cancellationToken = default); +} + +public class DashboardStatsDto +{ + public long StorageUsedBytes { get; set; } + public long StorageLimitBytes { get; set; } + public long OnlineUsers { get; set; } + public long OfflineUsers { get; set; } + public long TotalUsers { get; set; } + public List ActivityTimeline { get; set; } = new(); + public List TopUsersByMessages { get; set; } = new(); + public List TopUsersByStorage { get; set; } = new(); +} + +public class ActivityStatDto +{ + public DateTime Date { get; set; } + public long Messages { get; set; } + public long FilesSize { get; set; } +} + +public class TopUserDto +{ + public Guid UserId { get; set; } + public string Username { get; set; } = string.Empty; + public long Value { get; set; } // messages count or bytes +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a76ef50..41698e1 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,6 +3,7 @@ import { AnimatePresence } from 'framer-motion'; import { useAuthStore } from './stores/authStore'; import AuthPage from './pages/AuthPage'; import ChatPage from './pages/ChatPage'; +import AdminPage from './pages/AdminPage'; import NotificationProvider from './components/NotificationProvider'; export default function App() { @@ -23,6 +24,11 @@ export default function App() { ); } + // Simple routing for admin + if (window.location.pathname.startsWith('/admin')) { + return ; + } + return ( <> diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4de75c8..789e62c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -38,7 +38,7 @@ import Avatar from './Avatar'; import { useThemeStore } from '../stores/themeStore'; export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCall?: (targetUser: UserBasic, type: 'voice' | 'video') => void; onStartGroupCall?: (chatId: string, chatName: string, type: 'voice' | 'video') => void }) { - const { user } = useAuthStore(); + const { user, config } = useAuthStore(); const { t, lang } = useLang(); const { chatTheme } = useThemeStore(); const { @@ -586,7 +586,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal {showSearch ? : } - {!isFavorites && ( + {!isFavorites && config?.enableCalls && ( <> - {(getKlipyKey() || onSelectGif) && ( + {config?.enableKlipy && (klipyApiKey || onSelectGif) && ( )} diff --git a/apps/web/src/components/UserProfile.tsx b/apps/web/src/components/UserProfile.tsx index 273ce81..3b44c79 100644 --- a/apps/web/src/components/UserProfile.tsx +++ b/apps/web/src/components/UserProfile.tsx @@ -28,7 +28,7 @@ interface UserProfileProps { type MediaTab = 'publications' | 'media' | 'files' | 'links'; export default function UserProfile({ userId, chatId, onClose, onGoToMessage, isSelf }: UserProfileProps) { - const { user: authUser } = useAuthStore(); + const { user: authUser, config } = useAuthStore(); const { t, lang } = useLang(); const [profile, setProfile] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -576,15 +576,17 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is {isMutedLocally ? t('enableSound') : t('disableSound')} - + {config?.enableCalls && ( + + )} + + + + setCreds({ ...creds, user: e.target.value })} + /> + setCreds({ ...creds, pass: e.target.value })} + /> + + + + ); + } + + + + const pct = stats ? Math.min((stats.storageUsedBytes / stats.storageLimitBytes) * 100, 100) : 0; + const freeSpace = stats ? stats.storageLimitBytes - stats.storageUsedBytes : 0; + + return ( +
+ + +
+ + + {/* DASHBOARD TAB */} + {activeTab === 'dashboard' && stats && ( + +

{t.dashboard}

+ +
+
+
+ +
+

{t.storageUsed}

+
{formatBytes(stats.storageUsedBytes)}
+
{t.memoryOccupied}
+ +
+ {pct.toFixed(1)}% Used + {formatBytes(freeSpace)} {t.storageFree} +
+
+ 80 ? 'bg-red-500' : 'bg-accent'}`} /> +
+
+ +
+
+ +
+

{t.userAnalytics}

+
+
+
{t.usersOnline}
+
{stats.onlineUsers}
+
+
+
{t.usersOffline}
+
{stats.offlineUsers}
+
+
+
{t.totalRegistered}
+
{stats.totalUsers}
+
+
+
+
+
+ )} + + {/* SETTINGS TAB */} + {activeTab === 'settings' && config && ( + +

{t.moduleSettings}

+ +
+

{t.limits}

+
+ + +
+
+ +
+
+
+

{t.calls}

+

{t.callsDesc}

+
+ setConfig({...config, enableCalls: e})} /> +
+ + {config.enableCalls && ( + + +
+ + +
+ +
+ )} +
+ +
+
+
+

{t.gifs}

+

{t.gifsDesc}

+
+ setConfig({...config, enableKlipy: e})} /> +
+ {config.enableKlipy && ( + + + + + )} +
+ +
+ +
+
+ )} + + {/* FEDERATION TAB */} + {activeTab === 'federation' && config && ( + +

{t.confederationSettings}

+ +
+
+
+

{t.enableConfederation}

+

{t.enableConfederationDesc}

+
+ setConfig({...config, enableConfederation: e})} /> +
+ + {config.enableConfederation && ( + +
+

{t.allowedNodes}

+
+ + + +
+
+ +
+ setDomainInput(e.target.value)} + placeholder="node.example.com" + onKeyDown={e => e.key === 'Enter' && addDomain()} + /> + +
+ +
+ {config.allowedDomains.length === 0 &&
No domains added yet
} + {config.allowedDomains.map(d => ( +
+ {d} + +
+ ))} +
+
+ )} +
+ +
+ +
+
+ )} + + {/* USERS TAB */} + {activeTab === 'users' && ( + + + {!selectedUser ? ( + <> +
+

{t.userAnalytics}

+
+ +
+ + { + setSearchQuery(e.target.value); + searchUsers(e.target.value); + }} + /> + {isSearching && } +
+ +
+ {users.length === 0 && !isSearching && ( +
+ {t.noUsersFound} +
+ )} + {users.map(u => ( +
fetchUserDetails(u.id)}> + {u.avatar ? ( + avatar + ) : ( +
+ +
+ )} +
+

{u.displayName}

+
@{u.username}
+
+
+ ))} +
+ + ) : ( + + + +
+
+ {selectedUser.avatar ? ( + Avatar + ) : ( +
+ +
+ )} + +

{selectedUser.displayName}

+

@{selectedUser.username}

+ +
+
+ {t.bio} + {selectedUser.bio || '—'} +
+
+
+ {t.registeredOn} + {formatDateTime(selectedUser.createdAt)} +
+
+ {t.lastSeen} + {formatDateTime(selectedUser.lastOnlineAt)} +
+
+ + {selectedUser.stats && ( +
+
+ {t.messagesSent} + {selectedUser.stats.messagesCount} +
+
+ {t.mediaSent} + {selectedUser.stats.mediaCount} +
+
+ {t.filesSent} + {selectedUser.stats.filesCount} +
+
+ {t.linksSent} + {selectedUser.stats.linksCount} +
+
+ {t.userStorageOccupied} + {formatBytes(selectedUser.stats.storageUsedBytes)} +
+
+ )} +
+
+ + )} + + + )} + + +
+
+ ); +} + +function NavButton({ icon, label, active, onClick }: { icon: React.ReactNode, label: string, active: boolean, onClick: () => void }) { + return ( + + ); +} + +function Toggle({ checked, onChange }: { checked: boolean, onChange: (v: boolean) => void }) { + return ( +
onChange(!checked)} + > + +
+ ); +} diff --git a/apps/web/src/stores/authStore.ts b/apps/web/src/stores/authStore.ts index a1589ad..2e8df1d 100644 --- a/apps/web/src/stores/authStore.ts +++ b/apps/web/src/stores/authStore.ts @@ -13,6 +13,8 @@ interface AuthState { logout: () => void; checkAuth: () => Promise; updateUser: (data: Partial) => void; + config: any; + fetchConfig: () => Promise; } export const useAuthStore = create((set, get) => ({ @@ -20,6 +22,16 @@ export const useAuthStore = create((set, get) => ({ user: null, isLoading: true, error: null, + config: null, + + fetchConfig: async () => { + try { + const res = await fetch('/api/config'); + if (res.ok) { + set({ config: await res.json() }); + } + } catch {} + }, login: async (username, password) => { try { @@ -59,6 +71,7 @@ export const useAuthStore = create((set, get) => ({ }, checkAuth: async () => { + get().fetchConfig(); const token = get().token; if (!token) { set({ isLoading: false }); diff --git a/apps/server-net/out.txt b/build.txt similarity index 56% rename from apps/server-net/out.txt rename to build.txt index 25bad3a..7b52e3f 100644 --- a/apps/server-net/out.txt +++ b/build.txt @@ -1,27 +1,32 @@ - Determining projects to restore... + Determining projects to restore... All projects are up-to-date for restore. -C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Chats.UnitTests\Knot.Modules.Chats.UnitTests.csproj] C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Identity.UnitTests\Knot.Modules.Identity.UnitTests.csproj] +C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Knot.Modules.Chats.csproj] +C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Chats.UnitTests\Knot.Modules.Chats.UnitTests.csproj] C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\Knot.Modules.Identity.csproj] C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] -C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Knot.Modules.Chats.csproj] C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj] Knot.Shared.Kernel -> E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Kernel\bin\Debug\net10.0\Knot.Shared.Kernel.dll -E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\Domain\Story.cs(23,15): warning CS8618: Non-nullable property 'Type' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable. [E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\Knot.Modules.Identity.csproj] -E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Storage\S3FileStorageService.cs(116,27): warning CS8600: Converting null literal or possible null value to non-nullable type. [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] -E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Storage\S3FileStorageService.cs(142,47): warning CS8604: Possible null reference argument for parameter 's' in 'byte[] Convert.FromBase64String(string s)'. [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] Knot.Modules.Identity -> E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\bin\Debug\net10.0\Knot.Modules.Identity.dll - Knot.Shared.Infrastructure -> E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\bin\Debug\net10.0\Knot.Shared.Infrastructure.dll Knot.Modules.Chats -> E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\bin\Debug\net10.0\Knot.Modules.Chats.dll - Knot.Modules.Identity.UnitTests -> E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Identity.UnitTests\bin\Debug\net10.0\Knot.Modules.Identity.UnitTests.dll Knot.Modules.Chats.UnitTests -> E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Chats.UnitTests\bin\Debug\net10.0\Knot.Modules.Chats.UnitTests.dll - -Build succeeded. - -E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\Domain\Story.cs(23,15): warning CS8618: Non-nullable property 'Type' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable. [E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\Knot.Modules.Identity.csproj] + Knot.Modules.Identity.UnitTests -> E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Identity.UnitTests\bin\Debug\net10.0\Knot.Modules.Identity.UnitTests.dll +E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Middleware\AdminAuthMiddleware.cs(40,66): warning CS8604: Possible null reference argument for parameter 'input' in 'AuthenticationHeaderValue AuthenticationHeaderValue.Parse(string input)'. [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] +E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Statistics\StatisticsService.cs(71,33): error CS0234: The type or namespace name 'Modules' does not exist in the namespace 'Knot' (are you missing an assembly reference?) [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] +E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Statistics\StatisticsService.cs(28,46): warning CS8600: Converting null literal or possible null value to non-nullable type. [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] +E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Statistics\StatisticsWorker.cs(64,48): error CS1061: 'IMinioClient' does not contain a definition for 'ListObjectsAsync' and no accessible extension method 'ListObjectsAsync' accepting a first argument of type 'IMinioClient' could be found (are you missing a using directive or an assembly reference?) [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Storage\S3FileStorageService.cs(116,27): warning CS8600: Converting null literal or possible null value to non-nullable type. [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Storage\S3FileStorageService.cs(142,47): warning CS8604: Possible null reference argument for parameter 's' in 'byte[] Convert.FromBase64String(string s)'. [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] - 3 Warning(s) - 0 Error(s) -Time Elapsed 00:00:01.32 +Build FAILED. + +E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Middleware\AdminAuthMiddleware.cs(40,66): warning CS8604: Possible null reference argument for parameter 'input' in 'AuthenticationHeaderValue AuthenticationHeaderValue.Parse(string input)'. [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] +E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Statistics\StatisticsService.cs(28,46): warning CS8600: Converting null literal or possible null value to non-nullable type. [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] +E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Storage\S3FileStorageService.cs(116,27): warning CS8600: Converting null literal or possible null value to non-nullable type. [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] +E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Storage\S3FileStorageService.cs(142,47): warning CS8604: Possible null reference argument for parameter 's' in 'byte[] Convert.FromBase64String(string s)'. [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] +E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Statistics\StatisticsService.cs(71,33): error CS0234: The type or namespace name 'Modules' does not exist in the namespace 'Knot' (are you missing an assembly reference?) [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] +E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Statistics\StatisticsWorker.cs(64,48): error CS1061: 'IMinioClient' does not contain a definition for 'ListObjectsAsync' and no accessible extension method 'ListObjectsAsync' accepting a first argument of type 'IMinioClient' could be found (are you missing a using directive or an assembly reference?) [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj] + 4 Warning(s) + 2 Error(s) + +Time Elapsed 00:00:00.91 diff --git a/diff.txt b/diff.txt deleted file mode 100644 index bb0a8b4fdb06128bc07c62f2a8bbafbf7d199104..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12952 zcmdU#ZEqXL5yuyXUll0ercGNQMME2ALpL^+8bQ*!sbg7EYNxR+OLF|eF#?IC9uS#Q zNlLM5_(P=rzHR@%9gc4IczlwT0s(P6@@{u#cAjT;|NEbI*bCeG)WQvYcJyzc-eIs;rAH=7<(9->0IM4{5tZLMOVDvQiMQG}6U$D0H=6qoNJp4@b zbwgVeycFc^@Tq>?6o#An-PETOJ_&z`?{Df}3x5h9>GMhWSRZDy#vA9lKA?UWKj*#D zJK>Hfy`eQ+*Y#;!&xcygbzL>RyE9zTk+=+3cJ%Zgx*tdu&9EMJ#r2*z($>HGdcGay zf~0!kNU|B|oA-a62aY|#dM3zInVl9(Knfg zX-dQPwaPtdY(a~8^Yq*Ft312Vk9&ea>|l*Ot#?`XZ9T)c4WftK5-*P8m;&R5^yg3` zA86#EX7R=}xDl?Ma>nR5)|j5W@Rp#!OXFWH{7uiEYSxx4gqXUox4CUBX&zAVB6qXd zjRQ_UnVy8tG>XV+Gp=h?`v3EAdx+=N@LtmhKF`9H=pnliI#Jgw zZasn454VI9>2FF$HU+t(S~(xR#}38@lf5TG#r&4NnvY zzl!j#SI}lwb1QlkVg?@lthm3FmTw3nc$b6~?gMKQ-uqf@U+cgo-xlwEVP4mISA=^S zUSbuwFZw{#ep}p&!n-UlvLdZp()T$0<_Ffq<(8zr5hd8w+@8F`S!vjaGJZH!*5h!a zZ$0tBa)R@q0u5LbCl+OC^YUUfT0no8o3zh?E345K)}@_~MAdSHXkOuU$%DK1yDC3| zFZ#DGeB2vdPvw{1*K<7A^XLWN8II!3@AUhz-aI>fvsuSWwClea`b2w!R|W!5eI9<0 zx4ZSPhi}ZEx_9IaJ`dgcdT7J5dj;~cwm#GV4#rvLC@IvvAy20i(wH`8c?EjiRCKdOc)pyJJ zs(f@#3v@MA5p{$ONu+ezyBxP?IYvX7zvLCvz(j7Vgq*8+J(SilLI_8-zQFL5% z&PTeXJG1o+`Vi?v%=2l{ClJ^ z<;*;5wrbRCx8KH^m8z8b)#^LThrEX6*?gQ>(^~0C!G|5KH~07Ezuj`no;x4gR1JlW z!Z&Jna%Rigy?V61YSBY;9hna2am;_n$?~euykHsbsGiNo6RRJA>iHi%o>43DXz1N4 zG}80L$x+rDXa_!&dJ&m-)e*Zd4~WI|^s7D6H>T!V zlJ3$cBOjSG_I4JFOlRhaB-VMNPOd3>cm%N?qeqi={a3@NVs*yZqspXxjz+~kM_x~u z<&WPfS(iOgU9&$^&VJlyjP9kJr}Y&*bE}Zq2`IXZ=(q15xi2$s>wawh`MH;v<}Lhv zPF+V@So9L}*jesCIt0I$A(gv>ZwKuw#i#9PH|$|_WEb>xJ+9A7Te?d3*%tq-*Kt

v)dh8)`@P_OHP_EZMH$vCl%$l8=d5cvyH+ovJdq^*0pe7; zb7k@<_tbpd{4PurBo+)#($&-z84&EDyWzUe;A(^+Cyz-40;7G3_!F^8zO zzH)E0Om8_KT2Xh5iiLW^^v5|h>aEhT)!4^n3Qol%VWP^z;;EZD>g>Y(j<^RM?I8_t z6q;<`7rML`QKqm^jcS0kOOuDNGW)-OuW zFr>W%lZR`xogp*cmhs$`yluyQbiU`23$edoeT8BNI^WZ=js$z%7ve6o^{Q|kVBWjLH-LW`F$f<$WkM#XmyeRf$&ccP9#;#^joqO-1tZ+ zM?3beF*a)+n!LV6ifghT&gzlHaOQ=xdsTGYlQg#Ew>`?)e|%_~b1z6YEY_wrn8RBW zq^{-_Hfrw=BL+JkSrZ)2eNtD=-bu85&dHrg!&SC`95?>JPy4a+Y880e!)$1$5`LI; zu#mz(y2QNdY{i(6k16-|%JjHr$CubyeKPziNFHT-+9S4eyzKvBn?$Zba>fQpaoTPx lRz~b8v$nT|IeJ=_9;fZO(3LB4J9M6W_sbZ4gltB{2rC)C)C$fTA(zvargZ>_SlEtE*q@Lzt%RoS8E- zXWF0dPf9Epkh5e=A*zwm*Ut5BiwDLR%-ng5hKxzf$mI>SLQc=K#YnWpJn~m$B;rI* zoomj$U&ZgWfgk`W=&6zuHBpJX!Bydj9Z>X6>D~7x2)9QfK#rx;D$H$IbzM7 ziYoG&+9{V-?a0iQ1^>?f@;ZWSL+a3>5$4FLT*IZrq?8My{ zXOx{{>^*mqvg&4L2gYACSSxm;=l^F3YKCe}og>(VKQr(h6Ma)7C+c4sE8-O4ryq?Z R96LcZzD3FR_;PKs!7m#~V)Ot2 literal 0 HcmV?d00001 diff --git a/klipy_test.json b/klipy_test.json new file mode 100644 index 0000000..e69de29 diff --git a/klipy_tst.json b/klipy_tst.json new file mode 100644 index 0000000..140f784 --- /dev/null +++ b/klipy_tst.json @@ -0,0 +1 @@ +{"result":false,"errors":{"message":["The provided API key is invalid: [XfStVjTiYct0pXnTAR5BOJUq49xZm0tSxfNvWAsSAWW0aH4zz2tbvxafgWUm8xRY3] [https:\/\/api.klipy.com\/api\/v1\/XfStVjTiYct0pXnTAR5BOJUq49xZm0tSxfNvWAsSAWW0aH4zz2tbvxafgWUm8xRY3\/gifs\/categories?locale=ru-RU]"]}} \ No newline at end of file diff --git a/logs.txt b/logs.txt deleted file mode 100644 index ba749efa043dd46aac40242e6f6d1c927c4f0205..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 75588 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