Шифрование, GIF, хранилище, админка

This commit is contained in:
Халимов Рустам
2026-03-16 14:49:31 +03:00
parent 336f9ea559
commit 6d018e41ea
44 changed files with 1876 additions and 162 deletions

View File

@@ -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% клиентских конфигураций.

View File

@@ -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

View File

@@ -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<IActionResult> GetSettings(CancellationToken ct)
{
var settings = await _settingsService.GetSettingsAsync(ct);
return Ok(settings);
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings([FromBody] SystemSettingsDto settings, CancellationToken ct)
{
await _settingsService.UpdateSettingsAsync(settings, ct);
return Ok(settings);
}
[HttpGet("dashboard")]
public async Task<IActionResult> GetDashboardStats(CancellationToken ct)
{
var stats = await _statisticsService.GetDashboardStatsAsync(ct);
return Ok(stats);
}
[HttpGet("users")]
public async Task<IActionResult> 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<IActionResult> 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
}
});
}
}

View File

@@ -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
});
}
}

View File

@@ -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<string>();
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;
}

View File

@@ -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<IActionResult> UploadFile(IFormFile file, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage)
public async Task<IActionResult> 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);

View File

@@ -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<object>();
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 });

View File

@@ -17,7 +17,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.5" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />

View File

@@ -36,7 +36,9 @@ var envMappings = new Dictionary<string, string?>
["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<ChatsDbContext>();
await chatsDb.Database.MigrateAsync();
var systemDb = scope.ServiceProvider.GetRequiredService<Knot.Shared.Infrastructure.Persistence.SystemDbContext>();
await systemDb.Database.MigrateAsync();
// Initialize Global Settings Cache
var settingsService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Configuration.ISettingsService>();
if (settingsService is Knot.Shared.Infrastructure.Configuration.SettingsService concreteSettings)
{
concreteSettings.Initialize();
}
}
// Настройка конвейера запросов
app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.ExceptionHandlingMiddleware>();
app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.AdminAuthMiddleware>();
app.UseCors();

View File

@@ -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<string, ConcurrentDictionary<string, ParticipantInfo>> _groupCallParticipants = new();
public static int OnlineUsersCount => _userConnections.Count;
private readonly ISender _sender;
private readonly IUserContext _userContext;
private readonly IChatRepository _chatRepository;
private readonly ILogger<ChatHub> _logger;
private readonly IMemoryCache _cache;
public ChatHub(ISender sender, IUserContext userContext, IChatRepository chatRepository, ILogger<ChatHub> logger)
public ChatHub(ISender sender, IUserContext userContext, IChatRepository chatRepository, ILogger<ChatHub> 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);

View File

@@ -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<SystemSettingsDto> GetSettingsAsync(CancellationToken cancellationToken = default)
{
using var scope = _serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SystemDbContext>();
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<SystemSettingsDto>(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<SystemDbContext>();
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();
}
}

View File

@@ -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<SystemDbContext>(options =>
options.UseNpgsql(connectionString));
services.AddMemoryCache();
services.AddSingleton<ISettingsService, SettingsService>();
services.AddScoped<Knot.Shared.Kernel.Services.IStatisticsService, StatisticsService>();
services.AddHostedService<StatisticsWorker>();
return services;
}
}

View File

@@ -12,8 +12,12 @@
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Minio" Version="7.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
</ItemGroup>

View File

@@ -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);
}
}

View File

@@ -0,0 +1,64 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<long>("ActiveUsers")
.HasColumnType("bigint");
b.Property<long>("TotalFilesSize")
.HasColumnType("bigint");
b.Property<long>("TotalMessages")
.HasColumnType("bigint");
b.HasKey("Date");
b.ToTable("DailyStats", "system");
});
modelBuilder.Entity("Knot.Shared.Infrastructure.Persistence.Entities.SystemSetting", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("EncryptedValue")
.IsRequired()
.HasColumnType("text");
b.HasKey("Key");
b.ToTable("Settings", "system");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Shared.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialSystem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "system");
migrationBuilder.CreateTable(
name: "DailyStats",
schema: "system",
columns: table => new
{
Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
TotalMessages = table.Column<long>(type: "bigint", nullable: false),
TotalFilesSize = table.Column<long>(type: "bigint", nullable: false),
ActiveUsers = table.Column<long>(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<string>(type: "text", nullable: false),
EncryptedValue = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Settings", x => x.Key);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DailyStats",
schema: "system");
migrationBuilder.DropTable(
name: "Settings",
schema: "system");
}
}
}

View File

@@ -0,0 +1,61 @@
// <auto-generated />
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<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<long>("ActiveUsers")
.HasColumnType("bigint");
b.Property<long>("TotalFilesSize")
.HasColumnType("bigint");
b.Property<long>("TotalMessages")
.HasColumnType("bigint");
b.HasKey("Date");
b.ToTable("DailyStats", "system");
});
modelBuilder.Entity("Knot.Shared.Infrastructure.Persistence.Entities.SystemSetting", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("EncryptedValue")
.IsRequired()
.HasColumnType("text");
b.HasKey("Key");
b.ToTable("Settings", "system");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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<SystemDbContext> options) : base(options) { }
public DbSet<SystemSetting> Settings => Set<SystemSetting>();
public DbSet<DailyStat> DailyStats => Set<DailyStat>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("system");
modelBuilder.Entity<SystemSetting>(builder =>
{
builder.ToTable("Settings");
builder.HasKey(s => s.Key);
});
modelBuilder.Entity<DailyStat>(builder =>
{
builder.ToTable("DailyStats");
builder.HasKey(s => s.Date);
});
}
}

View File

@@ -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<SystemDbContext>
{
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<SystemDbContext>();
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);
}
}

View File

