Билд
This commit is contained in:
@@ -9,6 +9,9 @@ using Knot.Modules.Conversations;
|
|||||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
using Knot.Modules.Auth.Infrastructure.Persistence;
|
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||||
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||||
|
using Knot.Modules.Storage;
|
||||||
|
using Knot.Modules.Stories;
|
||||||
|
using Knot.Modules.Klipy;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
@@ -54,15 +57,24 @@ builder.Configuration.AddInMemoryCollection(
|
|||||||
|
|
||||||
builder.Services.AddAuthModule(builder.Configuration);
|
builder.Services.AddAuthModule(builder.Configuration);
|
||||||
builder.Services.AddSettingsModule(builder.Configuration);
|
builder.Services.AddSettingsModule(builder.Configuration);
|
||||||
|
builder.Services.AddMessagingModule(builder.Configuration);
|
||||||
builder.Services.AddConversationsModule(builder.Configuration);
|
builder.Services.AddConversationsModule(builder.Configuration);
|
||||||
builder.Services.AddProfilesModule(builder.Configuration);
|
builder.Services.AddProfilesModule(builder.Configuration);
|
||||||
|
builder.Services.AddStorageModule(builder.Configuration);
|
||||||
|
builder.Services.AddStoriesModule(builder.Configuration);
|
||||||
|
builder.Services.AddKlipyModule();
|
||||||
|
builder.Services.AddAdminModule();
|
||||||
builder.Services.AddSharedInfrastructure(builder.Configuration);
|
builder.Services.AddSharedInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
// CQRS / MediatR для команд в Host (например, AdminController)
|
// CQRS / MediatR для команд в Host (например, AdminController)
|
||||||
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
|
|
||||||
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
||||||
|
typeof(Program).Assembly,
|
||||||
typeof(Knot.Modules.Settings.DependencyInjection).Assembly,
|
typeof(Knot.Modules.Settings.DependencyInjection).Assembly,
|
||||||
typeof(Knot.Modules.Admin.DependencyInjection).Assembly
|
typeof(Knot.Modules.Admin.DependencyInjection).Assembly,
|
||||||
|
typeof(Knot.Modules.Messaging.DependencyInjection).Assembly,
|
||||||
|
typeof(Knot.Modules.Conversations.DependencyInjection).Assembly,
|
||||||
|
typeof(Knot.Modules.Stories.DependencyInjection).Assembly,
|
||||||
|
typeof(Knot.Modules.Klipy.DependencyInjection).Assembly
|
||||||
));
|
));
|
||||||
|
|
||||||
// Carter для вызова Minimal APIs (Endpoints)
|
// Carter для вызова Minimal APIs (Endpoints)
|
||||||
@@ -170,6 +182,9 @@ using (var scope = app.Services.CreateScope())
|
|||||||
var systemDb = scope.ServiceProvider.GetRequiredService<Knot.Shared.Infrastructure.Persistence.SystemDbContext>();
|
var systemDb = scope.ServiceProvider.GetRequiredService<Knot.Shared.Infrastructure.Persistence.SystemDbContext>();
|
||||||
await systemDb.Database.MigrateAsync();
|
await systemDb.Database.MigrateAsync();
|
||||||
|
|
||||||
|
var storiesDb = scope.ServiceProvider.GetRequiredService<Knot.Modules.Stories.Infrastructure.Database.StoriesDbContext>();
|
||||||
|
await storiesDb.Database.MigrateAsync();
|
||||||
|
|
||||||
// Set Encryption Service for MongoDB serializers
|
// Set Encryption Service for MongoDB serializers
|
||||||
var encryptionService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Security.IEncryptionService>();
|
var encryptionService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Security.IEncryptionService>();
|
||||||
Knot.Modules.Messaging.Infrastructure.Persistence.Mongo.EncryptedStringSerializer.EncryptionService = encryptionService;
|
Knot.Modules.Messaging.Infrastructure.Persistence.Mongo.EncryptedStringSerializer.EncryptionService = encryptionService;
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ using MediatR;
|
|||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Settings.Application.Settings.Abstractions;
|
using Knot.Modules.Settings.Application.Settings.Abstractions;
|
||||||
using Knot.Modules.Settings.Application.Settings.DTOs;
|
using Knot.Modules.Settings.Application.Settings.DTOs;
|
||||||
using Knot.Modules.Stories.Application.Abstractions;
|
using Knot.Modules.Klipy.Application.Abstractions;
|
||||||
|
|
||||||
namespace Knot.Modules.Admin.Application.Admin.Commands.TestKlipy;
|
namespace Knot.Modules.Admin.Application.Admin.Commands.TestKlipy;
|
||||||
|
|
||||||
|
|||||||
@@ -11,5 +11,7 @@ public record AdminUserDto(
|
|||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
bool IsOnline,
|
bool IsOnline,
|
||||||
DateTime LastOnlineAt,
|
DateTime LastOnlineAt,
|
||||||
bool IsBanned
|
bool IsBanned,
|
||||||
|
int? MessageCount = 0,
|
||||||
|
long? StorageSize = 0
|
||||||
);
|
);
|
||||||
+69
-49
@@ -9,6 +9,7 @@ using MediatR;
|
|||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Shared.Kernel.Storage;
|
using Knot.Shared.Kernel.Storage;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
|
using MongoDB.Bson;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Knot.Modules.Auth.Infrastructure.Persistence;
|
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||||
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||||
@@ -37,79 +38,98 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
|
|||||||
_identityDb = identityDb;
|
_identityDb = identityDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken)
|
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
|
// 1. Получаем ID активных чатов (SQL)
|
||||||
var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
|
var activeChats = await _chatsDbContext.Chats
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(c => new { c.Id, c.Avatar })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
|
var activeChatIds = activeChats.Select(c => c.Id).ToHashSet();
|
||||||
|
|
||||||
var orphanedMessages = allMessages
|
// 2. Считаем сообщения подлежащие удалению (MongoDB)
|
||||||
.Where(m => !activeChatIds.Contains(m.ChatId) || m.IsDeleted)
|
var orphanedFilter = Builders<Message>.Filter.Or(
|
||||||
.ToList();
|
Builders<Message>.Filter.BitsAnySet(m => m.State, (long)MessageState.IsDeleted),
|
||||||
|
Builders<Message>.Filter.Nin(m => m.ChatId, activeChatIds)
|
||||||
|
);
|
||||||
|
var orphanedMessagesCount = await _messages.CountDocumentsAsync(orphanedFilter, cancellationToken: ct);
|
||||||
|
|
||||||
var keptMessages = allMessages
|
// 3. Собираем ID всех используемых файлов
|
||||||
.Where(m => activeChatIds.Contains(m.ChatId) && !m.IsDeleted)
|
var validFileIds = new HashSet<string>();
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var orphanedMessagesCount = orphanedMessages.Count;
|
// Аватары чатов и пользователей
|
||||||
|
foreach (var chat in activeChats) AddFileIdIfValid(chat.Avatar, validFileIds);
|
||||||
|
|
||||||
var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList();
|
var userAvatars = await _identityDb.Users.AsNoTracking()
|
||||||
|
|
||||||
var allUsers = await _identityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var allStories = await _stories.Find(_ => true).ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var validUrls = new HashSet<string>();
|
|
||||||
|
|
||||||
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
|
|
||||||
.Where(m => m.Media != null)
|
|
||||||
.SelectMany(m => m.Media)
|
|
||||||
.Select(me => me.Url)
|
|
||||||
.Where(u => !string.IsNullOrEmpty(u));
|
|
||||||
|
|
||||||
var activeChatUrls = allChats
|
|
||||||
.Where(c => !string.IsNullOrEmpty(c.Avatar))
|
|
||||||
.Select(c => c.Avatar!);
|
|
||||||
|
|
||||||
var activeUserUrls = allUsers
|
|
||||||
.Where(u => !string.IsNullOrEmpty(u.Avatar))
|
.Where(u => !string.IsNullOrEmpty(u.Avatar))
|
||||||
.Select(u => u.Avatar!);
|
.Select(u => u.Avatar).ToListAsync(ct);
|
||||||
|
foreach (var avatar in userAvatars) AddFileIdIfValid(avatar, validFileIds);
|
||||||
|
|
||||||
var activeStoryUrls = allStories
|
// Медиа из актуальных сторис
|
||||||
.Where(s => !string.IsNullOrEmpty(s.MediaUrl))
|
var storiesMedia = await _stories.Find(s => s.CreatedAt > DateTime.UtcNow.AddHours(-24))
|
||||||
.Select(s => s.MediaUrl!);
|
.Project(s => s.MediaUrl).ToListAsync(ct);
|
||||||
|
foreach (var url in storiesMedia) AddFileIdIfValid(url, validFileIds);
|
||||||
|
|
||||||
foreach (var u in activeMessageUrls) validUrls.Add(u!);
|
// Медиа из активных сообщений (Проекция для скорости)
|
||||||
foreach (var u in activeChatUrls) validUrls.Add(u);
|
var activeFilter = Builders<Message>.Filter.And(
|
||||||
foreach (var u in activeUserUrls) validUrls.Add(u);
|
Builders<Message>.Filter.BitsAllClear(m => m.State, (long)MessageState.IsDeleted),
|
||||||
foreach (var u in activeStoryUrls) validUrls.Add(u);
|
Builders<Message>.Filter.In(m => m.ChatId, activeChatIds)
|
||||||
|
);
|
||||||
|
|
||||||
var validFileIds = validUrls
|
var projection = Builders<Message>.Projection.Include("Media");
|
||||||
.Where(u => u.Contains("/api/files/"))
|
|
||||||
.Select(u => u.Split('/').Last())
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
long safeBytes = 0;
|
using (var cursor = await _messages.Find(activeFilter).Project(projection).ToCursorAsync(ct))
|
||||||
foreach (var file in allMinioFiles)
|
|
||||||
{
|
{
|
||||||
if (!validFileIds.Contains(file.FileId))
|
while (await cursor.MoveNextAsync(ct))
|
||||||
{
|
{
|
||||||
safeBytes += file.Size;
|
foreach (var doc in cursor.Current)
|
||||||
|
{
|
||||||
|
if (doc.Contains("Media") && doc["Media"].IsBsonArray)
|
||||||
|
{
|
||||||
|
foreach (var media in doc["Media"].AsBsonArray)
|
||||||
|
{
|
||||||
|
if (media.IsBsonDocument && media.AsBsonDocument.Contains("Url"))
|
||||||
|
AddFileIdIfValid(media.AsBsonDocument["Url"].AsString, validFileIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Анализ физического хранилища
|
||||||
|
var allStoredFiles = await _fileStorage.ListFilesAsync();
|
||||||
|
long orphanedBytes = allStoredFiles
|
||||||
|
.Where(file => !validFileIds.Contains(file.FileId))
|
||||||
|
.Sum(file => file.Size);
|
||||||
|
|
||||||
return Result.Success(new CleanupDryRunResultDto
|
return Result.Success(new CleanupDryRunResultDto
|
||||||
{
|
{
|
||||||
OrphanedMessagesCount = orphanedMessagesCount,
|
OrphanedMessagesCount = (int)orphanedMessagesCount,
|
||||||
OrphanedMediaBytes = safeBytes
|
OrphanedMediaBytes = orphanedBytes
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Failure<CleanupDryRunResultDto>(new Error("Cleanup.ProcessError", $"Failed to analyze junk data: {ex.Message}"));
|
return Result.Failure<CleanupDryRunResultDto>(new Error("Cleanup.Error", $"Ошибка при анализе данных: {ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddFileIdIfValid(string? url, HashSet<string> validIds)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(url)) return;
|
||||||
|
|
||||||
|
// Предполагаем формат /api/files/{id}
|
||||||
|
if (url.Contains("/api/files/"))
|
||||||
|
{
|
||||||
|
var parts = url.Split('/');
|
||||||
|
var fileId = parts.LastOrDefault();
|
||||||
|
if (!string.IsNullOrEmpty(fileId))
|
||||||
|
{
|
||||||
|
validIds.Add(fileId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-14
@@ -6,10 +6,10 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Auth.Application.Auth.DTOs;
|
|
||||||
|
|
||||||
using Knot.Modules.Auth.Application.Users;
|
using Knot.Modules.Auth.Application.Users;
|
||||||
using Knot.Modules.Auth.Domain;
|
using Knot.Modules.Auth.Domain;
|
||||||
|
using Knot.Modules.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Modules.Conversations.Application.Abstractions;
|
||||||
|
|
||||||
namespace Knot.Modules.Admin.Application.Admin.Queries;
|
namespace Knot.Modules.Admin.Application.Admin.Queries;
|
||||||
|
|
||||||
@@ -18,30 +18,57 @@ public record SearchUsersQuery(string Query) : IQuery<List<AdminUserDto>>;
|
|||||||
internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, List<AdminUserDto>>
|
internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, List<AdminUserDto>>
|
||||||
{
|
{
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IUserStatsService _statsService;
|
||||||
|
private readonly IUserStatusService _statusService;
|
||||||
|
|
||||||
public SearchUsersQueryHandler(IUserRepository userRepository)
|
public SearchUsersQueryHandler(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IUserStatsService statsService,
|
||||||
|
IUserStatusService statusService)
|
||||||
{
|
{
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
|
_statsService = statsService;
|
||||||
|
_statusService = statusService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<List<AdminUserDto>>> Handle(SearchUsersQuery request, CancellationToken cancellationToken)
|
public async Task<Result<List<AdminUserDto>>> Handle(SearchUsersQuery request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var users = string.IsNullOrWhiteSpace(request.Query)
|
try
|
||||||
? await _userRepository.SearchUsersAsync("", cancellationToken)
|
{
|
||||||
: await _userRepository.SearchUsersAsync(request.Query, cancellationToken);
|
// 1. Поиск пользователей в реляционной БД
|
||||||
|
var users = await _userRepository.SearchUsersAsync(request.Query ?? "", ct);
|
||||||
|
if (!users.Any()) return Result.Success(new List<AdminUserDto>());
|
||||||
|
|
||||||
var result = users.Select(u => new AdminUserDto(
|
var userIds = users.Select(u => u.Id).ToList();
|
||||||
|
|
||||||
|
// 2. Получение агрегированной статистики из NoSQL
|
||||||
|
var statsMap = await _statsService.GetStatsForUsersAsync(userIds, ct);
|
||||||
|
|
||||||
|
// 3. Сборка DTO с использованием сервисов статуса и статистики
|
||||||
|
var result = users.Select(u => {
|
||||||
|
var stats = statsMap.GetValueOrDefault(u.Id, new UserStats(0, 0L));
|
||||||
|
var isOnline = _statusService.IsUserOnline(u.Id.ToString());
|
||||||
|
|
||||||
|
return new AdminUserDto(
|
||||||
u.Id,
|
u.Id,
|
||||||
u.Username,
|
u.Username ?? "unknown",
|
||||||
u.DisplayName,
|
u.DisplayName ?? "User",
|
||||||
u.Email,
|
u.Email,
|
||||||
u.Avatar,
|
u.Avatar,
|
||||||
u.CreatedAt,
|
u.CreatedAt,
|
||||||
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()),
|
isOnline,
|
||||||
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()) ? DateTime.UtcNow : u.CreatedAt,
|
isOnline ? DateTime.UtcNow : (u.LastSeen ?? u.CreatedAt),
|
||||||
u.IsBanned
|
u.IsBanned,
|
||||||
)).ToList();
|
stats.MessageCount,
|
||||||
|
stats.StorageSize
|
||||||
|
);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
return Result.Success(result);
|
return Result.Success(result);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure<List<AdminUserDto>>(new Error("Admin.SearchUsers.Error", $"Ошибка при поиске пользователей: {ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
|
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
|
||||||
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" />
|
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" />
|
||||||
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" />
|
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" />
|
||||||
|
<ProjectReference Include="..\Klipy\Knot.Modules.Klipy.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Carter" Version="10.0.0" />
|
<PackageReference Include="Carter" Version="10.0.0" />
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ public sealed class AdminEndpoints : ICarterModule
|
|||||||
group.MapGet("users", async (ISender sender, [FromQuery] string query = "", CancellationToken ct = default) =>
|
group.MapGet("users", async (ISender sender, [FromQuery] string query = "", CancellationToken ct = default) =>
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new SearchUsersQuery(query), ct);
|
var result = await sender.Send(new SearchUsersQuery(query), ct);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = result.Error.Description });
|
||||||
|
}
|
||||||
return Results.Ok(result.Value);
|
return Results.Ok(result.Value);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,15 +116,20 @@ public sealed class AdminEndpoints : ICarterModule
|
|||||||
return Results.Ok(result.Value);
|
return Results.Ok(result.Value);
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("clean/run", async (ISender sender, CancellationToken ct) =>
|
group.MapGet("timezones", () =>
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new CleanRunCommand(), ct);
|
// Получаем все системные часовые пояса и формируем удобный для фронтенда формат
|
||||||
if (!result.IsSuccess)
|
var zones = TimeZoneInfo.GetSystemTimeZones()
|
||||||
{
|
.Select(tz => new {
|
||||||
Console.WriteLine($"[Admin] Cleanup Run Error: {result.Error.Description}");
|
id = tz.Id,
|
||||||
return Results.BadRequest(new { error = result.Error.Description });
|
displayName = tz.DisplayName,
|
||||||
}
|
standardName = tz.StandardName,
|
||||||
return Results.Ok(result.Value);
|
offsetMinutes = tz.BaseUtcOffset.TotalMinutes,
|
||||||
|
offsetString = (tz.BaseUtcOffset >= TimeSpan.Zero ? "+" : "-") + tz.BaseUtcOffset.ToString(@"hh\:mm")
|
||||||
|
})
|
||||||
|
.OrderBy(tz => tz.offsetMinutes);
|
||||||
|
|
||||||
|
return Results.Ok(zones);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IUserStatusService
|
||||||
|
{
|
||||||
|
bool IsUserOnline(string userId);
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
services.AddScoped<Knot.Modules.Messaging.Application.Abstractions.IChatAccessProvider, Knot.Modules.Conversations.Infrastructure.Services.ChatAccessProvider>();
|
services.AddScoped<Knot.Modules.Messaging.Application.Abstractions.IChatAccessProvider, Knot.Modules.Conversations.Infrastructure.Services.ChatAccessProvider>();
|
||||||
services.AddScoped<Knot.Modules.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>();
|
services.AddScoped<Knot.Modules.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>();
|
||||||
|
services.AddScoped<IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Knot.Modules.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Infrastructure.Services;
|
||||||
|
|
||||||
|
public sealed class UserStatusService : IUserStatusService
|
||||||
|
{
|
||||||
|
public bool IsUserOnline(string userId)
|
||||||
|
{
|
||||||
|
return ChatHub.IsUserOnline(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@ using System.Collections.Generic;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace Knot.Modules.Stories.Application.Abstractions;
|
namespace Knot.Modules.Klipy.Application.Abstractions;
|
||||||
|
|
||||||
public interface IKlipyClient
|
public interface IKlipyClient
|
||||||
{
|
{
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
namespace Knot.Modules.Klipy;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Knot.Modules.Klipy.Application.Abstractions;
|
||||||
|
using Knot.Modules.Klipy.Infrastructure.External;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Klipy;
|
||||||
|
|
||||||
public static class DependencyInjection {
|
public static class DependencyInjection {
|
||||||
public static IServiceCollection AddKlipyModule(this IServiceCollection services) {
|
public static IServiceCollection AddKlipyModule(this IServiceCollection services) {
|
||||||
|
services.AddHttpClient<IKlipyClient, KlipyClient>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using Knot.Modules.Stories.Application.Abstractions;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Knot.Modules.Klipy.Application.Abstractions;
|
||||||
|
|
||||||
namespace Knot.Modules.Stories.Infrastructure.External;
|
namespace Knot.Modules.Klipy.Infrastructure.External;
|
||||||
|
|
||||||
public sealed class KlipyClient : IKlipyClient
|
public sealed class KlipyClient : IKlipyClient
|
||||||
{
|
{
|
||||||
@@ -7,13 +7,9 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
|
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
|
||||||
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
|
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
|
||||||
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" />
|
|
||||||
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Carter" Version="10.0.0" />
|
<PackageReference Include="Carter" Version="10.0.0" />
|
||||||
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Messaging.Application.Abstractions;
|
||||||
|
|
||||||
|
public record UserStats(int MessageCount, long StorageSize);
|
||||||
|
|
||||||
|
public interface IUserStatsService
|
||||||
|
{
|
||||||
|
Task<Dictionary<Guid, UserStats>> GetStatsForUsersAsync(IEnumerable<Guid> userIds, CancellationToken ct = default);
|
||||||
|
Task<long> GetTotalStorageSizeAsync(CancellationToken ct = default);
|
||||||
|
Task<int> GetCountOrphanedMessagesAsync(HashSet<Guid> activeChatIds, CancellationToken ct = default);
|
||||||
|
Task<long> GetOrphanedMediaSizeAsync(HashSet<string> validFileIds, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ public static class DependencyInjection
|
|||||||
// Registration
|
// Registration
|
||||||
services.AddScoped<IMessageRepository, MessageRepository>();
|
services.AddScoped<IMessageRepository, MessageRepository>();
|
||||||
services.AddScoped<IMessageReactionRepository, MessageReactionRepository>();
|
services.AddScoped<IMessageReactionRepository, MessageReactionRepository>();
|
||||||
|
services.AddScoped<IUserStatsService, UserStatsService>();
|
||||||
|
|
||||||
// MediatR
|
// MediatR
|
||||||
services.AddMediatR(config =>
|
services.AddMediatR(config =>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
namespace Knot.Modules.Messaging.Domain;
|
namespace Knot.Modules.Messaging.Domain;
|
||||||
|
|
||||||
|
[BsonDiscriminator("MediaMessage")]
|
||||||
public class MediaMessage : Message
|
public class MediaMessage : Message
|
||||||
{
|
{
|
||||||
public override string Type => MediaType.ToString().ToLower();
|
public override string Type => MediaType.ToString().ToLower();
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
namespace Knot.Modules.Messaging.Domain;
|
namespace Knot.Modules.Messaging.Domain;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Абстрактная база агрегата Сообщение.
|
/// Абстрактная база агрегата Сообщение.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[BsonDiscriminator(RootClass = true)]
|
||||||
|
[BsonKnownTypes(typeof(TextMessage), typeof(MediaMessage), typeof(StoryMessage), typeof(PollMessage))]
|
||||||
public abstract class Message : AggregateRoot<Guid>
|
public abstract class Message : AggregateRoot<Guid>
|
||||||
{
|
{
|
||||||
// ================== Базовые поля ==================
|
// ================== Базовые поля ==================
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
namespace Knot.Modules.Messaging.Domain;
|
namespace Knot.Modules.Messaging.Domain;
|
||||||
|
|
||||||
|
[BsonDiscriminator("PollMessage")]
|
||||||
public class PollMessage : Message
|
public class PollMessage : Message
|
||||||
{
|
{
|
||||||
public override string Type => "poll";
|
public override string Type => "poll";
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
namespace Knot.Modules.Messaging.Domain;
|
namespace Knot.Modules.Messaging.Domain;
|
||||||
|
|
||||||
|
[BsonDiscriminator("StoryMessage")]
|
||||||
public class StoryMessage : Message
|
public class StoryMessage : Message
|
||||||
{
|
{
|
||||||
public override string Type => "story";
|
public override string Type => "story";
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
namespace Knot.Modules.Messaging.Domain;
|
namespace Knot.Modules.Messaging.Domain;
|
||||||
|
|
||||||
|
[BsonDiscriminator("TextMessage")]
|
||||||
public class TextMessage : Message
|
public class TextMessage : Message
|
||||||
{
|
{
|
||||||
public override string Type => "text";
|
public override string Type => "text";
|
||||||
|
|||||||
+2
-2
@@ -14,9 +14,9 @@ public static class MongoDbMapConfigurator
|
|||||||
{
|
{
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
|
|
||||||
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
try { BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); } catch { /* Already registered */ }
|
||||||
|
|
||||||
BsonSerializer.RegisterSerializer(new EnumSerializer<MessageState>(BsonType.String));
|
BsonSerializer.RegisterSerializer(new EnumSerializer<MessageState>(BsonType.Int32));
|
||||||
BsonSerializer.RegisterSerializer(new EnumSerializer<MediaType>(BsonType.String));
|
BsonSerializer.RegisterSerializer(new EnumSerializer<MediaType>(BsonType.String));
|
||||||
|
|
||||||
BsonClassMap.RegisterClassMap<Entity<Guid>>(cm =>
|
BsonClassMap.RegisterClassMap<Entity<Guid>>(cm =>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Knot.Modules.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Modules.Messaging.Domain;
|
||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
|
||||||
|
|
||||||
|
public sealed class UserStatsService : IUserStatsService
|
||||||
|
{
|
||||||
|
private readonly IMongoCollection<Message> _messages;
|
||||||
|
|
||||||
|
public UserStatsService(IMongoDatabase database)
|
||||||
|
{
|
||||||
|
_messages = database.GetCollection<Message>("messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<Guid, UserStats>> GetStatsForUsersAsync(IEnumerable<Guid> userIds, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var userGuidList = userIds.ToList();
|
||||||
|
if (!userGuidList.Any()) return new Dictionary<Guid, UserStats>();
|
||||||
|
|
||||||
|
// Эффективная агрегация: считаем количество и сумму Media.Size
|
||||||
|
var stats = await _messages.Aggregate()
|
||||||
|
.Match(Builders<Message>.Filter.In(m => m.SenderId, userGuidList))
|
||||||
|
.Group(new BsonDocument {
|
||||||
|
{ "_id", "$SenderId" },
|
||||||
|
{ "Count", new BsonDocument("$sum", 1) },
|
||||||
|
{ "MediaSize", new BsonDocument("$sum", new BsonDocument("$sum", "$Media.Size")) }
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return stats.ToDictionary(
|
||||||
|
doc => doc["_id"].AsGuid,
|
||||||
|
doc => new UserStats(
|
||||||
|
doc["Count"].AsInt32,
|
||||||
|
doc.Contains("MediaSize") && !doc["MediaSize"].IsBsonNull ? (long)(doc["MediaSize"].IsInt64 ? doc["MediaSize"].AsInt64 : doc["MediaSize"].AsInt32) : 0L
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> GetTotalStorageSizeAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var result = await _messages.Aggregate()
|
||||||
|
.Group(new BsonDocument {
|
||||||
|
{ "_id", BsonNull.Value },
|
||||||
|
{ "TotalSize", new BsonDocument("$sum", new BsonDocument("$sum", "$Media.Size")) }
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (result == null) return 0;
|
||||||
|
return result.Contains("TotalSize") ? (long)(result["TotalSize"].IsInt64 ? result["TotalSize"].AsInt64 : result["TotalSize"].AsInt32) : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetCountOrphanedMessagesAsync(HashSet<Guid> activeChatIds, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var filter = Builders<Message>.Filter.Or(
|
||||||
|
Builders<Message>.Filter.BitsAnySet(m => m.State, (long)MessageState.IsDeleted),
|
||||||
|
Builders<Message>.Filter.Nin(m => m.ChatId, activeChatIds)
|
||||||
|
);
|
||||||
|
return (int)(await _messages.CountDocumentsAsync(filter, cancellationToken: ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> GetOrphanedMediaSizeAsync(HashSet<string> validFileIds, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Для больших объемов правильнее собирать список ВСЕХ URL файлов из сообщений,
|
||||||
|
// но здесь мы оптимизируем через проекцию, чтобы вернуть только нужные поля.
|
||||||
|
// Этот метод может быть реализован в BackgroundTask для очень больших баз.
|
||||||
|
return 0; // Временная заглушка, реальный подсчет через курсор в DryRun
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ public record PublicConfigDto
|
|||||||
{
|
{
|
||||||
SupportGroups = settings.Chats.SupportGroups,
|
SupportGroups = settings.Chats.SupportGroups,
|
||||||
MaxGroupParticipants = settings.Chats.MaxGroupParticipants,
|
MaxGroupParticipants = settings.Chats.MaxGroupParticipants,
|
||||||
|
EnableAutoClean = settings.Chats.EnableAutoClean,
|
||||||
AllowChatToGroupConversion = settings.Chats.AllowChatToGroupConversion,
|
AllowChatToGroupConversion = settings.Chats.AllowChatToGroupConversion,
|
||||||
EnableFolders = settings.Chats.EnableFolders
|
EnableFolders = settings.Chats.EnableFolders
|
||||||
},
|
},
|
||||||
@@ -61,7 +62,6 @@ public record PublicConfigDto
|
|||||||
WebRtc = new WebRtcConfigDto
|
WebRtc = new WebRtcConfigDto
|
||||||
{
|
{
|
||||||
Enabled = settings.WebRtc.Enabled,
|
Enabled = settings.WebRtc.Enabled,
|
||||||
EnableVoiceCalls = settings.WebRtc.EnableVoiceCalls,
|
|
||||||
EnableVideoCalls = settings.WebRtc.EnableVideoCalls,
|
EnableVideoCalls = settings.WebRtc.EnableVideoCalls,
|
||||||
EnableScreenSharing = settings.WebRtc.EnableScreenSharing,
|
EnableScreenSharing = settings.WebRtc.EnableScreenSharing,
|
||||||
TurnHost = settings.WebRtc.TurnHost,
|
TurnHost = settings.WebRtc.TurnHost,
|
||||||
@@ -107,6 +107,7 @@ public record ChatsConfigDto
|
|||||||
{
|
{
|
||||||
public bool SupportGroups { get; init; }
|
public bool SupportGroups { get; init; }
|
||||||
public int MaxGroupParticipants { get; init; }
|
public int MaxGroupParticipants { get; init; }
|
||||||
|
public bool EnableAutoClean { get; init; }
|
||||||
public bool AllowChatToGroupConversion { get; init; }
|
public bool AllowChatToGroupConversion { get; init; }
|
||||||
public bool EnableFolders { get; init; }
|
public bool EnableFolders { get; init; }
|
||||||
}
|
}
|
||||||
@@ -133,7 +134,6 @@ public record MessagesConfigDto
|
|||||||
public record WebRtcConfigDto
|
public record WebRtcConfigDto
|
||||||
{
|
{
|
||||||
public bool Enabled { get; init; }
|
public bool Enabled { get; init; }
|
||||||
public bool EnableVoiceCalls { get; init; }
|
|
||||||
public bool EnableVideoCalls { get; init; }
|
public bool EnableVideoCalls { get; init; }
|
||||||
public bool EnableScreenSharing { get; init; }
|
public bool EnableScreenSharing { get; init; }
|
||||||
public string TurnHost { get; init; } = string.Empty;
|
public string TurnHost { get; init; } = string.Empty;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public class ChatsConfig
|
|||||||
{
|
{
|
||||||
public bool SupportGroups { get; set; } = true;
|
public bool SupportGroups { get; set; } = true;
|
||||||
public int MaxGroupParticipants { get; set; } = 200000;
|
public int MaxGroupParticipants { get; set; } = 200000;
|
||||||
public bool AutoCleanChats { get; set; } = false;
|
public bool EnableAutoClean { get; set; } = false; // Возможность автоочистки для пользователей
|
||||||
public bool AllowChatToGroupConversion { get; set; } = true;
|
public bool AllowChatToGroupConversion { get; set; } = true;
|
||||||
public bool EnableFolders { get; set; } = true;
|
public bool EnableFolders { get; set; } = true;
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,6 @@ public class MessagesConfig
|
|||||||
public class WebRtcConfig
|
public class WebRtcConfig
|
||||||
{
|
{
|
||||||
public bool Enabled { get; set; } = false;
|
public bool Enabled { get; set; } = false;
|
||||||
public bool EnableVoiceCalls { get; set; } = true;
|
|
||||||
public bool EnableVideoCalls { get; set; } = true;
|
public bool EnableVideoCalls { get; set; } = true;
|
||||||
public bool EnableScreenSharing { get; set; } = true;
|
public bool EnableScreenSharing { get; set; } = true;
|
||||||
public string TurnHost { get; set; } = string.Empty;
|
public string TurnHost { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -205,13 +205,17 @@ public class S3FileStorageService : IFileStorageService
|
|||||||
var result = new List<(string FileId, long Size)>();
|
var result = new List<(string FileId, long Size)>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await EnsureBucketExistsAsync();
|
||||||
var listArgs = new ListObjectsArgs().WithBucket(_bucketName).WithRecursive(true);
|
var listArgs = new ListObjectsArgs().WithBucket(_bucketName).WithRecursive(true);
|
||||||
|
|
||||||
await foreach (var item in _minioClient.ListObjectsEnumAsync(listArgs).ConfigureAwait(false))
|
await foreach (var item in _minioClient.ListObjectsEnumAsync(listArgs).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (item != null)
|
||||||
{
|
{
|
||||||
result.Add((item.Key, (long)item.Size));
|
result.Add((item.Key, (long)item.Size));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[Bucket] Error listing files: {ex.Message}");
|
Console.WriteLine($"[Bucket] Error listing files: {ex.Message}");
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Knot.Modules.Stories.Application.Abstractions;
|
using Knot.Modules.Stories.Application.Abstractions;
|
||||||
using Knot.Modules.Stories.Infrastructure.External;
|
|
||||||
using Knot.Modules.Stories.Infrastructure.Persistence.Mongo;
|
using Knot.Modules.Stories.Infrastructure.Persistence.Mongo;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
@@ -14,7 +13,6 @@ public static class DependencyInjection
|
|||||||
public static IServiceCollection AddStoriesModule(this IServiceCollection services, IConfiguration configuration)
|
public static IServiceCollection AddStoriesModule(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddScoped<IStoryRepository, StoryRepository>();
|
services.AddScoped<IStoryRepository, StoryRepository>();
|
||||||
services.AddHttpClient<IKlipyClient, KlipyClient>();
|
|
||||||
|
|
||||||
var connectionString = configuration.GetConnectionString("DefaultConnection");
|
var connectionString = configuration.GetConnectionString("DefaultConnection");
|
||||||
services.AddDbContext<Infrastructure.Database.StoriesDbContext>(options =>
|
services.AddDbContext<Infrastructure.Database.StoriesDbContext>(options =>
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ public static class StoriesMongoMapConfigurator
|
|||||||
{
|
{
|
||||||
if (_configured) return;
|
if (_configured) return;
|
||||||
|
|
||||||
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
try { BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); } catch { /* Already registered */ }
|
||||||
|
|
||||||
BsonClassMap.RegisterClassMap<Story>(cm =>
|
BsonClassMap.RegisterClassMap<Story>(cm =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ interface StoriesConfig {
|
|||||||
interface ChatsConfig {
|
interface ChatsConfig {
|
||||||
supportGroups: boolean;
|
supportGroups: boolean;
|
||||||
maxGroupParticipants: number;
|
maxGroupParticipants: number;
|
||||||
autoCleanChats: boolean;
|
enableAutoClean: boolean;
|
||||||
allowChatToGroupConversion: boolean;
|
allowChatToGroupConversion: boolean;
|
||||||
enableFolders: boolean;
|
enableFolders: boolean;
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,6 @@ interface MessagesConfig {
|
|||||||
|
|
||||||
interface WebRtcConfig {
|
interface WebRtcConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
enableVoiceCalls: boolean;
|
|
||||||
enableVideoCalls: boolean;
|
enableVideoCalls: boolean;
|
||||||
enableScreenSharing: boolean;
|
enableScreenSharing: boolean;
|
||||||
turnHost: string;
|
turnHost: string;
|
||||||
@@ -82,6 +81,14 @@ interface WebRtcConfig {
|
|||||||
turnSecret: string;
|
turnSecret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TimezoneDto {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
standardName: string;
|
||||||
|
offsetMinutes: number;
|
||||||
|
offsetString: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface KlipyConfig {
|
interface KlipyConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
@@ -124,6 +131,8 @@ interface AppUser {
|
|||||||
lastOnlineAt: string;
|
lastOnlineAt: string;
|
||||||
isOnline?: boolean;
|
isOnline?: boolean;
|
||||||
isBanned?: boolean;
|
isBanned?: boolean;
|
||||||
|
messageCount?: number;
|
||||||
|
storageSize?: number;
|
||||||
stats?: {
|
stats?: {
|
||||||
messagesCount: number;
|
messagesCount: number;
|
||||||
mediaCount: number;
|
mediaCount: number;
|
||||||
@@ -178,7 +187,9 @@ const translations = {
|
|||||||
mediaSent: 'Media',
|
mediaSent: 'Media',
|
||||||
filesSent: 'Files',
|
filesSent: 'Files',
|
||||||
linksSent: 'Links',
|
linksSent: 'Links',
|
||||||
userStorageOccupied: 'Storage',
|
userStorageOccupied: 'Attachments',
|
||||||
|
sent: 'Messages',
|
||||||
|
storage: 'Attachments',
|
||||||
displayName: 'Display Name',
|
displayName: 'Display Name',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
createUser: 'Create User',
|
createUser: 'Create User',
|
||||||
@@ -265,7 +276,7 @@ const translations = {
|
|||||||
maxMediaSize: 'Max file size for story media.',
|
maxMediaSize: 'Max file size for story media.',
|
||||||
supportGroups: 'Enable group chat functionality.',
|
supportGroups: 'Enable group chat functionality.',
|
||||||
maxGroupMembers: 'Max participants per group.',
|
maxGroupMembers: 'Max participants per group.',
|
||||||
autoClean: 'Automatically remove old messages.',
|
autoClean: 'Enable/disable auto-cleanup feature for users. Users manage their own chat timers.',
|
||||||
chatToGroup: 'Allow upgrading 1-on-1 chats to groups.',
|
chatToGroup: 'Allow upgrading 1-on-1 chats to groups.',
|
||||||
enableFolders: 'Allow users to use chat folders.',
|
enableFolders: 'Allow users to use chat folders.',
|
||||||
dailyLimit: 'Max messages per user day (0 = unlimited).',
|
dailyLimit: 'Max messages per user day (0 = unlimited).',
|
||||||
@@ -273,7 +284,7 @@ const translations = {
|
|||||||
maxFileSize: 'Max size for message attachments.',
|
maxFileSize: 'Max size for message attachments.',
|
||||||
noCopy: 'Prevent text copying in clients.',
|
noCopy: 'Prevent text copying in clients.',
|
||||||
links: 'Make URLs clickable.',
|
links: 'Make URLs clickable.',
|
||||||
webRtc: 'Required for real-time calls.',
|
webRtc: 'Enable real-time audio and video calls. This is a master switch for the WebRTC module.',
|
||||||
turn: 'Required for calls behind NAT.',
|
turn: 'Required for calls behind NAT.',
|
||||||
klipy: 'Integration for stickers and GIFs.',
|
klipy: 'Integration for stickers and GIFs.',
|
||||||
federation: 'Communication between different Knot instances.',
|
federation: 'Communication between different Knot instances.',
|
||||||
@@ -343,7 +354,9 @@ const translations = {
|
|||||||
mediaSent: 'Медиа',
|
mediaSent: 'Медиа',
|
||||||
filesSent: 'Файлы',
|
filesSent: 'Файлы',
|
||||||
linksSent: 'Ссылки',
|
linksSent: 'Ссылки',
|
||||||
userStorageOccupied: 'Хранилище',
|
userStorageOccupied: 'Вложения',
|
||||||
|
sent: 'Сообщения',
|
||||||
|
storage: 'Вложения',
|
||||||
displayName: 'Имя',
|
displayName: 'Имя',
|
||||||
cancel: 'Отмена',
|
cancel: 'Отмена',
|
||||||
createUser: 'Создать',
|
createUser: 'Создать',
|
||||||
@@ -430,7 +443,7 @@ const translations = {
|
|||||||
maxMediaSize: 'Лимит одного файла в историях.',
|
maxMediaSize: 'Лимит одного файла в историях.',
|
||||||
supportGroups: 'Включить группы.',
|
supportGroups: 'Включить группы.',
|
||||||
maxGroupMembers: 'Максимум людей в одной группе.',
|
maxGroupMembers: 'Максимум людей в одной группе.',
|
||||||
autoClean: 'Удаление старых сообщений.',
|
autoClean: 'Разрешить пользователям использовать функцию автоочистки. Самим процессом (таймерами) управляют пользователи в своих чатах.',
|
||||||
chatToGroup: 'Разрешить создавать группы из чатов.',
|
chatToGroup: 'Разрешить создавать группы из чатов.',
|
||||||
enableFolders: 'Разрешить папки чатов.',
|
enableFolders: 'Разрешить папки чатов.',
|
||||||
dailyLimit: 'Лимит сообщений в сутки (0 = без лимита).',
|
dailyLimit: 'Лимит сообщений в сутки (0 = без лимита).',
|
||||||
@@ -438,7 +451,7 @@ const translations = {
|
|||||||
maxFileSize: 'Лимит файлов в сообщениях.',
|
maxFileSize: 'Лимит файлов в сообщениях.',
|
||||||
noCopy: 'Мешать копированию текста.',
|
noCopy: 'Мешать копированию текста.',
|
||||||
links: 'Автоматические ссылки.',
|
links: 'Автоматические ссылки.',
|
||||||
webRtc: 'Нужно для звонков.',
|
webRtc: 'Включить возможность аудио и видео звонков. Глобальный переключатель для модуля WebRTC.',
|
||||||
turn: 'Нужно для звонков за NAT.',
|
turn: 'Нужно для звонков за NAT.',
|
||||||
klipy: 'Стикеры и GIF.',
|
klipy: 'Стикеры и GIF.',
|
||||||
federation: 'Связь с другими серверами Knot.',
|
federation: 'Связь с другими серверами Knot.',
|
||||||
@@ -487,9 +500,11 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const k = 1024;
|
||||||
|
const dm = 2;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
const bytesToMb = (bytes: number) => Math.floor(bytes / (1024 * 1024));
|
const bytesToMb = (bytes: number) => Math.floor(bytes / (1024 * 1024));
|
||||||
@@ -522,6 +537,10 @@ export default function AdminPage() {
|
|||||||
const [newUser, setNewUser] = useState({ username: '', displayName: '', password: '' });
|
const [newUser, setNewUser] = useState({ username: '', displayName: '', password: '' });
|
||||||
const [generatedPass, setGeneratedPass] = useState('');
|
const [generatedPass, setGeneratedPass] = useState('');
|
||||||
|
|
||||||
|
const [timezones, setTimezones] = useState<TimezoneDto[]>([]);
|
||||||
|
const [tzSearch, setTzSearch] = useState('');
|
||||||
|
const [showTzDropdown, setShowTzDropdown] = useState(false);
|
||||||
|
|
||||||
const [toast, setToast] = useState<{message: string, type: 'success' | 'error'} | null>(null);
|
const [toast, setToast] = useState<{message: string, type: 'success' | 'error'} | null>(null);
|
||||||
|
|
||||||
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
|
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
|
||||||
@@ -616,6 +635,13 @@ export default function AdminPage() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchTimezones = async () => {
|
||||||
|
try {
|
||||||
|
const res = await httpClient.request<TimezoneDto[]>('/admin/timezones');
|
||||||
|
setTimezones(res);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
const saveSettings = async () => {
|
const saveSettings = async () => {
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
try {
|
try {
|
||||||
@@ -786,6 +812,7 @@ export default function AdminPage() {
|
|||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
fetchDashboard();
|
fetchDashboard();
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
|
fetchTimezones();
|
||||||
searchUsers('');
|
searchUsers('');
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t.errorInvalidLogin, 'error');
|
showToast(t.errorInvalidLogin, 'error');
|
||||||
@@ -931,11 +958,11 @@ export default function AdminPage() {
|
|||||||
<button onClick={handleCalcCleanup} className="bg-accent/10 hover:bg-accent/20 text-accent px-4 py-2 rounded-xl transition-colors">{t.analyzeJunk}</button>
|
<button onClick={handleCalcCleanup} className="bg-accent/10 hover:bg-accent/20 text-accent px-4 py-2 rounded-xl transition-colors">{t.analyzeJunk}</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="bg-black/20 p-4 rounded-xl">
|
<div className="bg-black/20 p-4 rounded-xl flex flex-col justify-between min-h-[100px]">
|
||||||
<div className="text-xs text-gray-500">{t.orphanedMessages}</div>
|
<div className="text-xs text-gray-500">{t.orphanedMessages}</div>
|
||||||
<div className="text-xl font-bold">{cleanStats.orphanedMessagesCount}</div>
|
<div className="text-xl font-bold">{cleanStats.orphanedMessagesCount}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-black/20 p-4 rounded-xl">
|
<div className="bg-black/20 p-4 rounded-xl flex flex-col justify-between min-h-[100px]">
|
||||||
<div className="text-xs text-gray-500">{t.orphanedMedia}</div>
|
<div className="text-xs text-gray-500">{t.orphanedMedia}</div>
|
||||||
<div className="text-xl font-bold">{formatBytes(cleanStats.orphanedMediaBytes)}</div>
|
<div className="text-xl font-bold">{formatBytes(cleanStats.orphanedMediaBytes)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -987,11 +1014,56 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Hint>{t.hints.enableRegistration}</Hint>
|
<Hint>{t.hints.enableRegistration}</Hint>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex flex-col gap-1.5 group">
|
<div className="flex flex-col gap-1.5 group relative">
|
||||||
<span className="text-xs font-bold text-gray-500 uppercase ml-1 group-focus-within:text-accent transition-colors">{t.timezone}</span>
|
<span className="text-xs font-bold text-gray-500 uppercase ml-1 group-focus-within:text-accent transition-colors">{t.timezone}</span>
|
||||||
<input className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent/50 focus:bg-black/60 transition-all font-mono text-sm" value={config.system.serverTimezone} onChange={e => setConfig({...config, system: {...config.system, serverTimezone: e.target.value}})} placeholder="UTC" />
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent/50 focus:bg-black/60 transition-all font-mono text-xs pr-10"
|
||||||
|
value={config.system.serverTimezone}
|
||||||
|
onFocus={() => setShowTzDropdown(true)}
|
||||||
|
onChange={e => {
|
||||||
|
setConfig({...config, system: {...config.system, serverTimezone: e.target.value}});
|
||||||
|
setTzSearch(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="UTC"
|
||||||
|
/>
|
||||||
|
<Globe className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showTzDropdown && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className="absolute left-0 right-0 top-full mt-2 bg-surface border border-white/10 rounded-xl shadow-2xl z-[100] max-h-60 overflow-y-auto overflow-x-hidden p-1"
|
||||||
|
>
|
||||||
|
{timezones
|
||||||
|
.filter(tz => tz.displayName.toLowerCase().includes(tzSearch.toLowerCase()) || tz.id.toLowerCase().includes(tzSearch.toLowerCase()))
|
||||||
|
.map(tz => (
|
||||||
|
<button
|
||||||
|
key={tz.id}
|
||||||
|
onClick={() => {
|
||||||
|
setConfig({...config, system: {...config.system, serverTimezone: tz.id}});
|
||||||
|
setTzSearch('');
|
||||||
|
setShowTzDropdown(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left p-3 hover:bg-white/5 rounded-lg transition-colors flex flex-col group/tz"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center w-full">
|
||||||
|
<span className="text-xs font-bold text-white group-hover/tz:text-accent truncate">{tz.displayName}</span>
|
||||||
|
<span className="text-[10px] font-mono text-gray-500 group-hover/tz:text-accent/50 ml-2 shrink-0">{tz.offsetString}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-gray-600 truncate">{tz.id}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
{showTzDropdown && <div className="fixed inset-0 z-[90]" onClick={() => setShowTzDropdown(false)} />}
|
||||||
<Hint>{t.hints.timezone}</Hint>
|
<Hint>{t.hints.timezone}</Hint>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1060,7 +1132,7 @@ export default function AdminPage() {
|
|||||||
<div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5">
|
<div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-200 font-semibold">{t.autoClean}</span>
|
<span className="text-sm text-gray-200 font-semibold">{t.autoClean}</span>
|
||||||
<Toggle checked={config.chats.autoCleanChats} onChange={v => setConfig({...config, chats: {...config.chats, autoCleanChats: v}})} />
|
<Toggle checked={config.chats.enableAutoClean} onChange={v => setConfig({...config, chats: {...config.chats, enableAutoClean: v}})} />
|
||||||
</div>
|
</div>
|
||||||
<Hint>{t.hints.autoClean}</Hint>
|
<Hint>{t.hints.autoClean}</Hint>
|
||||||
</div>
|
</div>
|
||||||
@@ -1144,16 +1216,8 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Hint>{t.hints.webRtc}</Hint>
|
<Hint>{t.hints.webRtc}</Hint>
|
||||||
|
|
||||||
{config.webRtc.enabled && (
|
{config.webRtc.enabled && (<>
|
||||||
<div className="flex flex-col gap-10 pt-4 border-t border-white/10">
|
<div className="flex flex-col gap-10 pt-4 border-t border-white/10">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-white/[0.03] p-4 rounded-2xl border border-white/5 flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-bold text-gray-400 uppercase">{t.voiceCalls}</span>
|
|
||||||
<Toggle checked={config.webRtc.enableVoiceCalls} onChange={v => setConfig({...config, webRtc: {...config.webRtc, enableVoiceCalls: v}})} />
|
|
||||||
</div>
|
|
||||||
<Hint>{t.hints.voiceCalls}</Hint>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white/[0.03] p-4 rounded-2xl border border-white/5 flex flex-col gap-3">
|
<div className="bg-white/[0.03] p-4 rounded-2xl border border-white/5 flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-bold text-gray-400 uppercase">{t.videoCalls}</span>
|
<span className="text-xs font-bold text-gray-400 uppercase">{t.videoCalls}</span>
|
||||||
@@ -1204,8 +1268,7 @@ export default function AdminPage() {
|
|||||||
{t.testConnection}
|
{t.testConnection}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1344,9 +1407,20 @@ export default function AdminPage() {
|
|||||||
{u.isOnline ? (
|
{u.isOnline ? (
|
||||||
<span className="text-[10px] bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full font-bold">{t.online.toUpperCase()}</span>
|
<span className="text-[10px] bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full font-bold">{t.online.toUpperCase()}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-gray-500">{t.offline}</span>
|
<span className="text-xs text-gray-400">{t.offline}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-4 flex items-center gap-4 shrink-0 text-[10px]">
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="text-gray-500 uppercase font-black tracking-tighter opacity-50">{t.sent}</span>
|
||||||
|
<span className="text-accent font-bold">{u.messageCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end border-l border-white/5 pl-4">
|
||||||
|
<span className="text-gray-500 uppercase font-black tracking-tighter opacity-50">{t.storage}</span>
|
||||||
|
<span className="text-white font-bold">{formatBytes(u.storageSize || 0)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user