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 bb0a8b4..0000000 Binary files a/diff.txt and /dev/null differ diff --git a/docker-compose.yml b/docker-compose.yml index 8b166c6..58eb627 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,8 @@ services: S3_SECRET_KEY: ${S3_SECRET_KEY} S3_BUCKET: ${S3_BUCKET} KNOT_MASTER_ENCRYPTION_KEY: ${KNOT_MASTER_ENCRYPTION_KEY} + KNOT_ADMIN_USER: ${KNOT_ADMIN_USER} + KNOT_ADMIN_PASSWORD: ${KNOT_ADMIN_PASSWORD} depends_on: - db - minio diff --git a/klipy_err.txt b/klipy_err.txt new file mode 100644 index 0000000..c3b30df Binary files /dev/null and b/klipy_err.txt differ 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 ba749ef..0000000 Binary files a/logs.txt and /dev/null differ diff --git a/logs2.txt b/logs2.txt deleted file mode 100644 index b0fa06c..0000000 Binary files a/logs2.txt and /dev/null differ