@@ -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<DashboardStatsDto> 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<long> 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
}
}
}

View File

@@ -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<SystemDbContext>();
// 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);
}
}
}

View File

@@ -0,0 +1,11 @@
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Shared.Kernel.Configuration;
public interface ISettingsService
{
Task<SystemSettingsDto> GetSettingsAsync(CancellationToken cancellationToken = default);
Task UpdateSettingsAsync(SystemSettingsDto settings, CancellationToken cancellationToken = default);
SystemSettingsDto Current { get; }
}

View File

@@ -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<string> AllowedDomains { get; set; } = new();
}

View File

@@ -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<DashboardStatsDto> 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<ActivityStatDto> ActivityTimeline { get; set; } = new();
public List<TopUserDto> TopUsersByMessages { get; set; } = new();
public List<TopUserDto> 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
}

View File

@@ -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 <AdminPage />;
}
return (
<>
<AnimatePresence mode="wait">

View File

@@ -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 ? <X size={18} /> : <Search size={18} />}
</button>
{!isFavorites && (
{!isFavorites && config?.enableCalls && (
<>
<button
onClick={() => {
@@ -768,7 +768,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{/* Закреплённое сообщение */}
{/* Active group call banner */}
{chat?.type === 'group' && activeGroupCallParticipants.length > 0 && (
{chat?.type === 'group' && config?.enableCalls && activeGroupCallParticipants.length > 0 && (
<button
onClick={() => onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice')}
className="flex items-center gap-3 px-4 py-2.5 border-b border-border bg-emerald-500/10 hover:bg-emerald-500/20 transition-colors text-left w-full flex-shrink-0"

View File

@@ -8,14 +8,11 @@ import { useAuthStore } from '../stores/authStore';
interface KlipyGif {
id: string;
images: {
original: { url: string };
fixed_height_small: { url: string };
};
file: {
sd?: { gif?: { url: string }; webp?: { url: string } };
hd?: { gif?: { url: string }; webp?: { url: string } };
};
files?: any;
file?: any;
media_formats?: any;
media?: any;
images?: any;
title?: string;
}
@@ -25,14 +22,18 @@ interface EmojiPickerProps {
onClose: () => void;
}
const getKlipyKey = () => import.meta.env.VITE_KLIPY_API_KEY || '';
const getCustomerId = () => {
const user = useAuthStore.getState().user;
return user?.id || 'anonymous';
const getKlipyKey = (config: any) => {
return config?.klipyApiKey || import.meta.env.VITE_KLIPY_API_KEY || '';
};
const getCustomerId = (config: any, user: any) => {
return config?.klipyCustomerId || user?.id || 'anonymous';
};
export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPickerProps) {
const { lang, t } = useLang();
const { config, user } = useAuthStore();
const klipyApiKey = getKlipyKey(config);
const klipyCustomerId = getCustomerId(config, user);
const [tab, setTab] = useState<'emoji' | 'gif'>('emoji');
const [gifQuery, setGifQuery] = useState('');
const [gifs, setGifs] = useState<KlipyGif[]>([]);
@@ -40,10 +41,12 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
const [trendingGifs, setTrendingGifs] = useState<KlipyGif[]>([]);
const gifSearchRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const initialFetchDone = useRef(false);
// Helper to safely extract GIF array from various possible Klipy API responses
const extractGifs = (d: any): KlipyGif[] => {
if (!d) return [];
if (d.data && Array.isArray(d.data.data)) return d.data.data;
if (Array.isArray(d)) return d;
if (Array.isArray(d.data)) return d.data;
if (Array.isArray(d.result)) return d.result;
@@ -54,10 +57,17 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
// Load trending GIFs (Klipy)
useEffect(() => {
if (tab === 'gif' && getKlipyKey() && trendingGifs.length === 0) {
if (tab === 'gif' && klipyApiKey && !initialFetchDone.current) {
initialFetchDone.current = true;
setGifLoading(true);
fetch(`https://api.klipy.com/api/v1/${getKlipyKey()}/gifs/trending?customer_id=${getCustomerId()}&per_page=30`)
.then(r => r.json())
fetch(`https://api.klipy.com/api/v1/${klipyApiKey}/gifs/trending?page=1&per_page=30&customer_id=${klipyCustomerId}`)
.then(async (r) => {
if (!r.ok) {
const text = await r.text();
throw new Error(`Klipy error: ${r.status} ${text.substring(0, 100)}`);
}
return r.json();
})
.then(d => {
setTrendingGifs(extractGifs(d));
setGifLoading(false);
@@ -68,13 +78,19 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
setGifLoading(false);
});
}
}, [tab, trendingGifs.length]);
}, [tab, klipyApiKey, klipyCustomerId]);
const searchGifs = useCallback((q: string) => {
if (!getKlipyKey() || !q.trim()) { setGifs([]); return; }
if (!klipyApiKey || !q.trim()) { setGifs([]); return; }
setGifLoading(true);
fetch(`https://api.klipy.com/api/v1/${getKlipyKey()}/gifs/search?customer_id=${getCustomerId()}&q=${encodeURIComponent(q)}&per_page=30`)
.then(r => r.json())
fetch(`https://api.klipy.com/api/v1/${klipyApiKey}/gifs/search?page=1&per_page=30&q=${encodeURIComponent(q)}&customer_id=${klipyCustomerId}`)
.then(async (r) => {
if (!r.ok) {
const text = await r.text();
throw new Error(`Klipy error: ${r.status} ${text.substring(0, 100)}`);
}
return r.json();
})
.then(d => {
setGifs(extractGifs(d));
setGifLoading(false);
@@ -84,7 +100,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
setGifs([]);
setGifLoading(false);
});
}, []);
}, [klipyApiKey, klipyCustomerId]);
const handleGifSearch = (q: string) => {
setGifQuery(q);
@@ -92,9 +108,23 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
debounceRef.current = setTimeout(() => searchGifs(q), 400);
};
const getGifUrl = (gif: any): string => {
return gif.files?.hd?.gif?.url || gif.files?.sd?.gif?.url
|| gif.file?.hd?.gif?.url || gif.file?.sd?.gif?.url
|| gif.media_formats?.gif?.url || gif.media?.[0]?.gif?.url
|| gif.images?.original?.url || '';
};
const getGifPreview = (gif: any, fullUrl: string): string => {
return gif.files?.sd?.webp?.url || gif.files?.sd?.gif?.url
|| gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url
|| gif.media_formats?.tinygif?.url || gif.media?.[0]?.tinygif?.url
|| gif.images?.fixed_height_small?.url || fullUrl;
};
const pickGif = (gif: KlipyGif) => {
const url = gif.file?.hd?.gif?.url || gif.file?.sd?.gif?.url || '';
const preview = gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url || url;
const url = getGifUrl(gif);
const preview = getGifPreview(gif, url);
if (onSelectGif && url) {
onSelectGif(url, preview);
}
@@ -132,6 +162,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
className="fixed z-[9991] rounded-2xl shadow-2xl border border-white/10"
style={{
width: pickerWidth,
height: tab === 'gif' ? 435 : undefined,
bottom: pos ? `${window.innerHeight - pos.top}px` : undefined,
left: pos ? pos.left : undefined,
background: 'rgb(17, 17, 19)',
@@ -146,7 +177,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
>
EMOJI
</button>
{(getKlipyKey() || onSelectGif) && (
{config?.enableKlipy && (klipyApiKey || onSelectGif) && (
<button
onClick={() => { setTab('gif'); setTimeout(() => gifSearchRef.current?.focus(), 100); }}
className={`flex-1 py-2.5 text-xs font-semibold tracking-wide transition-colors ${tab === 'gif' ? 'text-white border-b-2 border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
@@ -176,9 +207,9 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
)}
{/* GIF tab */}
{tab === 'gif' && (
{config?.enableKlipy && tab === 'gif' && (
<div className="flex flex-col h-[calc(100%-41px)]">
{!getKlipyKey() ? (
{!klipyApiKey ? (
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
<p className="text-sm text-zinc-400 mb-2">Klipy API Key required</p>
<p className="text-xs text-zinc-500 mb-3">{t('openConsoleRun')}</p>
@@ -214,7 +245,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
) : displayGifs.length === 0 ? (
<p className="text-center text-xs text-zinc-500 py-10">{gifQuery ? t('nothingFound') : ''}</p>
) : (
<div className="columns-2 gap-1.5">
<div className="columns-4 gap-1.5">
{displayGifs.map((gif) => (
<button
key={gif.id}
@@ -222,7 +253,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
className="w-full mb-1.5 rounded-lg overflow-hidden hover:opacity-80 transition-opacity block"
>
<img
src={gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url || gif.file?.hd?.gif?.url}
src={getGifPreview(gif, getGifUrl(gif))}
alt={gif.title || 'GIF'}
className="w-full h-auto rounded-lg"
loading="lazy"

View File

@@ -498,35 +498,44 @@ function MessageBubble({
)}
{/* Изображения и Видео (Галерея) */}
{(hasImage || hasVideo) && (
{(hasImage || hasVideo) && (() => {
const galleryMedia = media.filter(m => m.type === 'image' || m.type === 'video');
const isSingleGif = galleryMedia.length === 1 && (
galleryMedia[0].filename === 'gif' ||
galleryMedia[0].filename === 'gif.gif' ||
galleryMedia[0].url?.includes('klipy') ||
galleryMedia[0].url?.endsWith('.gif')
);
return (
<div className={`
${(message.content || message.forwardedFrom) ? '-mx-4' : ''}
${(message.content || message.forwardedFrom) ? (message.forwardedFrom ? 'mt-2' : '-mt-2.5') : ''}
${(message.content || message.forwardedFrom) ? (message.content ? 'mb-2' : '-mb-2.5') : ''}
bg-black/20 overflow-hidden
${isSingleGif && !(message.content || message.forwardedFrom) ? 'max-w-[260px] rounded-[1.25rem]' : ''}
${isSingleGif && (message.content || message.forwardedFrom) ? 'max-h-[260px] mx-auto' : ''}
bg-black/20 overflow-hidden relative
`}>
<div className={`grid gap-[2px] ${media.filter(m => m.type === 'image' || m.type === 'video').length >= 3
<div className={`grid gap-[2px] ${galleryMedia.length >= 3
? 'grid-cols-3'
: media.filter(m => m.type === 'image' || m.type === 'video').length === 2
: galleryMedia.length === 2
? 'grid-cols-2'
: 'grid-cols-1'
}`}>
{media
.filter((m) => m.type === 'image' || m.type === 'video')
.map((m, idx) => (
{galleryMedia.map((m, idx) => (
m.type === 'image' ? (
<img
key={m.id}
src={m.url}
alt=""
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${media.filter(i => i.type === 'image' || i.type === 'video').length > 1 ? 'aspect-square' : 'max-h-[500px]'
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]'
}`}
onClick={() => setLightboxData({ index: idx })}
/>
) : (
<div
key={m.id}
className={`relative cursor-pointer group/video ${media.filter(i => i.type === 'image' || i.type === 'video').length > 1 ? 'aspect-square' : ''
className={`relative cursor-pointer group/video ${galleryMedia.length > 1 ? 'aspect-square' : ''
}`}
onClick={() => setLightboxData({ index: idx })}
>
@@ -535,14 +544,15 @@ function MessageBubble({
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover/video:bg-black/40 transition-colors">
<Play size={media.filter(i => i.type === 'image' || i.type === 'video').length > 1 ? 24 : 48} className="text-white opacity-80" />
<Play size={galleryMedia.length > 1 ? 24 : 48} className="text-white opacity-80" />
</div>
</div>
)
))}
</div>
</div>
)}
);
})()}
{/* Голосовое */}
{hasVoice && (

View File

@@ -412,11 +412,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
chatId,
content: null,
type: 'voice',
mediaUrl: result.url,
mediaType: 'voice',
fileName: result.filename,
fileSize: result.size,
duration: recordingTimeRef.current,
attachments: [{
type: 'voice',
url: result.url,
fileName: result.filename,
fileSize: result.size
}],
replyToId: replyTo?.id || null,
});
setReplyTo(null);
@@ -874,9 +875,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
chatId,
content: null,
type: 'image',
mediaUrl: gifUrl,
mediaType: 'image',
fileName: 'gif',
attachments: [{
type: 'image',
url: gifUrl,
fileName: 'gif.gif',
fileSize: 0,
}],
replyToId: replyTo?.id || null,
});
setReplyTo(null);

View File

@@ -14,7 +14,7 @@ interface NewChatModalProps {
type Mode = 'personal' | 'group-select' | 'group-name';
export default function NewChatModal({ onClose }: NewChatModalProps) {
const { user } = useAuthStore();
const { user, config } = useAuthStore();
const { t } = useLang();
const { addChat, setActiveChat, loadMessages } = useChatStore();
const [mode, setMode] = useState<Mode>('personal');
@@ -212,7 +212,9 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">{t('createGroup')}</p>
<p className="text-xs text-zinc-500">{t('upTo200')}</p>
<p className="text-xs text-zinc-500">
{t('upTo200').replace('200', String(config?.maxGroupMembers || 500))}
</p>
</div>
</button>
)}

View File

@@ -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<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -576,15 +576,17 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
</div>
<span className="text-[11px] font-medium text-white shadow-sm">{isMutedLocally ? t('enableSound') : t('disableSound')}</span>
</button>
<button
onClick={() => handleStartCall('voice')}
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 hover:bg-white/10 transition-colors border border-white/5"
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
<Phone size={16} className="text-white" />
</div>
<span className="text-[11px] font-medium text-white">{t('call')}</span>
</button>
{config?.enableCalls && (
<button
onClick={() => handleStartCall('voice')}
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 hover:bg-white/10 transition-colors border border-white/5"
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
<Phone size={16} className="text-white" />
</div>
<span className="text-[11px] font-medium text-white">{t('call')}</span>
</button>
)}
<button
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 border border-white/5 opacity-50 cursor-not-allowed"
>

View File

@@ -21,7 +21,7 @@ export function connectSocket(token: string): SocketCompat {
accessTokenFactory: () => token
})
.withAutomaticReconnect()
.configureLogging(LogLevel.Information)
.configureLogging(LogLevel.Error)
.build();
// Обертка для совместимости с Socket.io API

View File

@@ -0,0 +1,704 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Settings, Shield, Activity, Users, Database, Globe, Search, Trash2, Plus, Download, Upload, Loader2, User } from 'lucide-react';
interface Stats {
storageUsedBytes: number;
storageLimitBytes: number;
onlineUsers: number;
offlineUsers: number;
totalUsers: number;
}
interface Conf {
maxStorageQuotaTb: number;
maxGroupMembers: number;
maxFileSizeMb: number;
enableCalls: boolean;
turnHost: string;
turnPort: number;
turnUser: string;
turnSecret: string;
enableKlipy: boolean;
klipyApiKey: string;
klipyCustomerId: string;
enableConfederation: boolean;
allowedDomains: string[];
}
interface AppUser {
id: string;
username: string;
displayName: string;
avatar: string | null;
bio?: string;
createdAt: string;
lastOnlineAt: string;
stats?: {
messagesCount: number;
mediaCount: number;
filesCount: number;
linksCount: number;
storageUsedBytes: number;
};
}
const translations = {
en: {
loginTitle: 'Admin Login',
username: 'Username',
password: 'Password',
loginBtn: 'Login',
dashboard: 'Dashboard',
settings: 'Settings',
federation: 'Federation',
userAnalytics: 'User Analytics',
storageUsed: 'Storage Used',
storageFree: 'Storage Free',
memoryOccupied: 'Memory occupied by user messages & files',
usersOnline: 'Users Online',
usersOffline: 'Users Offline',
totalRegistered: 'total registered',
moduleSettings: 'Module Settings',
limits: 'Limits',
maxFileSize: 'Max File Size (MB)',
maxGroupMembers: 'Max Group Members',
calls: 'WebRTC Calls',
turnHost: 'TURN Host',
turnPort: 'TURN Port',
turnUser: 'TURN User',
turnSecret: 'TURN Password/Secret',
gifs: 'Klipy GIFs',
gifsDesc: 'Allow users to search and send GIFs',
apiKey: 'API Key',
saveChanges: 'Save Changes',
confederationSettings: 'Confederation Settings',
enableConfederation: 'Enable Confederation',
enableConfederationDesc: 'Connect this node with the Knot network',
callsDesc: 'Enable peer-to-peer and group calls',
allowedNodes: 'Allowed Nodes (Whitelist)',
addNode: 'Add Node',
importJson: 'Import JSON',
exportJson: 'Export JSON',
searchUsers: 'Search Users...',
noUsersFound: 'No users found.',
viewDetails: 'View Details',
backToList: 'Back to list',
profile: 'Profile Details',
registeredOn: 'Registered:',
lastSeen: 'Last seen:',
bio: 'Bio',
errorInvalidLogin: 'Invalid credentials',
successSave: 'Settings saved successfully!',
errorSave: 'Error saving settings',
messagesSent: 'Messages Sent',
mediaSent: 'Media Sent',
filesSent: 'Files Sent',
linksSent: 'Links Sent',
userStorageOccupied: 'Storage Occupied'
},
ru: {
loginTitle: 'Вход администратора',
username: 'Имя пользователя',
password: 'Пароль',
loginBtn: 'Войти',
dashboard: 'Дашборд',
settings: 'Настройки',
federation: 'Федерация',
userAnalytics: 'Пользователи',
storageUsed: 'Места использовано',
storageFree: 'Свободно',
memoryOccupied: 'Память, занятая сообщениями и файлами пользователей',
usersOnline: 'В сети',
usersOffline: 'Не в сети',
totalRegistered: 'всего зарегистрировано',
moduleSettings: 'Настройки модулей',
limits: 'Лимиты',
maxFileSize: 'Макс. размер файла (МБ)',
maxGroupMembers: 'Макс. участников группы',
calls: 'Звонки WebRTC',
turnHost: 'TURN Хост',
turnPort: 'TURN Порт',
turnUser: 'TURN Пользователь',
turnSecret: 'TURN Пароль',
gifs: 'GIF Klipy',
gifsDesc: 'Разрешить пользователям поиск и отправку GIF',
apiKey: 'API Ключ',
saveChanges: 'Сохранить изменения',
confederationSettings: 'Настройки Конфедерации',
enableConfederation: 'Включить Конфедерацию',
enableConfederationDesc: 'Подключить этот узел к сети Knot',
callsDesc: 'Включить P2P и групповые звонки',
allowedNodes: 'Разрешенные узлы (Белый список)',
addNode: 'Добавить узел',
importJson: 'Импорт JSON',
exportJson: 'Экспорт JSON',
searchUsers: 'Поиск пользователей...',
noUsersFound: 'Пользователи не найдены.',
viewDetails: 'Подробнее',
backToList: 'Назад к списку',
profile: 'Профиль пользователя',
registeredOn: 'Зарегистрирован:',
lastSeen: 'Был в сети:',
bio: 'О себе',
errorInvalidLogin: 'Неверные учетные данные',
successSave: 'Настройки успешно сохранены!',
errorSave: 'Ошибка при сохранении',
messagesSent: 'Отправлено сообщений',
mediaSent: 'Отправлено медиа',
filesSent: 'Отправлено файлов',
linksSent: 'Отправлено ссылок',
userStorageOccupied: 'Места на диске'
}
};
export default function AdminPage() {
const [activeTab, setActiveTab] = useState('dashboard');
const [lang, setLang] = useState<'en' | 'ru'>('ru');
const t = translations[lang];
const formatDateTime = (dateStr: string) => {
const d = new Date(dateStr);
const options: Intl.DateTimeFormatOptions = {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
};
return d.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', options).replace(',', '');
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const [stats, setStats] = useState<Stats | null>(null);
const [config, setConfig] = useState<Conf | null>(null);
const [authHeader, setAuthHeader] = useState('');
const [creds, setCreds] = useState({ user: '', pass: '' });
const [authenticated, setAuthenticated] = useState(false);
// Users Tab
const [users, setUsers] = useState<AppUser[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [selectedUser, setSelectedUser] = useState<AppUser | null>(null);
const [domainInput, setDomainInput] = useState('');
const fetchDashboard = async (header: string) => {
try {
const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } });
if (res.ok) setStats(await res.json());
} catch {}
};
const fetchSettings = async (header: string) => {
try {
const res = await fetch('/api/admin/settings', { headers: { Authorization: header } });
if (res.ok) setConfig(await res.json());
} catch {}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const header = `Basic ${btoa(`${creds.user}:${creds.pass}`)}`;
const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } });
if (res.ok) {
setAuthHeader(header);
setAuthenticated(true);
fetchDashboard(header);
fetchSettings(header);
searchUsers('', header); // Fetch initial users list
} else {
alert(t.errorInvalidLogin);
}
};
useEffect(() => {
if (!authenticated || !authHeader) return;
const interval = setInterval(() => {
// Refresh dashboard if we are on dashboard tab
if (activeTab === 'dashboard') {
fetchDashboard(authHeader);
}
}, 10000);
return () => clearInterval(interval);
}, [authenticated, authHeader, activeTab]);
const saveSettings = async () => {
if (!config) return;
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify(config)
});
if (res.ok) alert(t.successSave);
} catch {
alert(t.errorSave);
}
};
const searchUsers = async (q: string, header: string = authHeader) => {
setIsSearching(true);
try {
const res = await fetch(`/api/admin/users?query=${encodeURIComponent(q)}`, { headers: { Authorization: header } });
if (res.ok) {
setUsers(await res.json());
}
} catch {} finally {
setIsSearching(false);
}
};
const fetchUserDetails = async (id: string) => {
try {
const res = await fetch(`/api/admin/users/${id}`, { headers: { Authorization: authHeader } });
if (res.ok) {
setSelectedUser(await res.json());
}
} catch {}
};
// Federation Domain Handlers
const addDomain = () => {
if (domainInput.trim() && config && !config.allowedDomains.includes(domainInput.trim().toLowerCase())) {
setConfig({ ...config, allowedDomains: [...config.allowedDomains, domainInput.trim().toLowerCase()] });
setDomainInput('');
}
};
const removeDomain = (d: string) => {
if (config) {
setConfig({ ...config, allowedDomains: config.allowedDomains.filter(x => x !== d) });
}
};
const exportDomains = () => {
if (!config) return;
const blob = new Blob([JSON.stringify(config.allowedDomains, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'knot_allowed_domains.json';
a.click();
URL.revokeObjectURL(url);
};
const importDomains = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length || !config) return;
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target?.result as string);
if (Array.isArray(parsed)) {
setConfig({ ...config, allowedDomains: parsed });
}
} catch {
alert('Invalid JSON file');
}
};
reader.readAsText(file);
};
if (!authenticated) {
return (
<div className="min-h-screen flex items-center justify-center bg-black/90 text-white font-sans">
<form onSubmit={handleLogin} className="bg-surface border border-accent/20 p-8 rounded-2xl w-full max-w-sm">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-accent">{t.loginTitle}</h1>
<div className="flex gap-2">
<button type="button" onClick={() => setLang('en')} className={`text-sm ${lang==='en'?'text-accent font-bold':'text-gray-500'}`}>EN</button>
<button type="button" onClick={() => setLang('ru')} className={`text-sm ${lang==='ru'?'text-accent font-bold':'text-gray-500'}`}>RU</button>
</div>
</div>
<input
className="w-full bg-black/50 border border-white/10 rounded-xl px-4 py-3 mb-4 outline-none focus:border-accent"
placeholder={t.username}
value={creds.user}
onChange={(e) => setCreds({ ...creds, user: e.target.value })}
/>
<input
className="w-full bg-black/50 border border-white/10 rounded-xl px-4 py-3 mb-6 outline-none focus:border-accent"
type="password"
placeholder={t.password}
value={creds.pass}
onChange={(e) => setCreds({ ...creds, pass: e.target.value })}
/>
<button className="w-full bg-accent hover:bg-accentLight text-black font-semibold py-3 rounded-xl transition-colors">
{t.loginBtn}
</button>
</form>
</div>
);
}
const pct = stats ? Math.min((stats.storageUsedBytes / stats.storageLimitBytes) * 100, 100) : 0;
const freeSpace = stats ? stats.storageLimitBytes - stats.storageUsedBytes : 0;
return (
<div className="min-h-screen bg-black text-white font-sans flex overflow-hidden">
<aside className="w-64 border-r border-white/10 bg-surface/50 p-6 flex flex-col gap-2 shrink-0 overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-accent flex items-center gap-2">
<Shield className="w-6 h-6" /> Knot Admin
</h2>
</div>
<NavButton icon={<Activity />} label={t.dashboard} active={activeTab === 'dashboard'} onClick={() => setActiveTab('dashboard')} />
<NavButton icon={<Settings />} label={t.settings} active={activeTab === 'settings'} onClick={() => setActiveTab('settings')} />
<NavButton icon={<Globe />} label={t.federation} active={activeTab === 'federation'} onClick={() => setActiveTab('federation')} />
<NavButton icon={<Users />} label={t.userAnalytics} active={activeTab === 'users'} onClick={() => { setActiveTab('users'); setSelectedUser(null); }} />
<div className="mt-auto pt-6 flex gap-4 text-gray-500 text-sm justify-center">
<button onClick={() => setLang('en')} className={lang === 'en' ? 'text-accent font-bold' : 'hover:text-white'}>EN</button>
<span>|</span>
<button onClick={() => setLang('ru')} className={lang === 'ru' ? 'text-accent font-bold' : 'hover:text-white'}>RU</button>
</div>
</aside>
<main className="flex-1 p-8 overflow-y-auto relative">
<AnimatePresence mode="wait">
{/* DASHBOARD TAB */}
{activeTab === 'dashboard' && stats && (
<motion.div key="dashboard" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="flex flex-col gap-6 w-full max-w-5xl">
<h1 className="text-3xl font-bold">{t.dashboard}</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
<div className="bg-surface border border-white/10 rounded-2xl p-6 relative overflow-hidden">
<div className="absolute -right-10 -top-10 text-white/5 rotate-12">
<Database className="w-40 h-40" />
</div>
<h3 className="text-gray-400 font-medium mb-4 flex items-center gap-2 relative z-10"><Database className="w-5 h-5"/> {t.storageUsed}</h3>
<div className="text-4xl font-bold text-white relative z-10 mb-2">{formatBytes(stats.storageUsedBytes)}</div>
<div className="text-sm text-gray-500 mb-6 relative z-10">{t.memoryOccupied}</div>
<div className="flex justify-between text-sm text-gray-400 mb-2">
<span>{pct.toFixed(1)}% Used</span>
<span>{formatBytes(freeSpace)} {t.storageFree}</span>
</div>
<div className="w-full bg-white/10 rounded-full h-3 overflow-hidden relative z-10">
<motion.div initial={{ width: 0 }} animate={{ width: `${pct}%` }} className={`h-full ${pct > 80 ? 'bg-red-500' : 'bg-accent'}`} />
</div>
</div>
<div className="bg-surface border border-white/10 rounded-2xl p-6 relative overflow-hidden">
<div className="absolute -right-10 -top-10 text-white/5 -rotate-12">
<Users className="w-40 h-40" />
</div>
<h3 className="text-gray-400 font-medium mb-4 flex items-center gap-2 relative z-10"><Users className="w-5 h-5"/> {t.userAnalytics}</h3>
<div className="flex flex-col gap-4 relative z-10">
<div className="flex justify-between items-end border-b border-white/10 pb-4">
<div className="text-gray-400">{t.usersOnline}</div>
<div className="text-3xl font-bold text-green-400">{stats.onlineUsers}</div>
</div>
<div className="flex justify-between items-end border-b border-white/10 pb-4">
<div className="text-gray-400">{t.usersOffline}</div>
<div className="text-3xl font-bold text-gray-300">{stats.offlineUsers}</div>
</div>
<div className="flex justify-between items-end pt-2">
<div className="text-sm text-gray-500 uppercase tracking-wider">{t.totalRegistered}</div>
<div className="text-xl font-bold text-accent">{stats.totalUsers}</div>
</div>
</div>
</div>
</div>
</motion.div>
)}
{/* SETTINGS TAB */}
{activeTab === 'settings' && config && (
<motion.div key="settings" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="flex flex-col gap-6 max-w-3xl pb-20">
<h1 className="text-3xl font-bold mb-4">{t.moduleSettings}</h1>
<div className="bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-5">
<h3 className="text-xl font-semibold mb-2">{t.limits}</h3>
<div className="grid grid-cols-2 gap-4">
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.maxFileSize}
<input type="number" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.maxFileSizeMb} onChange={e => setConfig({...config, maxFileSizeMb: parseInt(e.target.value)||0})} />
</label>
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.maxGroupMembers}
<input type="number" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.maxGroupMembers} onChange={e => setConfig({...config, maxGroupMembers: parseInt(e.target.value)||0})} />
</label>
</div>
</div>
<div className="bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold text-white">{t.calls}</h3>
<p className="text-sm text-gray-400 mt-1">{t.callsDesc}</p>
</div>
<Toggle checked={config.enableCalls} onChange={e => setConfig({...config, enableCalls: e})} />
</div>
{config.enableCalls && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} className="flex flex-col gap-4 mt-4 pt-4 border-t border-white/10">
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.turnHost}
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnHost} onChange={e => setConfig({...config, turnHost: e.target.value})} placeholder="ip or domain" />
</label>
<div className="grid grid-cols-2 gap-4">
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.turnPort}
<input type="number" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnPort} onChange={e => setConfig({...config, turnPort: parseInt(e.target.value)||3478})} />
</label>
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.turnUser}
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnUser} onChange={e => setConfig({...config, turnUser: e.target.value})} />
</label>
</div>
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.turnSecret}
<input type="password" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnSecret} onChange={e => setConfig({...config, turnSecret: e.target.value})} />
</label>
</motion.div>
)}
</div>
<div className="bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold text-white">{t.gifs}</h3>
<p className="text-sm text-gray-400 mt-1">{t.gifsDesc}</p>
</div>
<Toggle checked={config.enableKlipy} onChange={e => setConfig({...config, enableKlipy: e})} />
</div>
{config.enableKlipy && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} className="mt-4 pt-4 border-t border-white/10 flex flex-col gap-4">
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.apiKey}
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.klipyApiKey} onChange={e => setConfig({...config, klipyApiKey: e.target.value})} />
</label>
<label className="flex flex-col gap-1 text-sm text-gray-400">
Customer ID (for tracking/analytics)
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.klipyCustomerId || ''} onChange={e => setConfig({...config, klipyCustomerId: e.target.value})} />
</label>
</motion.div>
)}
</div>
<div className="sticky bottom-8 self-end">
<button onClick={saveSettings} className="bg-accent hover:bg-accentLight text-black font-semibold py-4 px-10 rounded-xl transition-colors shadow-xl shadow-accent/20">
{t.saveChanges}
</button>
</div>
</motion.div>
)}
{/* FEDERATION TAB */}
{activeTab === 'federation' && config && (
<motion.div key="federation" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="flex flex-col gap-6 max-w-3xl pb-20">
<h1 className="text-3xl font-bold mb-4">{t.confederationSettings}</h1>
<div className="bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold text-white">{t.enableConfederation}</h3>
<p className="text-sm text-gray-400 mt-1">{t.enableConfederationDesc}</p>
</div>
<Toggle checked={config.enableConfederation} onChange={e => setConfig({...config, enableConfederation: e})} />
</div>
{config.enableConfederation && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} className="mt-6 pt-6 border-t border-white/10">
<div className="flex justify-between items-center mb-4">
<h4 className="text-lg font-medium text-white">{t.allowedNodes}</h4>
<div className="flex gap-2">
<input type="file" id="importJson" className="hidden" accept=".json" onChange={importDomains} />
<label htmlFor="importJson" className="flex items-center gap-2 cursor-pointer text-sm bg-white/5 hover:bg-white/10 px-3 py-2 rounded-lg transition-colors">
<Upload className="w-4 h-4" /> {t.importJson}
</label>
<button onClick={exportDomains} className="flex items-center gap-2 text-sm bg-white/5 hover:bg-white/10 px-3 py-2 rounded-lg transition-colors">
<Download className="w-4 h-4" /> {t.exportJson}
</button>
</div>
</div>
<div className="flex gap-2 mb-4">
<input
className="flex-1 bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
value={domainInput}
onChange={e => setDomainInput(e.target.value)}
placeholder="node.example.com"
onKeyDown={e => e.key === 'Enter' && addDomain()}
/>
<button onClick={addDomain} className="bg-white/10 hover:bg-white/20 px-6 rounded-xl transition-colors font-semibold">
{t.addNode}
</button>
</div>
<div className="bg-black/30 border border-white/5 rounded-xl block p-2 max-h-80 overflow-y-auto">
{config.allowedDomains.length === 0 && <div className="p-4 text-gray-500 text-center text-sm">No domains added yet</div>}
{config.allowedDomains.map(d => (
<div key={d} className="flex justify-between items-center bg-white/5 p-3 rounded-lg mb-2 last:mb-0 group">
<span className="font-mono text-sm">{d}</span>
<button onClick={() => removeDomain(d)} className="text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all">
<Trash2 className="w-5 h-5"/>
</button>
</div>
))}
</div>
</motion.div>
)}
</div>
<div className="sticky bottom-8 self-end">
<button onClick={saveSettings} className="bg-accent hover:bg-accentLight text-black font-semibold py-4 px-10 rounded-xl transition-colors shadow-xl shadow-accent/20">
{t.saveChanges}
</button>
</div>
</motion.div>
)}
{/* USERS TAB */}
{activeTab === 'users' && (
<motion.div key="users" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="flex flex-col gap-6 w-full max-w-5xl h-full pb-10">
{!selectedUser ? (
<>
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">{t.userAnalytics}</h1>
</div>
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
className="w-full bg-surface border border-white/10 rounded-2xl pl-12 pr-4 py-4 text-white outline-none focus:border-accent transition-colors shadow-lg"
placeholder={t.searchUsers}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
searchUsers(e.target.value);
}}
/>
{isSearching && <Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 text-accent w-5 h-5 animate-spin" />}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 auto-rows-max">
{users.length === 0 && !isSearching && (
<div className="col-span-full text-center py-20 text-gray-500">
{t.noUsersFound}
</div>
)}
{users.map(u => (
<div key={u.id} className="bg-surface border border-white/10 p-5 rounded-2xl flex items-center gap-4 hover:border-accent/50 transition-colors cursor-pointer" onClick={() => fetchUserDetails(u.id)}>
{u.avatar ? (
<img src={u.avatar} alt="avatar" className="w-14 h-14 rounded-full object-cover border border-white/10" />
) : (
<div className="w-14 h-14 rounded-full bg-white/10 flex items-center justify-center border border-white/5">
<User className="text-gray-400 w-6 h-6" />
</div>
)}
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-white truncate">{u.displayName}</h4>
<div className="text-sm text-gray-400 truncate">@{u.username}</div>
</div>
</div>
))}
</div>
</>
) : (
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="flex flex-col gap-6 max-w-2xl">
<button onClick={() => setSelectedUser(null)} className="text-accent hover:underline self-start flex items-center gap-2">
&larr; {t.backToList}
</button>
<div className="bg-surface border border-white/10 rounded-2xl p-8 flex flex-col items-center relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-32 bg-gradient-to-b from-accent/20 to-transparent" />
{selectedUser.avatar ? (
<img src={selectedUser.avatar} className="w-32 h-32 rounded-full object-cover border-4 border-surface shadow-2xl relative z-10 z-10" alt="Avatar" />
) : (
<div className="w-32 h-32 rounded-full bg-black border-4 border-surface shadow-2xl relative z-10 flex items-center justify-center">
<User className="text-gray-400 w-12 h-12" />
</div>
)}
<h2 className="text-3xl font-bold text-white mt-4 relative z-10">{selectedUser.displayName}</h2>
<p className="text-gray-400 text-lg relative z-10">@{selectedUser.username}</p>
<div className="w-full mt-8 flex flex-col gap-4 relative z-10">
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.bio}</span>
<span className="text-white">{selectedUser.bio || '—'}</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.registeredOn}</span>
<span className="text-white">{formatDateTime(selectedUser.createdAt)}</span>
</div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.lastSeen}</span>
<span className="text-white">{formatDateTime(selectedUser.lastOnlineAt)}</span>
</div>
</div>
{selectedUser.stats && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mt-2">
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.messagesSent}</span>
<span className="text-white text-xl font-bold">{selectedUser.stats.messagesCount}</span>
</div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.mediaSent}</span>
<span className="text-white text-xl font-bold">{selectedUser.stats.mediaCount}</span>
</div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.filesSent}</span>
<span className="text-white text-xl font-bold">{selectedUser.stats.filesCount}</span>
</div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.linksSent}</span>
<span className="text-white text-xl font-bold">{selectedUser.stats.linksCount}</span>
</div>
<div className="col-span-2 md:col-span-2 bg-gradient-to-br from-accent/10 to-transparent p-4 rounded-xl border border-accent/20 flex flex-col gap-1">
<span className="text-xs text-accent uppercase tracking-widest">{t.userStorageOccupied}</span>
<span className="text-accent text-2xl font-bold">{formatBytes(selectedUser.stats.storageUsedBytes)}</span>
</div>
</div>
)}
</div>
</div>
</motion.div>
)}
</motion.div>
)}
</AnimatePresence>
</main>
</div>
);
}
function NavButton({ icon, label, active, onClick }: { icon: React.ReactNode, label: string, active: boolean, onClick: () => void }) {
return (
<button
onClick={onClick}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-colors text-left font-medium ${active ? 'bg-accent text-black shadow-lg shadow-accent/20' : 'text-gray-400 hover:text-white hover:bg-white/5'}`}
>
{icon} {label}
</button>
);
}
function Toggle({ checked, onChange }: { checked: boolean, onChange: (v: boolean) => void }) {
return (
<div
className={`w-12 h-6 rounded-full cursor-pointer p-1 transition-colors ${checked ? 'bg-accent' : 'bg-white/10 border border-white/5'}`}
onClick={() => onChange(!checked)}
>
<motion.div
className={`w-4 h-4 rounded-full ${checked ? 'bg-black' : 'bg-gray-400'}`}
animate={{ x: checked ? 24 : 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
/>
</div>
);
}

View File

@@ -13,6 +13,8 @@ interface AuthState {
logout: () => void;
checkAuth: () => Promise<void>;
updateUser: (data: Partial<User>) => void;
config: any;
fetchConfig: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set, get) => ({
@@ -20,6 +22,16 @@ export const useAuthStore = create<AuthState>((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<AuthState>((set, get) => ({
},
checkAuth: async () => {
get().fetchConfig();
const token = get().token;
if (!token) {
set({ isLoading: false });

View File

@@ -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

BIN
diff.txt

Binary file not shown.

View File

@@ -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

BIN
klipy_err.txt Normal file

Binary file not shown.

0
klipy_test.json Normal file
View File

1
klipy_tst.json Normal file
View File

@@ -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]"]}}

BIN
logs.txt

Binary file not shown.

BIN
logs2.txt

Binary file not shown.