Шифрование, GIF, хранилище, админка
This commit is contained in:
95
README.md
95
README.md
@@ -1,81 +1,88 @@
|
||||
# Knot Messenger (Knot)
|
||||
# SelfHost Messenger
|
||||
|
||||
Knot Messenger — это современный, безопасный и многофункциональный мессенджер с открытым исходным кодом, который вы полностью можете развернуть на собственных серверах (self-hosted). Проект разработан на стеке **React + TypeScript + Vite** для фронтенда и **.NET (C#) + PostgreSQL + SignalR** для бэкенда.
|
||||
SelfHost Messenger — это современный, безопасный и многофункциональный мессенджер с открытым исходным кодом, предназначенный для самостоятельного развертывания на собственных серверах (self-hosted). Проект разработан на стеке **React + TypeScript + Vite** для фронтенда и **.NET (C#) 8/10 + PostgreSQL + MinIO + SignalR** для бэкенда.
|
||||
|
||||
## 🚀 Текущий функционал
|
||||
## 🚀 Основной функционал
|
||||
|
||||
Мессенджер обладает функциями полноценной современной платформы общения:
|
||||
Мессенджер обладает всеми функциями полноценной современной платформы для общения и командной работы:
|
||||
|
||||
### Чаты и группы
|
||||
* **Личные сообщения (P2P):** Обмен сообщениями в реальном времени (на базе SignalR). Текстовые сообщения, статусы прочитанности, индикаторы набора текста (typing).
|
||||
* **Групповые чаты:** Создание закрытых групп. Настройки группы (название, описание, аватар). Добавление и удаление участников администратором.
|
||||
* **Личные сообщения (P2P):** Обмен сообщениями в реальном времени (SignalR). Текстовые сообщения, статусы отправки, доставки и прочитанности, индикаторы набора текста (typing).
|
||||
* **Групповые чаты:** Создание групп. Настройки группы (название, описание, аватар). Добавление и удаление участников администратором. В админ-панели можно установить лимит участников.
|
||||
* **Закрепление чатов (Pin/Unpin):** Важные чаты всегда под рукой вверху списка.
|
||||
* **Вложения и медиа:** Поддержка отправки фото, видео, обычных файлов, а также удобный просмотр отправленных ссылок (распределение по вкладкам в информации профиля и группы). Изображения открываются во встроенном полноэкранном лайтбоксе.
|
||||
* **Интеграция GIF (Klipy):** Встроенный поиск и отправка GIF-анимаций через Klipy.
|
||||
* **Реакции на сообщения:** Стандартный набор эмодзи-реакций с красивой анимацией.
|
||||
* **Вложения и медиа:** Отправка фото, видео, голосовых сообщений (с генерацией waveform-волны), аудиофайлов и обычных документов. Плавный просмотр изображений и видео во встроенном полноэкранном лайтбоксе (галерее).
|
||||
* **Интеграция GIF (Klipy):** Встроенный поиск и отправка GIF-анимаций через Klipy. Ключи и ID клиента(Customer ID) настраиваются прямо из панели администратора.
|
||||
* **Реакции и действия с сообщениями:** Реакции на сообщения (эмодзи), редактирование, пересылка (Forward), ответы (Reply c цитатами), удаление (только для себя или для всех).
|
||||
* **Отложенные сообщения:** Возможность запланировать отправку сообщения на определенную дату и время.
|
||||
|
||||
### Конференц-связь
|
||||
* **Аудио и видеозвонки:** Интеграция WebRTC для личных звонков и групповых конференций.
|
||||
* **Демонстрация экрана:** Возможность делиться экраном со всеми участниками беседы.
|
||||
* Управление микрофоном, камерой, отображение статусов звонка в чате. *Для стабильной работы необходим внешний TURN-сервер.*
|
||||
### Конференц-связь (WebRTC)
|
||||
* **Аудио и видеозвонки:** Интеграция WebRTC для личных звонков P2P.
|
||||
* **Групповые звонки:** Аудио-конференции внутри групповых чатов со всеми участниками беседы.
|
||||
* **Демонстрация экрана (Screen Sharing):** Возможность поделиться экраном или окном.
|
||||
* Управление микрофоном, камерой, отображение статусов звонка прямо в чате. *Для стабильной работы необходим внешний TURN-сервер.*
|
||||
|
||||
### Социальные функции
|
||||
* **Истории (Stories):** Публикация фото/видео историй на 24 часа. Просмотр списка посмотревших (Viewer List).
|
||||
* **Истории (Stories):** Публикация фото/видео историй. Просмотр списка посмотревших (Viewer List). Истории автоматически удаляются через установленное время.
|
||||
* **Друзья (Friends):** Гибкая система отправки заявок в друзья, подтверждения и удаления из друзей.
|
||||
* **Продвинутый профиль пользователя:** Смена никнейма, информации "о себе", дня рождения.
|
||||
* **Клиентский кроп фото:** Идеально ровная обрезка квадратных и круглых аватаров для профиля и групповых чатов происходит прямо в вашем браузере. Решение не смещает координаты кадра и на сервер летит уже готовый результат.
|
||||
* **Онлайн-статусы:** Индикация того, кто находится онлайн в данный момент.
|
||||
* **Продвинутый профиль пользователя:** Настройка никнейма, информации "о себе", даты рождения. Клиентский редактор аватарок (Crop/Zoom) с идеальной обрезкой прямо в браузере.
|
||||
* **Онлайн-статусы:** Индикация того, кто находится онлайн в данный момент (с точным временем "Был(а) в сети...").
|
||||
|
||||
### Интерфейс и Кастомизация
|
||||
* Современно выглядит: эффект стекла (glassmorphism), плавные анимации (Framer Motion).
|
||||
* **Кастомизация тем:** Встроенное меню выбора цветовых акцентов и фона чата (Ocean, Nebula, Midnight, Forest и д.р.).
|
||||
### Интерфейс и Панель Управления
|
||||
* **Премиальный дизайн:** Эффект стекла (glassmorphism), плавные анимации (Framer Motion), кастомизация тем на лету (Ocean, Nebula, Midnight, Forest и д.р.).
|
||||
* **Админ-панель (Dashboard):** Динамическое управление настройками системы "на лету" без перезагрузки сервера:
|
||||
* Включение/выключение звонков.
|
||||
* Настройка ключей Klipy API.
|
||||
* Установка максимального размера загружаемого файла.
|
||||
* Лимиты на количество участников в группах.
|
||||
* Управление пользователями системы.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Архитектура
|
||||
|
||||
* **Бэкенд:** C# .NET 8/10, Entity Framework Core (PostgreSQL). Паттерн CQRS. Механизм SignalR для мгновенных уведомлений.
|
||||
* **Фронтенд:** React, zustand (стейт-менеджер), lucide-react (иконки), framer-motion (анимации), react-easy-crop, WebRTC APIs.
|
||||
* **База данных:** PostgreSQL Server. Хранение медиа происходит прямо на сервере в папке `uploads`.
|
||||
* **Бэкенд:** C# .NET (ASP.NET Core), Entity Framework Core (PostgreSQL). Паттерн CQRS (MediatR). Механизм SignalR для доставки событий и сообщений в реальном времени.
|
||||
* **Фронтенд:** React 18, Zustand (стейт-менеджер и кэширование параметров коннекта), TailwindCSS, lucide-react (векторные иконки), framer-motion (анимации), react-easy-crop, WebRTC APIs.
|
||||
* **База данных и S3:**
|
||||
* `PostgreSQL Server` — надежное и быстрое хранение реляционных данных.
|
||||
* `MinIO` (S3) — масштабируемое и независимое объектное хранилище для медиафайлов (аватарки, файлы, вложения).
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Установка и развертывание (Docker)
|
||||
## ⚙️ Установка и развертывание (Docker Compose)
|
||||
|
||||
Проект легко разворачивается с помощью `docker-compose`.
|
||||
Проект изначально готов к `production` развертыванию через `docker-compose`.
|
||||
|
||||
1. Клонируйте репозиторий.
|
||||
2. В корневой директории найдите файл `.env`. Там задаются секреты (базы данных, JWT-секреты, TURN параметры и API ключи).
|
||||
2. В корневой директории найдите файл `.env`. Там задаются секреты (пароль к базе данных, ключи JWT, доступы MinIO, ключи TURN).
|
||||
3. Запустите стек:
|
||||
```bash
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
В результате поднимутся 3 контейнера:
|
||||
* `knot-db` — База данных Postgres.
|
||||
* `knot-server` — Основной бэкенд на порту `:5059`.
|
||||
* `knot-web` — Фронтенд (Nginx + React) на порту `:9090`.
|
||||
В результате поднимутся 4 контейнера:
|
||||
* `knot-db` — База данных PostgreSQL.
|
||||
* `knot-minio` — S3 хранилище файлов MinIO.
|
||||
* `knot-server` — Основной бэкенд на порту `:5034` / `:5059`
|
||||
* `knot-web` — Фронтенд (Nginx + React) на порту `:9090` (или на 80/443 при использовании Traefik/Dokploy).
|
||||
|
||||
*Для продакшена (Dokploy) используйте гайд из файла `DOKPLOY.md` и `DEPLOYMENT.md` в этом же или соседних файлах, указав SSL сертификаты и правильные домены.*
|
||||
*Для продакшена (Dokploy / Coolify) можно использовать стандартный подход публикации через Docker Compose, указав SSL сертификаты и настроив домены.*
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Настройка TURN-сервера (для звонков)
|
||||
## 🌐 Настройка TURN-сервера (для стабильности звонков)
|
||||
|
||||
Механизм аудио и видео звонков, а также демонстрации экрана основан на технологии **WebRTC**.
|
||||
|
||||
**Почему нужен TURN-сервер?**
|
||||
Чтобы двое (или более) участников могли передавать медиа-трафик друг другу из своих частных сетей (из-за NAT или файрволов), им нужен промежуточный ретранслятор. Если прямое подключение (STUN) не удаётся, соединение будет перенаправлено через TURN. Без него звонки между мобильными сетями и многими домашними провайдерами работать *не будут*.
|
||||
Механизм аудио и видео звонков, а также демонстрации экрана основан на технологии **WebRTC**.
|
||||
Чтобы пользователи могли свободно общаться вне зависимости от локальных ограничений сети (NAT/Firewall/мобильные вышки), необходим внешний TURN сервер-ретранслятор.
|
||||
|
||||
### Требования к TURN
|
||||
1. Вам нужен **отдельный сервер с белым (публичным) IP адресом**.
|
||||
2. В файрволе этого сервера должны быть открыты порты:
|
||||
* `3478` (TCP/UDP)
|
||||
* `5349` (TCP/UDP, если настроен TLS)
|
||||
* Желательно также открыть диапазон портов `49152 - 65535` (UDP) для прохода медиа-трафика.
|
||||
* Диапазон портов `49152 - 65535` (UDP) для прохода медиа-трафика.
|
||||
|
||||
### Развертывание Coturn (самый популярный сервер)
|
||||
Вы можете развернуть его с помощью Docker (на отдельном VPS):
|
||||
### Развертывание Coturn
|
||||
Быстрое развертывание при помощи Docker на отдельном VPS:
|
||||
```bash
|
||||
docker run -d \
|
||||
--network=host \
|
||||
@@ -89,11 +96,11 @@ docker run -d \
|
||||
```
|
||||
*Замените `USER_NAME` и `SECRET_PASSWORD` на собственные логин и пароль.*
|
||||
|
||||
### Настройка в проекте
|
||||
После установки Coturn, перейдите в файл `.env` корневого проекта Knot и задайте переменные:
|
||||
### Подключение к приложению
|
||||
После установки Coturn, перейдите в файл `.env` корневого проекта SelfHost Messenger и укажите реквизиты:
|
||||
```env
|
||||
TURN_URL=turn:ВАШ_БЕЛЫЙ_IP_ТУТ:3478
|
||||
TURN_USERNAME=USER_NAME
|
||||
TURN_PASSWORD=SECRET_PASSWORD
|
||||
```
|
||||
Также убедитесь, что ваш React клиент принимает эти параметры для создания WebRTC-соедения, после этого WebRTC звонки будут работать практически в 100% случаев.
|
||||
(Также эти параметры можно переопределить через админ-панель в будущих версиях). После этого WebRTC звонки будут работать практически в 100% клиентских конфигураций.
|
||||
|
||||
@@ -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
|
||||
115
apps/server-net/src/Host/Controllers/AdminController.cs
Normal file
115
apps/server-net/src/Host/Controllers/AdminController.cs
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
32
apps/server-net/src/Host/Controllers/ConfigController.cs
Normal file
32
apps/server-net/src/Host/Controllers/ConfigController.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
59
apps/server-net/src/Host/Controllers/FederationController.cs
Normal file
59
apps/server-net/src/Host/Controllers/FederationController.cs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function connectSocket(token: string): SocketCompat {
|
||||
accessTokenFactory: () => token
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.configureLogging(LogLevel.Information)
|
||||
.configureLogging(LogLevel.Error)
|
||||
.build();
|
||||
|
||||
// Обертка для совместимости с Socket.io API
|
||||
|
||||
704
apps/web/src/pages/AdminPage.tsx
Normal file
704
apps/web/src/pages/AdminPage.tsx
Normal 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">
|
||||
← {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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
@@ -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
BIN
klipy_err.txt
Normal file
Binary file not shown.
0
klipy_test.json
Normal file
0
klipy_test.json
Normal file
1
klipy_tst.json
Normal file
1
klipy_tst.json
Normal 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]"]}}
|
||||
Reference in New Issue
Block a user