Reorganize root folder structure: Remove apps layer layer

This commit is contained in:
Халимов Рустам
2026-03-19 22:22:45 +03:00
parent fb252f9d87
commit 11ebbc853b
296 changed files with 139 additions and 139 deletions
@@ -0,0 +1,100 @@
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
namespace Host.Application.Admin.Commands;
public record CleanRunCommand(IFileStorageService FileStorage, IdentityDbContext IdentityDb) : ICommand<MessageResponse>;
internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand, MessageResponse>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
{
_chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("Messages");
}
public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken)
{
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
var orphanMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId))
.ToList();
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId))
.ToList();
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
var allUsers = await request.IdentityDb.Users.AsNoTracking().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))
.Select(u => u.Avatar!);
foreach (var u in activeMessageUrls)
{
validUrls.Add(u!);
}
foreach (var u in activeChatUrls)
{
validUrls.Add(u);
}
foreach (var u in activeUserUrls)
{
validUrls.Add(u);
}
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
foreach (var file in allMinioFiles)
{
if (!validFileIds.Contains(file.FileId))
{
await request.FileStorage.DeleteFileAsync(file.FileId);
}
}
if (orphanMessages.Any())
{
var orphanIds = orphanMessages.Select(m => m.Id).ToList();
var filter = Builders<Message>.Filter.In(m => m.Id, orphanIds);
await _messages.DeleteManyAsync(filter, cancellationToken);
}
return Result.Success(new MessageResponse("Cleanup completed successfully"));
}
}
@@ -0,0 +1,43 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Host.Models;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Application.Abstractions;
namespace Host.Application.Admin.Commands;
public record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand<SuccessResponse>;
internal sealed class ResetUserPasswordCommandHandler : ICommandHandler<ResetUserPasswordCommand, SuccessResponse>
{
private readonly IUserRepository _userRepository;
private readonly IIdentityUnitOfWork _identityUnitOfWork;
public ResetUserPasswordCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork identityUnitOfWork)
{
_userRepository = userRepository;
_identityUnitOfWork = identityUnitOfWork;
}
public async Task<Result<SuccessResponse>> Handle(ResetUserPasswordCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<SuccessResponse>(IdentityErrors.UserNotFound);
}
if (string.IsNullOrWhiteSpace(request.NewPassword))
return Result.Failure<SuccessResponse>(DomainErrors.InvalidPassword);
var hash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword);
user.ChangePassword(hash);
await _identityUnitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(new SuccessResponse(true));
}
}
@@ -0,0 +1,26 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
namespace Host.Application.Admin.Commands;
public record UpdateSettingsCommand(SystemSettingsDto Settings) : ICommand<SystemSettingsDto>;
internal sealed class UpdateSettingsCommandHandler : ICommandHandler<UpdateSettingsCommand, SystemSettingsDto>
{
private readonly ISettingsService _settingsService;
public UpdateSettingsCommandHandler(ISettingsService settingsService)
{
_settingsService = settingsService;
}
public async Task<Result<SystemSettingsDto>> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken)
{
await _settingsService.UpdateSettingsAsync(request.Settings, cancellationToken);
return Result.Success(request.Settings);
}
}
@@ -0,0 +1,101 @@
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
using Host.Models;
namespace Host.Application.Admin.Queries;
public record CleanDryRunQuery(IFileStorageService FileStorage, IdentityDbContext IdentityDb) : IQuery<CleanupDryRunResultDto>;
internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery, CleanupDryRunResultDto>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
{
_chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("Messages");
}
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken)
{
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
var orphanedMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId))
.ToList();
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId))
.ToList();
var orphanedMessagesCount = orphanedMessages.Count;
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
var allUsers = await request.IdentityDb.Users.AsNoTracking().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))
.Select(u => u.Avatar!);
foreach (var u in activeMessageUrls)
{
validUrls.Add(u!);
}
foreach (var u in activeChatUrls)
{
validUrls.Add(u);
}
foreach (var u in activeUserUrls)
{
validUrls.Add(u);
}
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
long safeBytes = 0;
foreach (var file in allMinioFiles)
{
if (!validFileIds.Contains(file.FileId))
{
safeBytes += file.Size;
}
}
return Result.Success(new CleanupDryRunResultDto
{
OrphanedMessagesCount = orphanedMessagesCount,
OrphanedMediaBytes = safeBytes
});
}
}
@@ -0,0 +1,25 @@
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Services;
namespace Host.Application.Admin.Queries;
public record GetDashboardStatsQuery() : IQuery<DashboardStatsDto>;
internal sealed class GetDashboardStatsQueryHandler : IQueryHandler<GetDashboardStatsQuery, DashboardStatsDto>
{
private readonly IStatisticsService _statisticsService;
public GetDashboardStatsQueryHandler(IStatisticsService statisticsService)
{
_statisticsService = statisticsService;
}
public async Task<Result<DashboardStatsDto>> Handle(GetDashboardStatsQuery request, CancellationToken cancellationToken)
{
var stats = await _statisticsService.GetDashboardStatsAsync(cancellationToken);
return Result.Success(stats);
}
}
@@ -0,0 +1,26 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
namespace Host.Application.Admin.Queries;
public record GetSettingsQuery() : IQuery<SystemSettingsDto>;
internal sealed class GetSettingsQueryHandler : IQueryHandler<GetSettingsQuery, SystemSettingsDto>
{
private readonly ISettingsService _settingsService;
public GetSettingsQueryHandler(ISettingsService settingsService)
{
_settingsService = settingsService;
}
public async Task<Result<SystemSettingsDto>> Handle(GetSettingsQuery request, CancellationToken cancellationToken)
{
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
return Result.Success(settings);
}
}
@@ -0,0 +1,72 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Host.Models;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Chats.Domain;
using MongoDB.Driver;
namespace Host.Application.Admin.Queries;
public record GetUserDetailsQuery(Guid UserId) : IQuery<AdminUserDetailsDto>;
internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQuery, AdminUserDetailsDto>
{
private readonly IUserRepository _userRepository;
private readonly IMongoCollection<Message> _messages;
public GetUserDetailsQueryHandler(IUserRepository userRepository, IMongoDatabase mongoDatabase)
{
_userRepository = userRepository;
_messages = mongoDatabase.GetCollection<Message>("Messages");
}
public async Task<Result<AdminUserDetailsDto>> Handle(GetUserDetailsQuery request, CancellationToken cancellationToken)
{
var targetUser = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (targetUser == null)
{
return Result.Failure<AdminUserDetailsDto>(IdentityErrors.UserNotFound);
}
var filter = Builders<Message>.Filter.Eq(m => m.SenderId, request.UserId);
var userMessages = await _messages.Find(filter).ToListAsync(cancellationToken);
var messagesCount = userMessages.Count;
var allUserMedia = userMessages.OfType<MediaMessage>().SelectMany(m => m.Media).ToList();
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);
var userContents = userMessages.OfType<TextMessage>().Select(m => m.Content)
.Concat(userMessages.OfType<MediaMessage>().Where(m => m.Caption != null).Select(m => m.Caption))
.ToList();
var linksCount = userContents.Count(c => !string.IsNullOrEmpty(c) && c.Contains("http"));
var result = new AdminUserDetailsDto(
targetUser.Id,
targetUser.Username,
targetUser.DisplayName,
targetUser.Bio,
targetUser.Avatar,
targetUser.CreatedAt,
Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()),
Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()) ? DateTime.UtcNow : targetUser.CreatedAt,
new AdminUserStatsDto(
messagesCount,
mediaCount,
filesCount,
linksCount,
storageUsed
)
);
return Result.Success(result);
}
}
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Host.Models;
using Knot.Modules.Identity.Domain;
namespace Host.Application.Admin.Queries;
public record SearchUsersQuery(string Query) : IQuery<List<AdminUserDto>>;
internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, List<AdminUserDto>>
{
private readonly IUserRepository _userRepository;
public SearchUsersQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<Result<List<AdminUserDto>>> Handle(SearchUsersQuery request, CancellationToken cancellationToken)
{
var users = string.IsNullOrWhiteSpace(request.Query)
? await _userRepository.SearchUsersAsync("", cancellationToken)
: await _userRepository.SearchUsersAsync(request.Query, cancellationToken);
var result = users.Select(u => new AdminUserDto(
u.Id,
u.Username,
u.DisplayName,
u.Email,
u.Avatar,
u.CreatedAt,
Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()),
Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()) ? DateTime.UtcNow : u.CreatedAt
)).ToList();
return Result.Success(result);
}
}
@@ -0,0 +1,25 @@
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using MediatR;
using Host.Models;
using System.Threading;
using System.Threading.Tasks;
namespace Host.Application.Config.Queries;
public record GetPublicConfigQuery : IQuery<PublicConfigDto>;
internal sealed class GetPublicConfigQueryHandler : IQueryHandler<GetPublicConfigQuery, PublicConfigDto>
{
private readonly ISettingsService _settings;
public GetPublicConfigQueryHandler(ISettingsService settings)
{
_settings = settings;
}
public Task<Result<PublicConfigDto>> Handle(GetPublicConfigQuery request, CancellationToken cancellationToken)
{
return Task.FromResult(Result.Success(PublicConfigDto.FromSettings(_settings.Current)));
}
}
@@ -0,0 +1,52 @@
using Host.Application.Stories;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Shared.Kernel.Constants;
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
namespace Host.Application.Federation.Commands;
public record HandshakeRequest(string Domain, string PublicKey);
public record HandshakeResponse(string Domain, string PublicKey, string Status);
public record HandshakeFederationCommand(HandshakeRequest Request) : ICommand<HandshakeResponse>;
internal sealed class HandshakeFederationCommandHandler : ICommandHandler<HandshakeFederationCommand, HandshakeResponse>
{
private readonly ISettingsService _settings;
public HandshakeFederationCommandHandler(ISettingsService settings)
{
_settings = settings;
}
public Task<Result<HandshakeResponse>> Handle(HandshakeFederationCommand request, CancellationToken cancellationToken)
{
var conf = _settings.Current;
if (!conf.EnableConfederation)
return Task.FromResult(Result.Failure<HandshakeResponse>(new Error(Errors.DisabledByAdmin, "Federation is disabled")));
if (string.IsNullOrEmpty(request.Request.Domain) || string.IsNullOrEmpty(request.Request.PublicKey))
return Task.FromResult(Result.Failure<HandshakeResponse>(DomainErrors.RequestInvalid));
var allowedList = conf.AllowedDomains?.Select(d => d.Trim().ToLower()).ToList() ?? new System.Collections.Generic.List<string>();
if (!allowedList.Contains(request.Request.Domain.ToLowerInvariant()))
return Task.FromResult(Result.Failure<HandshakeResponse>(StoryErrors.Unauthorized));
using var rsa = RSA.Create(2048);
var selfPublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey());
var response = new HandshakeResponse(
Environment.GetEnvironmentVariable("DOMAIN") ?? "knot.local",
selfPublicKey,
"Accepted"
);
return Task.FromResult(Result.Success(response));
}
}
@@ -0,0 +1,63 @@
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Shared.Kernel.Constants;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
namespace Host.Application.Klipy.Queries;
public record GetTrendingGifsQuery : IQuery<JsonElement?>;
internal sealed class GetTrendingGifsQueryHandler : IQueryHandler<GetTrendingGifsQuery, JsonElement?>
{
private readonly ISettingsService _settings;
private readonly IMemoryCache _cache;
private readonly IHttpClientFactory _httpClientFactory;
public GetTrendingGifsQueryHandler(ISettingsService settings, IMemoryCache cache, IHttpClientFactory httpClientFactory)
{
_settings = settings;
_cache = cache;
_httpClientFactory = httpClientFactory;
}
public async Task<Result<JsonElement?>> Handle(GetTrendingGifsQuery request, CancellationToken cancellationToken)
{
var conf = _settings.Current;
if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey))
return Result.Failure<JsonElement?>(new Error(Errors.KlipyNotConfigured, "Klipy is not configured"));
var cacheKeyTrending = $"klipy_trending_{conf.KlipyApiKey}";
if (_cache.TryGetValue(cacheKeyTrending, out JsonElement cachedResult))
return Result.Success<JsonElement?>(cachedResult);
var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId : conf.KlipyCustomerId;
var client = _httpClientFactory.CreateClient();
var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, "", customerId);
var response = await client.GetAsync(urlCo, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, "", customerId);
response = await client.GetAsync(urlCom, cancellationToken);
}
if (!response.IsSuccessStatusCode)
return Result.Failure<JsonElement?>(new Error(Errors.KlipyApiError, "Klipy API Error"));
}
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: cancellationToken);
_cache.Set(cacheKeyTrending, result, TimeSpan.FromMinutes(Knot.Shared.Kernel.Constants.Klipy.TrendingCacheMinutes));
return Result.Success<JsonElement?>(result);
}
}
@@ -0,0 +1,67 @@
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Shared.Kernel.Constants;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
namespace Host.Application.Klipy.Queries;
public record SearchGifsQuery(string Query) : IQuery<JsonElement?>;
internal sealed class SearchGifsQueryHandler : IQueryHandler<SearchGifsQuery, JsonElement?>
{
private readonly ISettingsService _settings;
private readonly IMemoryCache _cache;
private readonly IHttpClientFactory _httpClientFactory;
public SearchGifsQueryHandler(ISettingsService settings, IMemoryCache cache, IHttpClientFactory httpClientFactory)
{
_settings = settings;
_cache = cache;
_httpClientFactory = httpClientFactory;
}
public async Task<Result<JsonElement?>> Handle(SearchGifsQuery request, CancellationToken cancellationToken)
{
var conf = _settings.Current;
if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey))
return Result.Failure<JsonElement?>(new Error(Errors.KlipyNotConfigured, "Klipy is not configured"));
if (string.IsNullOrWhiteSpace(request.Query))
return Result.Failure<JsonElement?>(new Error(Errors.InvalidQuery, "Invalid query parameter"));
var cacheKey = $"klipy_search_{conf.KlipyApiKey}_{request.Query.ToLowerInvariant()}";
if (_cache.TryGetValue(cacheKey, out JsonElement cachedResult))
return Result.Success<JsonElement?>(cachedResult);
var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId : conf.KlipyCustomerId;
var client = _httpClientFactory.CreateClient();
var queryParam = $"q={Uri.EscapeDataString(request.Query)}";
var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceSearch, queryParam, customerId);
var response = await client.GetAsync(urlCo, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceSearch, queryParam, customerId);
response = await client.GetAsync(urlCom, cancellationToken);
}
if (!response.IsSuccessStatusCode)
return Result.Failure<JsonElement?>(new Error(Errors.KlipySearchError, "Klipy Search API Error"));
}
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: cancellationToken);
_cache.Set(cacheKey, result, TimeSpan.FromMinutes(Knot.Shared.Kernel.Constants.Klipy.SearchCacheMinutes));
return Result.Success<JsonElement?>(result);
}
}
@@ -0,0 +1,141 @@
using Host.Application.Stories;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Messages.Send;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Host.Models;
namespace Host.Application.Stories.Commands.AddStoryReaction;
public record AddStoryReactionCommand(Guid UserId, Guid StoryId, string Emoji) : ICommand<MessageResponse>;
internal sealed class AddStoryReactionCommandHandler : ICommandHandler<AddStoryReactionCommand, MessageResponse>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly ISender _sender;
private readonly IHubContext<ChatHub> _hubContext;
public AddStoryReactionCommandHandler(
IdentityDbContext context,
IUserRepository userRepository,
IChatRepository chatRepository,
IMessageRepository messageRepository,
ISender sender,
IHubContext<ChatHub> hubContext)
{
_context = context;
_userRepository = userRepository;
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_sender = sender;
_hubContext = hubContext;
}
public async Task<Result<MessageResponse>> Handle(AddStoryReactionCommand request, CancellationToken cancellationToken)
{
var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken);
if (story == null)
{
return Result.Failure<MessageResponse>(StoryErrors.StoryNotFound);
}
var existing = await _context.StoryReactions
.FirstOrDefaultAsync(r => r.StoryId == request.StoryId && r.UserId == request.UserId && r.Emoji == request.Emoji, cancellationToken);
if (existing != null)
{
return Result.Success(new MessageResponse("Reaction already exists"));
}
var reaction = new StoryReaction(request.StoryId, request.UserId, request.Emoji);
_context.StoryReactions.Add(reaction);
await _context.SaveChangesAsync(cancellationToken);
var chatId = await GetOrCreatePersonalChatIdAsync(request.UserId, story.UserId, cancellationToken);
var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, cancellationToken);
if (lastStoryMessage != null)
{
var addReactionCommand = new Knot.Modules.Chats.Application.Messages.React.AddReactionCommand(
lastStoryMessage.Id, request.UserId, request.Emoji, chatId);
await _sender.Send(addReactionCommand, cancellationToken);
}
else
{
var storyQuote = GetStoryQuote(story);
var messageCommand = new SendMessageCommand(
ChatId: chatId,
SenderId: request.UserId,
Content: request.Emoji,
Type: "text",
Quote: storyQuote,
StoryId: story.Id,
StoryMediaUrl: story.MediaUrl,
StoryMediaType: story.Type,
Attachments: null,
ReplyToId: null,
ForwardedFromId: null);
await _sender.Send(messageCommand, cancellationToken);
}
var reactor = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
await _hubContext.Clients.All.SendAsync("story_reaction", new
{
storyId = story.Id,
userId = request.UserId,
username = reactor?.Username,
displayName = reactor?.DisplayName,
avatar = reactor?.Avatar,
emoji = request.Emoji,
createdAt = DateTime.UtcNow,
ownerId = story.UserId
}, cancellationToken);
return Result.Success(new MessageResponse("Reaction added"));
}
private string GetStoryQuote(Story story)
{
if (!string.IsNullOrEmpty(story.Content))
{
return story.Content;
}
return story.Type.ToLower() switch
{
"image" => "🖼 Фото",
"video" => "🎬 Видео",
_ => "История"
};
}
private async Task<Guid> GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken cancellationToken)
{
var userChats = await _chatRepository.GetUserChatsAsync(userId1, cancellationToken);
var personalChat = userChats.FirstOrDefault(c =>
c.Type == ChatType.Personal &&
c.Members.Any(m => m.UserId == userId2));
if (personalChat != null)
{
return personalChat.Id;
}
var command = new Knot.Modules.Chats.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { userId1, userId2 });
var result = await _sender.Send(command, cancellationToken);
return result.Value;
}
}
@@ -0,0 +1,124 @@
using Host.Application.Stories;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Messages.Send;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Host.Models;
namespace Host.Application.Stories.Commands.AddStoryReply;
public record AddStoryReplyCommand(Guid UserId, Guid StoryId, string Content) : ICommand<MessageResponse>;
internal sealed class AddStoryReplyCommandHandler : ICommandHandler<AddStoryReplyCommand, MessageResponse>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly ISender _sender;
private readonly IHubContext<ChatHub> _hubContext;
public AddStoryReplyCommandHandler(
IdentityDbContext context,
IUserRepository userRepository,
IChatRepository chatRepository,
IMessageRepository messageRepository,
ISender sender,
IHubContext<ChatHub> hubContext)
{
_context = context;
_userRepository = userRepository;
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_sender = sender;
_hubContext = hubContext;
}
public async Task<Result<MessageResponse>> Handle(AddStoryReplyCommand request, CancellationToken cancellationToken)
{
var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken);
if (story == null)
{
return Result.Failure<MessageResponse>(StoryErrors.StoryNotFound);
}
var reply = new StoryReply(request.StoryId, request.UserId, request.Content);
_context.StoryReplies.Add(reply);
await _context.SaveChangesAsync(cancellationToken);
var chatId = await GetOrCreatePersonalChatIdAsync(request.UserId, story.UserId, cancellationToken);
var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, cancellationToken);
var storyQuote = GetStoryQuote(story);
var messageCommand = new SendMessageCommand(
ChatId: chatId,
SenderId: request.UserId,
Content: request.Content,
Type: "text",
Quote: storyQuote,
ReplyToId: lastStoryMessage?.Id,
StoryId: story.Id,
StoryMediaUrl: story.MediaUrl,
StoryMediaType: story.Type,
Attachments: null,
ForwardedFromId: null);
await _sender.Send(messageCommand, cancellationToken);
var replier = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
await _hubContext.Clients.All.SendAsync("story_reply", new
{
storyId = story.Id,
userId = request.UserId,
username = replier?.Username,
displayName = replier?.DisplayName,
avatar = replier?.Avatar,
content = request.Content,
createdAt = DateTime.UtcNow,
ownerId = story.UserId
}, cancellationToken);
return Result.Success(new MessageResponse("Reply added"));
}
private string GetStoryQuote(Story story)
{
if (!string.IsNullOrEmpty(story.Content))
{
return story.Content;
}
return story.Type.ToLower() switch
{
"image" => " Фото",
"video" => "🎬 Видео",
_ => "История"
};
}
private async Task<Guid> GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken cancellationToken)
{
var userChats = await _chatRepository.GetUserChatsAsync(userId1, cancellationToken);
var personalChat = userChats.FirstOrDefault(c =>
c.Type == ChatType.Personal &&
c.Members.Any(m => m.UserId == userId2));
if (personalChat != null)
{
return personalChat.Id;
}
var command = new Knot.Modules.Chats.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { userId1, userId2 });
var result = await _sender.Send(command, cancellationToken);
return result.Value;
}
}
@@ -0,0 +1,36 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Domain;
namespace Host.Application.Stories.Commands.CreateStory;
public record CreateStoryCommand(Guid UserId, string Type, string? MediaUrl, string? Content, string? BgColor) : ICommand<Guid>;
internal sealed class CreateStoryCommandHandler : ICommandHandler<CreateStoryCommand, Guid>
{
private readonly IdentityDbContext _context;
public CreateStoryCommandHandler(IdentityDbContext context)
{
_context = context;
}
public async Task<Result<Guid>> Handle(CreateStoryCommand request, CancellationToken cancellationToken)
{
var story = Story.Create(
request.UserId,
request.Type,
request.MediaUrl,
request.Content,
request.BgColor);
_context.Stories.Add(story);
await _context.SaveChangesAsync(cancellationToken);
return Result.Success(story.Id);
}
}
@@ -0,0 +1,40 @@
using Host.Application.Stories;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Host.Models;
namespace Host.Application.Stories.Commands.DeleteStory;
public record DeleteStoryCommand(Guid UserId, Guid StoryId) : ICommand<MessageResponse>;
internal sealed class DeleteStoryCommandHandler : ICommandHandler<DeleteStoryCommand, MessageResponse>
{
private readonly IdentityDbContext _context;
public DeleteStoryCommandHandler(IdentityDbContext context)
{
_context = context;
}
public async Task<Result<MessageResponse>> Handle(DeleteStoryCommand request, CancellationToken cancellationToken)
{
var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken);
if (story == null)
{
return Result.Failure<MessageResponse>(StoryErrors.StoryNotFound);
}
if (story.UserId != request.UserId)
{
return Result.Failure<MessageResponse>(StoryErrors.Unauthorized);
}
_context.Stories.Remove(story);
await _context.SaveChangesAsync(cancellationToken);
return Result.Success(new MessageResponse("Story deleted"));
}
}
@@ -0,0 +1,39 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Host.Models;
namespace Host.Application.Stories.Commands.RemoveStoryReaction;
public record RemoveStoryReactionCommand(Guid UserId, Guid StoryId, string Emoji) : ICommand<MessageResponse>;
internal sealed class RemoveStoryReactionCommandHandler : ICommandHandler<RemoveStoryReactionCommand, MessageResponse>
{
private readonly IdentityDbContext _context;
public RemoveStoryReactionCommandHandler(IdentityDbContext context)
{
_context = context;
}
public async Task<Result<MessageResponse>> Handle(RemoveStoryReactionCommand request, CancellationToken cancellationToken)
{
var reaction = await _context.StoryReactions
.FirstOrDefaultAsync(r => r.StoryId == request.StoryId && r.UserId == request.UserId && r.Emoji == request.Emoji, cancellationToken);
if (reaction == null)
{
return Result.Success(new MessageResponse("Reaction not found"));
}
_context.StoryReactions.Remove(reaction);
await _context.SaveChangesAsync(cancellationToken);
return Result.Success(new MessageResponse("Reaction removed"));
}
}
@@ -0,0 +1,77 @@
using Host.Application.Stories;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Host.Models;
namespace Host.Application.Stories.Commands.ViewStory;
public record ViewStoryCommand(Guid UserId, Guid StoryId) : ICommand<MessageResponse>;
internal sealed class ViewStoryCommandHandler : ICommandHandler<ViewStoryCommand, MessageResponse>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
private readonly IHubContext<ChatHub> _hubContext;
public ViewStoryCommandHandler(IdentityDbContext context, IUserRepository userRepository, IHubContext<ChatHub> hubContext)
{
_context = context;
_userRepository = userRepository;
_hubContext = hubContext;
}
public async Task<Result<MessageResponse>> Handle(ViewStoryCommand request, CancellationToken cancellationToken)
{
try
{
var story = await _context.Stories
.Include(s => s.Viewers)
.FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken);
if (story == null)
{
return Result.Failure<MessageResponse>(StoryErrors.StoryNotFound);
}
if (story.UserId == request.UserId)
{
return Result.Success(new MessageResponse("Owner view"));
}
if (!story.Viewers.Any(v => v.UserId == request.UserId))
{
story.AddViewer(request.UserId);
await _context.SaveChangesAsync(cancellationToken);
var viewer = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
var updatedStory = await _context.Stories.Include(s => s.Viewers).FirstAsync(s => s.Id == story.Id, cancellationToken);
await _hubContext.Clients.All.SendAsync("story_viewed", new
{
storyId = story.Id,
userId = request.UserId,
username = viewer?.Username,
displayName = viewer?.DisplayName,
avatar = viewer?.Avatar,
viewedAt = DateTime.UtcNow,
viewCount = updatedStory.Viewers.Count,
ownerId = story.UserId
}, cancellationToken);
}
return Result.Success(new MessageResponse("Story viewed"));
}
catch (DbUpdateException)
{
return Result.Success(new MessageResponse("Story already viewed"));
}
}
}
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Messages.Send;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Host.Models;
namespace Host.Application.Stories.Queries.GetStories;
public record GetStoriesQuery(Guid UserId) : IQuery<List<StoryGroupDto>>;
internal sealed class GetStoriesQueryHandler : IQueryHandler<GetStoriesQuery, List<StoryGroupDto>>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetStoriesQueryHandler(IdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<StoryGroupDto>>> Handle(GetStoriesQuery request, CancellationToken cancellationToken)
{
var friendships = await _context.Set<Friendship>()
.Where(f => f.Status == FriendshipStatus.Accepted && (f.UserId == request.UserId || f.FriendId == request.UserId))
.ToListAsync(cancellationToken);
var friendIds = friendships.Select(f => f.UserId == request.UserId ? f.FriendId : f.UserId).ToList();
friendIds.Add(request.UserId);
var stories = await _context.Stories
.Include(s => s.Viewers)
.Include(s => s.Reactions)
.Include(s => s.Replies)
.Where(s => s.ExpiresAt > DateTime.UtcNow && friendIds.Contains(s.UserId))
.OrderByDescending(s => s.CreatedAt)
.ToListAsync(cancellationToken);
var groups = stories.GroupBy(s => s.UserId).ToList();
var result = new List<StoryGroupDto>();
var userIds = groups.Select(g => g.Key).ToList();
var userMap = new Dictionary<Guid, dynamic>();
foreach (var uid in userIds)
{
var u = await _userRepository.GetByIdAsync(uid, cancellationToken);
if (u != null)
{
userMap[uid] = u;
}
}
foreach (var group in groups)
{
if (!userMap.TryGetValue(group.Key, out var user))
{
continue;
}
result.Add(new StoryGroupDto(
new StoryUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
group.Select(s => new StoryDto(
s.Id,
s.Type,
s.MediaUrl,
s.Content,
s.BgColor,
s.CreatedAt,
s.ExpiresAt,
s.Viewers.Count,
s.Viewers.Any(v => v.UserId == request.UserId),
s.Reactions.Select(r => new StoryReactionDto(r.Id, r.UserId, r.Emoji, r.CreatedAt)).ToList(),
s.Replies.Count
)).OrderBy(s => s.CreatedAt).ToList(),
group.Any(s => !s.Viewers.Any(v => v.UserId == request.UserId))
));
}
return Result.Success(result.OrderBy(r =>
{
if (r.User.Id == request.UserId)
{
return 0;
}
return 1;
}).ToList());
}
}
@@ -0,0 +1,66 @@
using Host.Application.Stories;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Host.Models;
namespace Host.Application.Stories.Queries.GetStoryReplies;
public record GetStoryRepliesQuery(Guid UserId, Guid StoryId) : IQuery<List<StoryReplyDto>>;
internal sealed class GetStoryRepliesQueryHandler : IQueryHandler<GetStoryRepliesQuery, List<StoryReplyDto>>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetStoryRepliesQueryHandler(IdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<StoryReplyDto>>> Handle(GetStoryRepliesQuery request, CancellationToken cancellationToken)
{
var story = await _context.Stories
.Include(s => s.Replies)
.FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken);
if (story == null)
{
return Result.Failure<List<StoryReplyDto>>(StoryErrors.StoryNotFound);
}
if (story.UserId != request.UserId)
{
return Result.Failure<List<StoryReplyDto>>(StoryErrors.Unauthorized);
}
var replies = new List<StoryReplyDto>();
foreach (var reply in story.Replies.OrderBy(r => r.CreatedAt))
{
var user = await _userRepository.GetByIdAsync(reply.UserId, cancellationToken);
if (user == null)
{
continue;
}
replies.Add(new StoryReplyDto(
reply.Id,
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
reply.Content,
reply.CreatedAt
));
}
return Result.Success(replies);
}
}
@@ -0,0 +1,68 @@
using Host.Application.Stories;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Host.Models;
namespace Host.Application.Stories.Queries.GetStoryViewers;
public record GetStoryViewersQuery(Guid UserId, Guid StoryId) : IQuery<List<StoryViewerDto>>;
internal sealed class GetStoryViewersQueryHandler : IQueryHandler<GetStoryViewersQuery, List<StoryViewerDto>>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetStoryViewersQueryHandler(IdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<StoryViewerDto>>> Handle(GetStoryViewersQuery request, CancellationToken cancellationToken)
{
var story = await _context.Stories
.Include(s => s.Viewers)
.FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken);
if (story == null)
{
return Result.Failure<List<StoryViewerDto>>(StoryErrors.StoryNotFound);
}
if (story.UserId != request.UserId)
{
return Result.Failure<List<StoryViewerDto>>(StoryErrors.Unauthorized);
}
var viewerIds = story.Viewers.Select(v => v.UserId).ToList();
var viewers = new List<StoryViewerDto>();
foreach (var viewerId in viewerIds)
{
var user = await _userRepository.GetByIdAsync(viewerId, cancellationToken);
if (user == null)
{
continue;
}
var viewerRecord = story.Viewers.First(v => v.UserId == viewerId);
viewers.Add(new StoryViewerDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
viewerRecord.ViewedAt
));
}
return Result.Success(viewers);
}
}
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Application.Abstractions;
using Microsoft.EntityFrameworkCore;
using Host.Models;
namespace Host.Application.Stories.Queries.GetUserStories;
public record GetUserStoriesQuery(Guid CurrentUserId, Guid TargetUserId) : IQuery<StoryGroupDto>;
internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQuery, StoryGroupDto>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetUserStoriesQueryHandler(IdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<StoryGroupDto>> Handle(GetUserStoriesQuery request, CancellationToken cancellationToken)
{
var stories = await _context.Stories
.Include(s => s.Viewers)
.Include(s => s.Reactions)
.Include(s => s.Replies)
.Where(s => s.UserId == request.TargetUserId)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync(cancellationToken);
var user = await _userRepository.GetByIdAsync(request.TargetUserId, cancellationToken);
if (user == null)
{
return Result.Failure<StoryGroupDto>(IdentityErrors.UserNotFound);
}
var result = new StoryGroupDto(
new StoryUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
stories.Select(s => new StoryDto(
s.Id,
s.Type,
s.MediaUrl,
s.Content,
s.BgColor,
s.CreatedAt,
s.ExpiresAt,
s.Viewers.Count,
s.Viewers.Any(v => v.UserId == request.CurrentUserId),
s.Reactions.Select(r => new StoryReactionDto(r.Id, r.UserId, r.Emoji, r.CreatedAt)).ToList(),
s.Replies.Count
)).ToList(),
stories.Any(s => !s.Viewers.Any(v => v.UserId == request.CurrentUserId))
);
return Result.Success(result);
}
}
@@ -0,0 +1,9 @@
using Knot.Shared.Kernel;
namespace Host.Application.Stories;
public static class StoryErrors
{
public static readonly Error Unauthorized = new Error("Unauthorized", "Access denied");
public static readonly Error StoryNotFound = new Error("Story.NotFound", "Story not found");
}
@@ -0,0 +1,73 @@
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Microsoft.Extensions.Configuration;
using MediatR;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Host.Application.WebRtc.Queries;
public record IceServerDto(string[] Urls, string? Username = null, string? Credential = null);
public record IceServersResultDto(List<IceServerDto> IceServers);
public record GetIceServersQuery : IQuery<IceServersResultDto>;
internal sealed class GetIceServersQueryHandler : IQueryHandler<GetIceServersQuery, IceServersResultDto>
{
private readonly IConfiguration _configuration;
private readonly ISettingsService _settingsService;
public GetIceServersQueryHandler(IConfiguration configuration, ISettingsService settingsService)
{
_configuration = configuration;
_settingsService = settingsService;
}
public Task<Result<IceServersResultDto>> Handle(GetIceServersQuery request, CancellationToken cancellationToken)
{
var settings = _settingsService.Current;
if (!settings.EnableCalls)
{
return Task.FromResult(Result.Failure<IceServersResultDto>(new Error(
Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin,
"Сервис отключен администратором."
)));
}
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<IceServerDto>();
if (!string.IsNullOrEmpty(turnUrl))
{
var stunUrl = turnUrl.Replace("turn:", "stun:");
iceServers.Add(new IceServerDto(new[] { stunUrl }));
if (!string.IsNullOrEmpty(turnUsername))
{
iceServers.Add(new IceServerDto(
new[] { turnUrl, turnUrl + "?transport=tcp" },
turnUsername,
!string.IsNullOrEmpty(turnSecret) ? turnSecret : turnUsername
));
}
else
{
iceServers.Add(new IceServerDto(new[] { turnUrl, turnUrl + "?transport=tcp" }));
}
}
return Task.FromResult(Result.Success(new IceServersResultDto(iceServers)));
}
}
@@ -0,0 +1,110 @@
using Microsoft.AspNetCore.Mvc;
using Host.Models;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Identity.Application.Users.Register;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Identity.Infrastructure.Persistence;
using System.Threading;
using System.Threading.Tasks;
using System;
using MediatR;
using Host.Application.Admin.Queries;
using Host.Application.Admin.Commands;
using Microsoft.AspNetCore.Authorization;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class AdminController : ControllerBase
{
private readonly ISender _sender;
public AdminController(ISender sender)
{
_sender = sender;
}
[HttpGet("settings")]
public async Task<IActionResult> GetSettings(CancellationToken ct)
{
var result = await _sender.Send(new GetSettingsQuery(), ct);
return Ok(result.Value);
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings([FromBody] SystemSettingsDto settings, CancellationToken ct)
{
var result = await _sender.Send(new UpdateSettingsCommand(settings), ct);
return Ok(result.Value);
}
[HttpGet("dashboard")]
public async Task<IActionResult> GetDashboardStats(CancellationToken ct)
{
var result = await _sender.Send(new GetDashboardStatsQuery(), ct);
return Ok(result.Value);
}
[HttpPost("users")]
public async Task<IActionResult> CreateUser([FromBody] RegisterUserCommand command, CancellationToken ct)
{
var result = await _sender.Send(command, ct);
if (result.IsFailure) return BadRequest(new { error = result.Error.Description });
var userDetails = await _sender.Send(new GetUserDetailsQuery(result.Value.User.Id), ct);
return Ok(userDetails.Value);
}
[HttpPost("users/{userId:guid}/reset-password")]
public async Task<IActionResult> ResetUserPassword(Guid userId, [FromBody] ResetPasswordDto dto, CancellationToken ct)
{
var result = await _sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct);
if (result.IsFailure)
{
if (result.Error.Code == "User.NotFound") return NotFound(new { error = "User not found" });
return BadRequest(new { error = result.Error.Description });
}
return Ok(result.Value);
}
[HttpGet("users")]
public async Task<IActionResult> SearchUsers([FromQuery] string query = "", CancellationToken ct = default)
{
var result = await _sender.Send(new SearchUsersQuery(query), ct);
return Ok(result.Value);
}
[HttpGet("users/{id:guid}")]
public async Task<IActionResult> GetUserDetails(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new GetUserDetailsQuery(id), ct);
if (result.IsFailure)
{
return NotFound("User not found");
}
return Ok(result.Value);
}
[HttpGet("clean/dry-run")]
public async Task<IActionResult> CleanDryRun(
[FromServices] IFileStorageService fileStorage,
[FromServices] IdentityDbContext identityDb,
CancellationToken ct)
{
var result = await _sender.Send(new CleanDryRunQuery(fileStorage, identityDb), ct);
return Ok(result.Value);
}
[HttpPost("clean/run")]
public async Task<IActionResult> CleanRun(
[FromServices] IFileStorageService fileStorage,
[FromServices] IdentityDbContext identityDb,
CancellationToken ct)
{
var result = await _sender.Send(new CleanRunCommand(fileStorage, identityDb), ct);
return Ok(result.Value);
}
}
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Application.Users.Auth;
using Knot.Modules.Identity.Application.Users.Register;
using Knot.Modules.Identity.Application.Users.Login;
using Knot.Modules.Identity.Application.Users.GetMe;
using MediatR;
namespace Host.Controllers;
[ApiController]
[Route("api/[controller]")]
public sealed class AuthController : ControllerBase
{
private readonly ISender _sender;
public AuthController(ISender sender)
{
_sender = sender;
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterUserCommand command, CancellationToken ct)
{
var result = await _sender.Send(command, ct);
if (result.IsFailure) return BadRequest(new { error = result.Error.Code ?? result.Error.Description });
return Ok(result.Value);
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> GetMe([FromServices] IUserContext userContext, CancellationToken ct)
{
var result = await _sender.Send(new GetMeQuery(userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(new { User = result.Value.User });
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginUserCommand command, CancellationToken ct)
{
var result = await _sender.Send(command, ct);
if (result.IsFailure) return Unauthorized(new { error = result.Error.Code ?? result.Error.Description });
return Ok(result.Value);
}
}
@@ -0,0 +1,241 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Chats.Application.Chats.GetChats;
using Knot.Modules.Chats.Application.Chats.GetChatById;
using Knot.Modules.Chats.Application.Chats.Create;
using Knot.Modules.Chats.Application.Chats.GetOrCreateFavorites;
using Knot.Modules.Chats.Application.Chats.Update;
using Knot.Modules.Chats.Application.Chats.LeaveOrDelete;
using Knot.Modules.Chats.Application.Chats.Clear;
using Knot.Modules.Chats.Application.Chats.TogglePin;
using Knot.Modules.Chats.Application.Chats.Members;
using Knot.Modules.Chats.Application.Chats.Avatar;
using Knot.Modules.Chats.Domain;
using MediatR;
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/chats")]
public sealed class ChatsController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public ChatsController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet]
public async Task<IActionResult> GetChats(CancellationToken ct)
{
var result = await _sender.Send(new GetChatsQuery(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateChatRequest request, CancellationToken ct)
{
var command = new CreateChatCommand(request.Name, request.Type, request.MemberIds);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("personal")]
public async Task<IActionResult> CreatePersonal([FromBody] CreatePersonalChatRequest request, CancellationToken ct)
{
var command = new CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { _userContext.UserId, request.UserId });
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("group")]
public async Task<IActionResult> CreateGroup([FromBody] CreateGroupChatRequest request, CancellationToken ct)
{
var memberIds = request.MemberIds.ToList();
if (memberIds.Contains(_userContext.UserId))
{
memberIds.Remove(_userContext.UserId);
}
memberIds.Insert(0, _userContext.UserId);
var command = new CreateChatCommand(request.Name, ChatType.Group, memberIds);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("favorites")]
public async Task<IActionResult> GetOrCreateFavorites(CancellationToken ct)
{
var result = await _sender.Send(new GetOrCreateFavoritesCommand(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> UpdateChat(Guid id, [FromBody] UpdateChatRequest request, CancellationToken ct)
{
var result = await _sender.Send(new UpdateChatCommand(id, _userContext.UserId, request.Name, request.Description), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> LeaveOrDeleteChat(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new LeaveOrDeleteChatCommand(id, _userContext.UserId), ct);
if (result.IsFailure)
{
if (result.Error.Code == "Unauthorized")
{
return Forbid();
}
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id:guid}/clear")]
public async Task<IActionResult> ClearChat(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new ClearChatCommand(id, _userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id:guid}/pin")]
public async Task<IActionResult> TogglePin(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new TogglePinCommand(id, _userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id:guid}/members")]
public async Task<IActionResult> AddMembers(Guid id, [FromBody] AddMembersRequest request, CancellationToken ct)
{
var result = await _sender.Send(new AddMembersCommand(id, _userContext.UserId, request.UserIds.ToList()), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpDelete("{id:guid}/members/{userId:guid}")]
public async Task<IActionResult> RemoveMember(Guid id, Guid userId, CancellationToken ct)
{
var result = await _sender.Send(new RemoveMemberCommand(id, _userContext.UserId, userId), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("{id:guid}/avatar")]
public async Task<IActionResult> UploadGroupAvatar(Guid id, Microsoft.AspNetCore.Http.IFormFile avatar, CancellationToken ct)
{
if (avatar == null || avatar.Length == 0)
{
return BadRequest("No file");
}
using var stream = avatar.OpenReadStream();
var result = await _sender.Send(new UploadGroupAvatarCommand(id, _userContext.UserId, avatar.FileName, avatar.ContentType, stream), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("{id:guid}/avatar/crop")]
public async Task<IActionResult> CropGroupAvatar(Guid id, [FromForm] Microsoft.AspNetCore.Http.IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, CancellationToken ct)
{
if (avatar == null || avatar.Length == 0)
{
return BadRequest("No file");
}
using var stream = avatar.OpenReadStream();
var result = await _sender.Send(new CropGroupAvatarCommand(id, _userContext.UserId, avatar.FileName, avatar.ContentType, stream, x, y, width, height), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpDelete("{id:guid}/avatar")]
public async Task<IActionResult> RemoveGroupAvatar(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new RemoveGroupAvatarCommand(id, _userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
}
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Shared.Kernel.Storage;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/files")]
public sealed class FilesController : ControllerBase
{
private readonly IFileStorageService _fileStorage;
public FilesController(IFileStorageService fileStorage)
{
_fileStorage = fileStorage;
}
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<IActionResult> DownloadFile(string id)
{
try
{
var result = await _fileStorage.DownloadFileAsync(id);
// Обратите внимание, что мы возвращаем поток с автоматическим освобождением памяти.
return File(result.Stream, result.ContentType, result.FileName);
}
catch (Exception ex)
{
return NotFound(new { error = "Файл не найден или ошибка доступа.", message = ex.Message });
}
}
}
@@ -0,0 +1,113 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Friends;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/friends")]
public sealed class FriendsController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public FriendsController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet]
public async Task<IActionResult> GetFriends(CancellationToken ct)
{
var result = await _sender.Send(new GetFriendsQuery(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpGet("requests")]
public async Task<IActionResult> GetRequests(CancellationToken ct)
{
var result = await _sender.Send(new GetIncomingRequestsQuery(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpGet("outgoing")]
public async Task<IActionResult> GetOutgoingRequests(CancellationToken ct)
{
var result = await _sender.Send(new GetOutgoingRequestsQuery(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpPost("request")]
public async Task<IActionResult> SendRequest([FromBody] Host.Models.SendFriendRequest request, CancellationToken ct)
{
var result = await _sender.Send(new SendFriendRequestCommand(_userContext.UserId, request.FriendId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(new { status = "pending" });
}
[HttpPost("{id:guid}/accept")]
public async Task<IActionResult> AcceptRequest(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new AcceptFriendRequestCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
return NotFound(result.Error.Description);
}
return Ok(new { id = result.Value });
}
[HttpPost("{id:guid}/decline")]
public async Task<IActionResult> DeclineRequest(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new DeclineFriendRequestCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
return NotFound(result.Error.Description);
}
return Ok(new Knot.Shared.Kernel.SuccessResponse(true));
}
[HttpGet("status/{userId:guid}")]
public async Task<IActionResult> GetStatus(Guid userId, CancellationToken ct)
{
var result = await _sender.Send(new GetFriendshipStatusQuery(_userContext.UserId, userId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> RemoveFriend(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new RemoveFriendCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
return NotFound(result.Error.Description);
}
return Ok(new Knot.Shared.Kernel.SuccessResponse(true));
}
}
@@ -0,0 +1,114 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Shared.Kernel;
using System.Threading;
using System.Threading.Tasks;
using System;
using Microsoft.AspNetCore.Http;
using MediatR;
using Knot.Modules.Chats.Application.Messages.GetMessages;
using Knot.Modules.Chats.Application.Messages.SearchMessages;
using Knot.Modules.Chats.Application.Messages.UploadFile;
using Knot.Modules.Chats.Application.Messages.GetSharedMedia;
using Knot.Modules.Chats.Application.Messages.Send;
using System.Linq;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/messages")]
public sealed class MessagesController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public MessagesController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet("chat/{chatId:guid}")]
public async Task<IActionResult> GetMessages(Guid chatId, [FromQuery] string? cursor, CancellationToken ct = default)
{
var result = await _sender.Send(new GetMessagesQuery(_userContext.UserId, chatId, cursor), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpGet("search")]
public async Task<IActionResult> GetSearch([FromQuery] string q, [FromQuery] Guid? chatId, CancellationToken ct)
{
var result = await _sender.Send(new SearchMessagesQuery(_userContext.UserId, q, chatId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpPost("upload")]
public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken ct)
{
if (file == null || file.Length == 0)
{
return BadRequest("No file uploaded");
}
using var stream = file.OpenReadStream();
var result = await _sender.Send(new UploadFileCommand(file.FileName, file.ContentType, file.Length, stream), ct);
if (result.IsFailure)
{
if (result.Error.Code == "File.TooLarge")
{
return StatusCode(413, result.Error.Description);
}
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpGet("chat/{chatId:guid}/shared")]
public async Task<IActionResult> GetSharedMedia(Guid chatId, [FromQuery] string? type, CancellationToken ct)
{
var result = await _sender.Send(new GetSharedMediaQuery(_userContext.UserId, chatId, type), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpPost("chat/{chatId:guid}")]
public async Task<IActionResult> SendMessage(Guid chatId, [FromBody] SendMessageRequest request, CancellationToken ct)
{
var attachments = request.Attachments?.Select(a =>
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
var command = new SendMessageCommand(
chatId,
_userContext.UserId,
request.Content,
request.Type,
attachments,
request.ReplyToId,
request.Quote,
request.ForwardedFromId);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
}
@@ -0,0 +1,149 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Host.Models;
using Knot.Shared.Kernel;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Host.Application.Stories.Queries.GetStories;
using Host.Application.Stories.Commands.CreateStory;
using Host.Application.Stories.Queries.GetUserStories;
using Host.Application.Stories.Commands.ViewStory;
using Host.Application.Stories.Queries.GetStoryViewers;
using Host.Application.Stories.Commands.AddStoryReaction;
using Host.Application.Stories.Commands.RemoveStoryReaction;
using Host.Application.Stories.Commands.AddStoryReply;
using Host.Application.Stories.Queries.GetStoryReplies;
using Host.Application.Stories.Commands.DeleteStory;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/stories")]
public sealed class StoriesController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public StoriesController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet]
public async Task<IActionResult> GetStories(CancellationToken ct)
{
var result = await _sender.Send(new GetStoriesQuery(_userContext.UserId), ct);
return Ok(result.Value);
}
[HttpPost]
public async Task<IActionResult> CreateStory([FromBody] CreateStoryRequest request, CancellationToken ct)
{
var result = await _sender.Send(new CreateStoryCommand(_userContext.UserId, request.Type, request.MediaUrl, request.Content, request.BgColor), ct);
return Ok(new { id = result.Value });
}
[HttpGet("user/{userId}")]
public async Task<IActionResult> GetUserStories(Guid userId, CancellationToken ct)
{
var result = await _sender.Send(new GetUserStoriesQuery(_userContext.UserId, userId), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id}/view")]
public async Task<IActionResult> ViewStory(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new ViewStoryCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpGet("{id}/viewers")]
public async Task<IActionResult> GetStoryViewers(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new GetStoryViewersQuery(_userContext.UserId, id), ct);
if (result.IsFailure)
{
if (result.Error.Code == "Unauthorized")
{
return Forbid();
}
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id}/reaction")]
public async Task<IActionResult> AddReaction(Guid id, [FromBody] AddStoryReactionRequest request, CancellationToken ct)
{
var result = await _sender.Send(new AddStoryReactionCommand(_userContext.UserId, id, request.Emoji), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpDelete("{id}/reaction")]
public async Task<IActionResult> RemoveReaction(Guid id, [FromBody] RemoveStoryReactionRequest request, CancellationToken ct)
{
var result = await _sender.Send(new RemoveStoryReactionCommand(_userContext.UserId, id, request.Emoji), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id}/reply")]
public async Task<IActionResult> AddReply(Guid id, [FromBody] AddStoryReplyRequest request, CancellationToken ct)
{
var result = await _sender.Send(new AddStoryReplyCommand(_userContext.UserId, id, request.Content), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpGet("{id}/replies")]
public async Task<IActionResult> GetReplies(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new GetStoryRepliesQuery(_userContext.UserId, id), ct);
if (result.IsFailure)
{
if (result.Error.Code == "Unauthorized")
{
return Forbid();
}
return NotFound();
}
return Ok(result.Value);
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteStory(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new DeleteStoryCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
if (result.Error.Code == "Unauthorized")
{
return Forbid();
}
return NotFound();
}
return Ok(result.Value);
}
}
@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using MediatR;
using Host.Models;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.TelegramImport;
using System.Threading;
using System.Threading.Tasks;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/import/telegram")]
public sealed class TelegramImportController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public TelegramImportController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpPost("analyze")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 10L * 1024 * 1024 * 1024)] // 10GB for big exports
public async Task<IActionResult> Analyze(IFormFile file, CancellationToken ct)
{
if (file == null)
{
return BadRequest("No file uploaded.");
}
using var stream = file.OpenReadStream();
var command = new AnalyzeImportCommand(stream, file.FileName);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description ?? result.Error.Code);
}
return Ok(result.Value);
}
[HttpPost("execute")]
public async Task<IActionResult> Execute([FromBody] ExecuteImportRequest req, CancellationToken ct)
{
var command = new ExecuteImportCommand(_userContext.UserId, req.Token, req.Mapping, req.GroupName);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description ?? result.Error.Code);
}
return Ok(result.Value);
}
}
@@ -0,0 +1,134 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Shared.Kernel;
using Host.Models;
using Knot.Shared.Kernel.Storage;
using MediatR;
using Knot.Modules.Identity.Application.Users;
using Knot.Modules.Identity.Application.Users.Avatar;
using Knot.Modules.Identity.Application.Users.GetUser;
using Knot.Modules.Identity.Application.Users.Search;
using Knot.Modules.Identity.Application.Users.UpdateProfile;
using Knot.Modules.Identity.Application.Users.UpdateSettings;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/users")]
public sealed class UsersController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public UsersController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet("search")]
public async Task<IActionResult> Search([FromQuery] string q, CancellationToken ct)
{
var result = await _sender.Send(new SearchUsersQuery(q), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings([FromBody] UpdateSettingsRequest request, CancellationToken ct)
{
var result = await _sender.Send(new UpdateSettingsCommand(_userContext.UserId, request.HideStoryViews), ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpPost("avatar")]
public async Task<IActionResult> UploadAvatar(IFormFile avatar, CancellationToken ct)
{
var fileToUpload = avatar ?? Request.Form.Files.FirstOrDefault();
if (fileToUpload == null || fileToUpload.Length == 0)
{
return BadRequest("No file uploaded");
}
using var stream = fileToUpload.OpenReadStream();
var command = new UploadAvatarCommand(
_userContext.UserId,
stream,
fileToUpload.FileName,
fileToUpload.ContentType
);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpPost("avatar/crop")]
public async Task<IActionResult> CropAvatar([FromForm] IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, CancellationToken ct)
{
if (avatar == null || avatar.Length == 0)
{
return BadRequest("No file uploaded");
}
using var stream = avatar.OpenReadStream();
var command = new CropAvatarCommand(
_userContext.UserId,
stream,
avatar.FileName ?? "avatar.jpg",
avatar.ContentType,
x, y, width, height
);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpDelete("avatar")]
public async Task<IActionResult> DeleteAvatar(CancellationToken ct)
{
var result = await _sender.Send(new DeleteAvatarCommand(_userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpPut("profile")]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request, CancellationToken ct)
{
var result = await _sender.Send(new UpdateProfileCommand(_userContext.UserId, request.DisplayName, request.Bio, request.Birthday), ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetUser(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new GetUserQuery(id), ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
}
@@ -0,0 +1,25 @@
using Carter;
using MediatR;
using Knot.Shared.Kernel.Constants;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Host.Endpoints;
/// <summary>
/// Регистрация эндпоинтов для конфигурации приложения.
/// </summary>
public sealed class ConfigEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup(Routes.ApiConfig);
group.MapGet("/", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.Config.Queries.GetPublicConfigQuery(), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
});
}
}
@@ -0,0 +1,41 @@
using Carter;
using MediatR;
using Knot.Shared.Kernel.Constants;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using System;
namespace Host.Endpoints;
/// <summary>
/// Регистрация эндпоинтов федерации.
/// </summary>
public sealed class FederationEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup(Routes.ApiFederation);
group.MapPost("/handshake", async ([FromBody] Host.Application.Federation.Commands.HandshakeRequest request, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.Federation.Commands.HandshakeFederationCommand(request), ct);
if (result.IsSuccess)
{
return Results.Ok(result.Value);
}
if (result.Error.Code == "Unauthorized")
{
return Results.Forbid();
}
if (result.Error.Code == Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin)
{
return Results.StatusCode(503);
}
return Results.BadRequest(new { error = result.Error.Description });
});
}
}
@@ -0,0 +1,34 @@
using Carter;
using MediatR;
using Knot.Shared.Kernel.Constants;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using System;
using System.Threading.Tasks;
namespace Host.Endpoints;
/// <summary>
/// Регистрация эндпоинтов для сервиса Klipy.
/// </summary>
public sealed class KlipyEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup(Routes.ApiKlipy).RequireAuthorization();
group.MapGet("/trending", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.Klipy.Queries.GetTrendingGifsQuery(), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
});
group.MapGet("/search", async ([FromQuery] string q, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.Klipy.Queries.SearchGifsQuery(q), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
});
}
}
@@ -0,0 +1,28 @@
using Carter;
using MediatR;
using Knot.Shared.Kernel;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Host.Endpoints;
/// <summary>
/// Регистрация эндпоинтов WebRTC
/// </summary>
public sealed class WebRtcEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup(Knot.Shared.Kernel.Constants.Routes.ApiWebRtc)
.RequireAuthorization();
group.MapGet("/ice-servers", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.WebRtc.Queries.GetIceServersQuery(), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
})
.WithName("GetIceServers")
.WithOpenApi();
}
}
+36
View File
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="Carter" Version="10.0.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-preview.1.25120.3" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0-preview.6.25358.103" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<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" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Modules\Identity\Knot.Modules.Identity.csproj" />
<ProjectReference Include="..\Modules\Chats\Knot.Modules.Chats.csproj" />
<ProjectReference Include="..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
@Host_HostAddress = http://localhost:5059
GET {{Host_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,15 @@
using System;
namespace Host.Models;
public record AdminUserDetailsDto(
Guid Id,
string Username,
string DisplayName,
string? Bio,
string? Avatar,
DateTime CreatedAt,
bool IsOnline,
DateTime LastOnlineAt,
AdminUserStatsDto Stats
);
@@ -0,0 +1,14 @@
using System;
namespace Host.Models;
public record AdminUserDto(
Guid Id,
string Username,
string DisplayName,
string? Email,
string? Avatar,
DateTime CreatedAt,
bool IsOnline,
DateTime LastOnlineAt
);
@@ -0,0 +1,9 @@
namespace Host.Models;
public record AdminUserStatsDto(
int MessagesCount,
int MediaCount,
int FilesCount,
int LinksCount,
long StorageUsedBytes
);
@@ -0,0 +1,7 @@
namespace Host.Models;
public class CleanupDryRunResultDto
{
public int OrphanedMessagesCount { get; set; }
public long OrphanedMediaBytes { get; set; }
}
@@ -0,0 +1,20 @@
using System;
namespace Host.Models;
public record AuthResponseDto(
string Token,
AuthUserDto User
);
public record AuthUserDto(
Guid Id,
string Username,
string DisplayName,
string? Email,
string? Bio,
string? Avatar,
DateTime? Birthday,
bool IsOnline,
DateTime CreatedAt
);
@@ -0,0 +1,6 @@
namespace Host.Models;
public class ResetPasswordDto
{
public string NewPassword { get; set; } = string.Empty;
}
@@ -0,0 +1,26 @@
using Knot.Shared.Kernel.Configuration;
namespace Host.Models;
public record PublicConfigDto
{
public bool EnableCalls { get; init; }
public bool EnableKlipy { get; init; }
public int MaxFileSizeMb { get; init; }
public int MaxGroupMembers { get; init; }
public bool EnableConfederation { get; init; }
public bool EnableRegistration { get; init; }
public static PublicConfigDto FromSettings(SystemSettingsDto settings)
{
return new PublicConfigDto
{
EnableCalls = settings.EnableCalls,
EnableKlipy = settings.EnableKlipy,
MaxFileSizeMb = settings.MaxFileSizeMb,
MaxGroupMembers = settings.MaxGroupMembers,
EnableConfederation = settings.EnableConfederation,
EnableRegistration = settings.EnableRegistration
};
}
}
@@ -0,0 +1,13 @@
using System;
namespace Host.Models;
public record FriendDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen,
Guid FriendshipId
);
@@ -0,0 +1,16 @@
using System;
namespace Host.Models;
public record FriendRequestDto(
Guid Id,
FriendUserDto User,
DateTime CreatedAt
);
public record FriendUserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar
);
@@ -0,0 +1,5 @@
using System;
namespace Host.Models;
public record SendFriendRequest(Guid FriendId);
@@ -0,0 +1,9 @@
using System;
using System.Collections.Generic;
namespace Host.Models;
public record AnalyzeImportResponseDto(
Guid Token,
List<string> Names
);
@@ -0,0 +1,10 @@
using System;
using System.Collections.Generic;
namespace Host.Models;
public record ExecuteImportRequest(
Guid Token,
Dictionary<string, Guid> Mapping,
string? GroupName
);
@@ -0,0 +1,9 @@
using System;
namespace Host.Models;
public record ExecuteImportResponseDto(
bool Success,
int MessagesImported,
Guid ChatId
);
@@ -0,0 +1,5 @@
using System;
namespace Host.Models;
public record AddStoryReactionRequest(string Emoji);
@@ -0,0 +1,5 @@
using System;
namespace Host.Models;
public record AddStoryReplyRequest(string Content);
@@ -0,0 +1,5 @@
using System;
namespace Host.Models;
public record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor);
@@ -0,0 +1,5 @@
using System;
namespace Host.Models;
public record RemoveStoryReactionRequest(string Emoji);
@@ -0,0 +1,10 @@
using System;
using System.Collections.Generic;
namespace Host.Models;
public record StoryGroupDto(
StoryUserDto User,
List<StoryDto> Stories,
bool HasUnviewed
);
@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
namespace Host.Models;
public record StoryDto(
Guid Id,
string Type,
string? MediaUrl,
string? Content,
string? BgColor,
DateTime CreatedAt,
DateTime ExpiresAt,
int ViewCount,
bool Viewed,
List<StoryReactionDto> Reactions,
int ReplyCount
);
@@ -0,0 +1,10 @@
using System;
namespace Host.Models;
public record StoryReactionDto(
Guid Id,
Guid UserId,
string Emoji,
DateTime CreatedAt
);
@@ -0,0 +1,13 @@
using System;
namespace Host.Models;
public record StoryReplyDto(
Guid Id,
Guid UserId,
string Username,
string DisplayName,
string? Avatar,
string Content,
DateTime CreatedAt
);
@@ -0,0 +1,10 @@
using System;
namespace Host.Models;
public record StoryUserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar
);
@@ -0,0 +1,11 @@
using System;
namespace Host.Models;
public record StoryViewerDto(
Guid UserId,
string Username,
string DisplayName,
string? Avatar,
DateTime ViewedAt
);
@@ -0,0 +1,5 @@
using System;
namespace Host.Models;
public sealed record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);
@@ -0,0 +1,3 @@
namespace Host.Models;
public sealed record UpdateSettingsRequest(bool? HideStoryViews);
+12
View File
@@ -0,0 +1,12 @@
using System;
namespace Host.Models;
public record UserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen
);
@@ -0,0 +1,16 @@
using System;
namespace Host.Models;
public record UserProfileDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
string? Bio,
DateTime? Birthday,
DateTime CreatedAt,
bool? HideStoryViews = null,
bool IsOnline = false,
DateTime? LastSeen = null
);
+210
View File
@@ -0,0 +1,210 @@
using Carter;
using Knot.Shared.Infrastructure;
using Knot.Modules.Identity;
using Knot.Modules.Chats;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using System.IdentityModel.Tokens.Jwt;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.FileProviders;
using Microsoft.AspNetCore.SignalR;
using System.Security.Claims;
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
var builder = WebApplication.CreateBuilder(args);
// Регистрация модулей
// Маппинг стандартных переменных окружения в иерархию .NET
var envMappings = new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = builder.Configuration["DATABASE_URL"],
["ConnectionStrings:MongoConnection"] = builder.Configuration["MONGO_CONNECTION"],
["Jwt:Secret"] = builder.Configuration["JWT_SECRET"],
["Jwt:Issuer"] = builder.Configuration["JWT_ISSUER"],
["Jwt:Audience"] = builder.Configuration["JWT_AUDIENCE"],
["WebRtc:TurnUrl"] = builder.Configuration["TURN_URL"],
["WebRtc:TurnUsername"] = builder.Configuration["TURN_USERNAME"],
["WebRtc:TurnPassword"] = builder.Configuration["TURN_PASSWORD"],
["KNOT_MASTER_ENCRYPTION_KEY"] = builder.Configuration["KNOT_MASTER_ENCRYPTION_KEY"],
["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"],
["KNOT_ADMIN_USER"] = builder.Configuration["KNOT_ADMIN_USER"],
["KNOT_ADMIN_PASSWORD"] = builder.Configuration["KNOT_ADMIN_PASSWORD"]
};
// Добавляем только те, что реально заданы в ENV
builder.Configuration.AddInMemoryCollection(
envMappings.Where(kv => !string.IsNullOrEmpty(kv.Value))
.ToDictionary(kv => kv.Key, kv => kv.Value));
builder.Services.AddIdentityModule(builder.Configuration);
builder.Services.AddChatsModule(builder.Configuration);
builder.Services.AddSharedInfrastructure(builder.Configuration);
// CQRS / MediatR для команд в Host (например, AdminController)
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
// Carter для вызова Minimal APIs (Endpoints)
builder.Services.AddCarter();
// Настройка CORS
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
var originsFromConfig = builder.Configuration["Cors:Origins"];
var domain = builder.Configuration["DOMAIN"];
var origins = !string.IsNullOrEmpty(originsFromConfig)
? originsFromConfig.Split(',')
: (!string.IsNullOrEmpty(domain) ? new[] { $"https://{domain}" } : new[] { "*" });
var corsBuilder = policy.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod();
if (origins.Contains("*"))
{
// The CORS protocol does not allow specifying a wildcard (any) origin and credentials at the same time.
}
else
{
corsBuilder.AllowCredentials();
}
});
});
// Настройка Swagger/OpenAPI
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Настройка маршрутизации
builder.Services.AddRouting(options =>
{
options.LowercaseUrls = true;
options.LowercaseQueryStrings = true;
});
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient();
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddSignalR()
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddSingleton<IUserIdProvider, CustomUserIdProvider>();
// Настройка JWT Аутентификации
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
// Применяем миграции при старте
using (var scope = app.Services.CreateScope())
{
var identityDb = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
await identityDb.Database.MigrateAsync();
var chatsDb = scope.ServiceProvider.GetRequiredService<ChatsDbContext>();
await chatsDb.Database.MigrateAsync();
var systemDb = scope.ServiceProvider.GetRequiredService<Knot.Shared.Infrastructure.Persistence.SystemDbContext>();
await systemDb.Database.MigrateAsync();
// Set Encryption Service for MongoDB serializers
var encryptionService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Security.IEncryptionService>();
Knot.Modules.Chats.Infrastructure.Persistence.Mongo.EncryptedStringSerializer.EncryptionService = encryptionService;
// 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();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// app.UseHttpsRedirection();
// Не раздаем статические файлы, так как теперь используем MinIO
app.UseAuthentication();
app.UseAuthorization();
// Добавляем контроллеры и Carter (Minimal APIs)
app.MapControllers();
app.MapCarter();
// Добавляем SignalR хабы
app.MapHub<ChatHub>("/hubs/chat");
app.Run();
public class CustomUserIdProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext connection)
{
return connection.User?.FindFirstValue("sub") ?? connection.User?.FindFirstValue(ClaimTypes.NameIdentifier);
}
}
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5059",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7124;http://localhost:5059",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass"
},
"Jwt": {
"Secret": "knot_super_secret_key_1234567890_knot",
"Issuer": "Knot",
"Audience": "KnotUsers",
"ExpiryInMinutes": 1440
},
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
}