Очистка мусора

This commit is contained in:
Халимов Рустам
2026-03-15 21:36:49 +03:00
parent c14999d319
commit 893c9f2316
424 changed files with 689 additions and 493340 deletions
@@ -0,0 +1,116 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Modules.Identity.Application.Users.Register;
using Knot.Modules.Identity.Application.Users.Login;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
namespace Host.Controllers;
[ApiController]
[Route("api/[controller]")]
public sealed class AuthController : ControllerBase
{
private readonly ISender _sender;
private readonly IJwtTokenProvider _tokenProvider;
private readonly IUserRepository _userRepository;
public AuthController(ISender sender, IJwtTokenProvider tokenProvider, IUserRepository userRepository)
{
_sender = sender;
_tokenProvider = tokenProvider;
_userRepository = userRepository;
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterUserCommand command)
{
Result<Guid> result = await _sender.Send(command);
if (result.IsFailure)
{
return BadRequest(new { error = result.Error.Description, code = result.Error.Code });
}
// Получаем созданного пользователя для генерации токена и возврата данных
var user = await _userRepository.GetByIdAsync(result.Value, default);
if (user is null) return BadRequest(new { error = "Не удалось получить созданного пользователя" });
string token = _tokenProvider.Generate(user);
return Ok(new
{
Token = token,
User = new
{
user.Id,
user.Username,
user.DisplayName,
user.Email,
user.Bio,
user.Avatar,
user.Birthday,
IsOnline = true,
user.CreatedAt
}
});
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> GetMe([FromServices] IUserContext userContext)
{
var user = await _userRepository.GetByIdAsync(userContext.UserId, default);
if (user is null) return NotFound();
return Ok(new
{
User = new
{
user.Id,
user.Username,
user.DisplayName,
user.Email,
user.Bio,
user.Avatar,
user.Birthday,
IsOnline = true,
user.CreatedAt
}
});
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginUserCommand command)
{
Result<string> result = await _sender.Send(command);
if (result.IsFailure)
{
return Unauthorized(new { error = result.Error.Description, code = result.Error.Code });
}
// Получаем данные пользователя
var user = await _userRepository.GetByUsernameAsync(command.Username, default);
if (user is null) return Unauthorized();
return Ok(new
{
Token = result.Value,
User = new
{
user.Id,
user.Username,
user.DisplayName,
user.Email,
user.Bio,
user.Avatar,
user.Birthday,
IsOnline = true,
user.CreatedAt
}
});
}
}
@@ -0,0 +1,439 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Application.Chats.Create;
using Knot.Modules.Chats.Application.Messages.Send;
using Knot.Modules.Chats.Application.Chats.GetOrCreateFavorites;
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Chats.Infrastructure.Persistence;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/chats")]
public sealed class ChatsController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
private readonly IUserRepository _userRepository;
public ChatsController(ISender sender, IUserContext userContext, IUserRepository userRepository)
{
_sender = sender;
_userContext = userContext;
_userRepository = userRepository;
}
[HttpGet]
public async Task<IActionResult> GetChats([FromServices] IChatRepository chatRepository, [FromServices] IMessageRepository messageRepository, CancellationToken ct)
{
var chats = await chatRepository.GetUserChatsAsync(_userContext.UserId, ct);
var result = new List<object>();
bool hasFavorites = false;
foreach (var c in chats)
{
if (c.Type == ChatType.Favorites)
{
if (hasFavorites) continue;
hasFavorites = true;
}
var members = new List<object>();
foreach (var m in c.Members)
{
var user = await _userRepository.GetByIdAsync(m.UserId, ct);
members.Add(new
{
id = m.Id,
userId = m.UserId,
role = m.Role,
isPinned = m.IsPinned,
user = user != null ? new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
isOnline = false,
lastSeen = DateTime.UtcNow
} : null
});
}
var chatMessages = await messageRepository.GetChatMessagesAsync(c.Id, 1, 0, ct);
var messagesList = new List<object>();
if (chatMessages.Any())
{
var m = chatMessages.First();
var senderObj = await _userRepository.GetByIdAsync(m.SenderId, ct);
var reactionsWithUser = new List<object>();
foreach (var r in m.Reactions)
{
var rUser = await _userRepository.GetByIdAsync(r.UserId, ct);
reactionsWithUser.Add(new
{
id = r.Id,
emoji = r.Emoji,
userId = r.UserId,
user = rUser != null
? new { id = rUser.Id, username = rUser.Username, displayName = rUser.DisplayName }
: new { id = r.UserId, username = "unknown", displayName = "Unknown" }
});
}
messagesList.Add(new
{
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.ReplyToId,
m.Quote,
m.StoryId,
m.StoryMediaUrl,
m.StoryMediaType,
m.IsEdited,
m.IsDeleted,
m.CreatedAt,
Media = m.Media.ToList(),
Sender = senderObj != null ? new {
id = senderObj.Id,
username = senderObj.Username,
displayName = senderObj.DisplayName,
avatar = senderObj.Avatar
} : new { id = m.SenderId, username = "unknown", displayName = "Unknown", avatar = (string?)null },
reactions = reactionsWithUser,
ReadBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList()
});
}
result.Add(new
{
id = c.Id,
type = c.Type.ToString().ToLowerInvariant(),
name = c.Type == ChatType.Favorites ? "Избранное" : (c.Type == ChatType.Personal ? null : c.Name),
description = c.Description,
avatar = c.Avatar,
createdAt = c.CreatedAt,
members = members,
messages = messagesList,
unreadCount = 0
});
}
return Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateChatRequest request)
{
var command = new CreateChatCommand(request.Name, request.Type, request.MemberIds);
Result<Guid> result = await _sender.Send(command);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.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 });
Result<Guid> result = await _sender.Send(command, ct);
if (result.IsFailure) return BadRequest(result.Error);
return Ok(await MapChatAsync(result.Value, ct));
}
[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);
Result<Guid> result = await _sender.Send(command, ct);
if (result.IsFailure) return BadRequest(result.Error);
return Ok(await MapChatAsync(result.Value, ct));
}
[HttpPost("favorites")]
public async Task<IActionResult> GetOrCreateFavorites(CancellationToken ct)
{
var command = new GetOrCreateFavoritesCommand(_userContext.UserId);
Result<Guid> result = await _sender.Send(command, ct);
if (result.IsFailure) return BadRequest(result.Error);
return Ok(await MapChatAsync(result.Value, ct));
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> UpdateChat(Guid id, [FromBody] UpdateChatRequest request, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
if (request.Name != null) chat.UpdateName(request.Name);
if (request.Description != null) chat.UpdateDescription(request.Description);
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
return Ok(await MapChatAsync(id, ct));
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> LeaveOrDeleteChat(Guid id, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
if (chat.Type == ChatType.Group)
{
chat.RemoveMember(_userContext.UserId);
chatRepository.Update(chat);
}
else
{
chatRepository.Remove(chat);
}
await uow.SaveChangesAsync(ct);
return Ok(new { success = true });
}
[HttpPost("{id:guid}/clear")]
public async Task<IActionResult> ClearChat(Guid id, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
// Mark clear timestamp per member — simplified: just return success
return Ok(new { message = "Cleared" });
}
[HttpPost("{id:guid}/pin")]
public async Task<IActionResult> TogglePin(Guid id, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null) return NotFound();
var member = chat.Members.FirstOrDefault(m => m.UserId == _userContext.UserId);
if (member == null) return NotFound();
member.TogglePin();
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
return Ok(new { isPinned = member.IsPinned });
}
[HttpPost("{id:guid}/members")]
public async Task<IActionResult> AddMembers(Guid id, [FromBody] AddMembersRequest request, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
foreach (var userId in request.UserIds)
{
chat.AddMember(userId);
}
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
return Ok(await MapChatAsync(id, ct));
}
[HttpDelete("{id:guid}/members/{userId:guid}")]
public async Task<IActionResult> RemoveMember(Guid id, Guid userId, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
chat.RemoveMember(userId);
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
return Ok(await MapChatAsync(id, ct));
}
[HttpPost("{id:guid}/avatar")]
public async Task<IActionResult> UploadGroupAvatar(Guid id, IFormFile avatar, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
if (avatar == null || avatar.Length == 0) return BadRequest("No file");
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "avatars");
if (!Directory.Exists(uploadsPath)) Directory.CreateDirectory(uploadsPath);
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(avatar.FileName)}";
var filePath = Path.Combine(uploadsPath, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
await avatar.CopyToAsync(stream, ct);
var url = $"/uploads/avatars/{fileName}";
chat.UpdateAvatar(url);
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
return Ok(await MapChatAsync(id, ct));
}
[HttpPost("{id:guid}/avatar/crop")]
public async Task<IActionResult> CropGroupAvatar(Guid id, [FromForm] IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
if (avatar == null || avatar.Length == 0) return BadRequest("No file");
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "avatars");
if (!Directory.Exists(uploadsPath)) Directory.CreateDirectory(uploadsPath);
var fileName = $"{Guid.NewGuid()}.jpg";
var filePath = Path.Combine(uploadsPath, fileName);
try
{
using (var inputStream = avatar.OpenReadStream())
using (var image = await SixLabors.ImageSharp.Image.LoadAsync(inputStream))
{
int startX = Math.Max(0, Math.Min(x, image.Width - 1));
int startY = Math.Max(0, Math.Min(y, image.Height - 1));
int rectWidth = Math.Max(1, Math.Min(width, image.Width - startX));
int rectHeight = Math.Max(1, Math.Min(height, image.Height - startY));
image.Mutate(ctx => ctx.Crop(new SixLabors.ImageSharp.Rectangle(startX, startY, rectWidth, rectHeight)));
image.Mutate(ctx => ctx.Resize(400, 400));
await image.SaveAsJpegAsync(filePath, ct);
}
}
catch (Exception ex)
{
return StatusCode(500, "Error processing image: " + ex.Message);
}
var url = $"/uploads/avatars/{fileName}";
chat.UpdateAvatar(url);
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
return Ok(await MapChatAsync(id, ct));
}
[HttpDelete("{id:guid}/avatar")]
public async Task<IActionResult> RemoveGroupAvatar(Guid id, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
chat.UpdateAvatar(null);
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
return Ok(await MapChatAsync(id, ct));
}
private async Task<object> MapChatAsync(Guid chatId, CancellationToken ct)
{
var chatRepository = HttpContext.RequestServices.GetRequiredService<IChatRepository>();
var messageRepository = HttpContext.RequestServices.GetRequiredService<IMessageRepository>();
var chat = await chatRepository.GetByIdAsync(chatId, ct);
if (chat == null) return new { };
var members = new List<object>();
foreach (var m in chat.Members)
{
var user = await _userRepository.GetByIdAsync(m.UserId, ct);
members.Add(new
{
id = m.Id,
userId = m.UserId,
role = m.Role,
isPinned = m.IsPinned,
user = user != null ? new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
isOnline = false,
lastSeen = DateTime.UtcNow
} : null
});
}
var chatMessages = await messageRepository.GetChatMessagesAsync(chatId, 1, 0, ct);
var messagesList = new List<object>();
if (chatMessages.Any())
{
var m = chatMessages.First();
var senderObj = await _userRepository.GetByIdAsync(m.SenderId, ct);
messagesList.Add(new
{
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.ReplyToId,
m.Quote,
m.StoryId,
m.StoryMediaUrl,
m.StoryMediaType,
m.IsEdited,
m.IsDeleted,
m.CreatedAt,
Media = m.Media.ToList(),
Sender = senderObj != null ? new {
id = senderObj.Id,
username = senderObj.Username,
displayName = senderObj.DisplayName,
avatar = senderObj.Avatar
} : new { id = m.SenderId, username = "unknown", displayName = "Unknown", avatar = (string?)null },
ReadBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList()
});
}
return new
{
id = chat.Id,
type = chat.Type.ToString().ToLowerInvariant(),
name = chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
description = chat.Description,
avatar = chat.Avatar,
createdAt = chat.CreatedAt,
members = members,
messages = messagesList,
unreadCount = 0
};
}
}
public sealed record CreateChatRequest(string Name, ChatType Type, List<Guid> MemberIds);
public sealed record CreatePersonalChatRequest(Guid UserId);
public sealed record CreateGroupChatRequest(string Name, List<Guid> MemberIds);
public sealed record UpdateChatRequest(string? Name, string? Description);
public sealed record AddMembersRequest(List<Guid> UserIds);
@@ -0,0 +1,199 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Application.Abstractions;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/friends")]
public sealed class FriendsController : ControllerBase
{
private readonly IdentityDbContext _context;
private readonly IUserContext _userContext;
private readonly IUserRepository _userRepository;
public FriendsController(IdentityDbContext context, IUserContext userContext, IUserRepository userRepository)
{
_context = context;
_userContext = userContext;
_userRepository = userRepository;
}
[HttpGet]
public async Task<IActionResult> GetFriends(CancellationToken ct)
{
var currentUserId = _userContext.UserId;
var friendships = await _context.Friendships
.Where(f => (f.UserId == currentUserId || f.FriendId == currentUserId) && f.Status == FriendshipStatus.Accepted)
.ToListAsync(ct);
var friendIds = friendships.Select(f => f.UserId == currentUserId ? f.FriendId : f.UserId).ToList();
var friends = new List<object>();
foreach (var id in friendIds)
{
var user = await _userRepository.GetByIdAsync(id, ct);
if (user == null) continue;
var fs = friendships.First(f => f.UserId == id || f.FriendId == id);
friends.Add(new
{
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
isOnline = false,
lastSeen = DateTime.UtcNow,
friendshipId = fs.Id
});
}
return Ok(friends);
}
[HttpGet("requests")]
public async Task<IActionResult> GetRequests(CancellationToken ct)
{
var currentUserId = _userContext.UserId;
var friendships = await _context.Friendships
.Where(f => f.FriendId == currentUserId && f.Status == FriendshipStatus.Pending)
.ToListAsync(ct);
var requests = new List<object>();
foreach (var fs in friendships)
{
var user = await _userRepository.GetByIdAsync(fs.UserId, ct);
if (user == null) continue;
requests.Add(new
{
id = fs.Id,
user = new
{
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar
},
createdAt = fs.CreatedAt
});
}
return Ok(requests);
}
[HttpGet("outgoing")]
public async Task<IActionResult> GetOutgoingRequests(CancellationToken ct)
{
var currentUserId = _userContext.UserId;
var friendships = await _context.Friendships
.Where(f => f.UserId == currentUserId && f.Status == FriendshipStatus.Pending)
.ToListAsync(ct);
var requests = new List<object>();
foreach (var fs in friendships)
{
var user = await _userRepository.GetByIdAsync(fs.FriendId, ct);
if (user == null) continue;
requests.Add(new
{
id = fs.Id,
user = new
{
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar
},
createdAt = fs.CreatedAt
});
}
return Ok(requests);
}
[HttpPost("request")]
public async Task<IActionResult> SendRequest([FromBody] SendFriendRequest request, CancellationToken ct)
{
var currentUserId = _userContext.UserId;
if (currentUserId == request.FriendId) return BadRequest("Cannot add yourself");
var existing = await _context.Friendships
.FirstOrDefaultAsync(f => (f.UserId == currentUserId && f.FriendId == request.FriendId) ||
(f.UserId == request.FriendId && f.FriendId == currentUserId), ct);
if (existing != null) return BadRequest("Friendship already exists");
var friendship = Friendship.Create(currentUserId, request.FriendId);
_context.Friendships.Add(friendship);
await _context.SaveChangesAsync(ct);
return Ok(new { status = "pending" });
}
[HttpPost("{id:guid}/accept")]
public async Task<IActionResult> AcceptRequest(Guid id, CancellationToken ct)
{
var friendship = await _context.Friendships.FindAsync(new object[] { id }, ct);
if (friendship == null || friendship.FriendId != _userContext.UserId) return NotFound();
friendship.Accept();
await _context.SaveChangesAsync(ct);
return Ok(new { id = friendship.Id });
}
[HttpPost("{id:guid}/decline")]
public async Task<IActionResult> DeclineRequest(Guid id, CancellationToken ct)
{
var friendship = await _context.Friendships.FindAsync(new object[] { id }, ct);
if (friendship == null || friendship.FriendId != _userContext.UserId) return NotFound();
friendship.Decline();
await _context.SaveChangesAsync(ct);
return Ok(new { success = true });
}
[HttpGet("status/{userId:guid}")]
public async Task<IActionResult> GetStatus(Guid userId, CancellationToken ct)
{
var currentUserId = _userContext.UserId;
if (currentUserId == userId) return Ok(new { status = "self" });
var fs = await _context.Friendships
.FirstOrDefaultAsync(f => (f.UserId == currentUserId && f.FriendId == userId) ||
(f.UserId == userId && f.FriendId == currentUserId), ct);
if (fs == null) return Ok(new { status = "none" });
return Ok(new
{
status = fs.Status.ToString().ToLower(),
friendshipId = fs.Id,
direction = fs.UserId == currentUserId ? "outgoing" : "incoming"
});
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> RemoveFriend(Guid id, CancellationToken ct)
{
var currentUserId = _userContext.UserId;
var friendship = await _context.Friendships.FindAsync(new object[] { id }, ct);
if (friendship == null || (friendship.UserId != currentUserId && friendship.FriendId != currentUserId))
return NotFound();
_context.Friendships.Remove(friendship);
await _context.SaveChangesAsync(ct);
return Ok(new { success = true });
}
}
public sealed record SendFriendRequest(Guid FriendId);
@@ -0,0 +1,317 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.Messages.Send;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
using System.Linq;
using System.Text.RegularExpressions;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/messages")]
public sealed class MessagesController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
private readonly IUserRepository _userRepository;
public MessagesController(ISender sender, IUserContext userContext, IUserRepository userRepository)
{
_sender = sender;
_userContext = userContext;
_userRepository = userRepository;
}
[HttpGet("chat/{chatId:guid}")]
public async Task<IActionResult> GetMessages(Guid chatId, [FromServices] IMessageRepository messageRepository, [FromQuery] string? cursor, CancellationToken ct)
{
var messages = await messageRepository.GetChatMessagesAsync(chatId, 50, 0, ct);
// Filter out messages deleted for this user
messages = messages.Where(m => !m.DeletedByUsers.Contains(_userContext.UserId)).ToList();
// Collect all user IDs needed (senders, forwarded from)
var userIds = messages.Select(m => m.SenderId).ToList();
userIds.AddRange(messages.Where(m => m.ForwardedFromId.HasValue).Select(m => m.ForwardedFromId!.Value));
var senders = new Dictionary<Guid, Knot.Modules.Identity.Domain.User>();
foreach (var id in userIds.Distinct())
{
var user = await _userRepository.GetByIdAsync(id, ct);
if (user != null) senders[id] = user;
}
var result = new List<object>();
foreach (var m in messages)
{
object? replyToObj = null;
if (m.ReplyToId.HasValue)
{
var replyMsg = await messageRepository.GetByIdAsync(m.ReplyToId.Value, ct);
if (replyMsg != null)
{
var replySender = await _userRepository.GetByIdAsync(replyMsg.SenderId, ct);
replyToObj = new
{
id = replyMsg.Id,
content = replyMsg.Content,
isDeleted = replyMsg.IsDeleted,
media = replyMsg.Media.Select(rm => new { rm.Id, rm.Type, rm.Url }).ToList(),
sender = replySender != null ? new { id = replySender.Id, username = replySender.Username, displayName = replySender.DisplayName } : null
};
}
}
var reactionsWithUser = new List<object>();
foreach (var r in m.Reactions)
{
var rUser = await _userRepository.GetByIdAsync(r.UserId, ct);
reactionsWithUser.Add(new
{
id = r.Id,
emoji = r.Emoji,
userId = r.UserId,
user = rUser != null
? new { id = rUser.Id, username = rUser.Username, displayName = rUser.DisplayName }
: new { id = r.UserId, username = "unknown", displayName = "Unknown" }
});
}
result.Add(new
{
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.ReplyToId,
replyTo = replyToObj,
m.Quote,
m.IsEdited,
m.IsDeleted,
m.CreatedAt,
forwardedFromId = m.ForwardedFromId,
forwardedFrom = m.ForwardedFromId.HasValue && senders.TryGetValue(m.ForwardedFromId.Value, out var fwd) ? new {
id = fwd.Id,
username = fwd.Username,
displayName = fwd.DisplayName,
avatar = fwd.Avatar
} : null,
storyId = m.StoryId,
storyMediaUrl = m.StoryMediaUrl,
storyMediaType = m.StoryMediaType,
media = m.Media.Select(media => new {
media.Id,
media.Type,
media.Url,
filename = media.Filename,
size = media.Size
}).ToList(),
sender = senders.TryGetValue(m.SenderId, out var s) ? new {
id = s.Id,
username = s.Username,
displayName = s.DisplayName,
avatar = s.Avatar
} : new { id = m.SenderId, username = "unknown", displayName = "Unknown", avatar = (string?)null },
reactions = reactionsWithUser,
readBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList()
});
}
return Ok(result);
}
[HttpGet("search")]
public async Task<IActionResult> GetSearch([FromServices] IMessageRepository messageRepository, [FromQuery] string q, [FromQuery] Guid? chatId, CancellationToken ct)
{
var messages = await messageRepository.SearchMessagesAsync(q, chatId, ct);
// Filter out messages deleted for this user
messages = messages.Where(m => !m.DeletedByUsers.Contains(_userContext.UserId)).ToList();
var userIds = messages.Select(m => m.SenderId).ToList();
userIds.AddRange(messages.Where(m => m.ForwardedFromId.HasValue).Select(m => m.ForwardedFromId!.Value));
var senders = new Dictionary<Guid, Knot.Modules.Identity.Domain.User>();
foreach (var id in userIds.Distinct())
{
var user = await _userRepository.GetByIdAsync(id, ct);
if (user != null) senders[id] = user;
}
var result = messages.Select(m => new
{
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.ReplyToId,
m.Quote,
m.IsEdited,
m.IsDeleted,
m.CreatedAt,
forwardedFromId = m.ForwardedFromId,
forwardedFrom = m.ForwardedFromId.HasValue && senders.TryGetValue(m.ForwardedFromId.Value, out var fwd) ? new {
id = fwd.Id,
username = fwd.Username,
displayName = fwd.DisplayName,
avatar = fwd.Avatar
} : null,
storyId = m.StoryId,
storyMediaUrl = m.StoryMediaUrl,
storyMediaType = m.StoryMediaType,
media = m.Media.Select(media => new {
media.Id,
media.Type,
media.Url,
filename = media.Filename,
size = media.Size
}).ToList(),
sender = senders.TryGetValue(m.SenderId, out var s) ? new {
id = s.Id,
username = s.Username,
displayName = s.DisplayName,
avatar = s.Avatar
} : new { id = m.SenderId, username = "unknown", displayName = "Unknown", avatar = (string?)null },
reactions = m.Reactions.Select(r => new { r.UserId, r.Emoji }).ToList(),
readBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList()
});
return Ok(result);
}
[HttpPost("upload")]
public async Task<IActionResult> UploadFile(IFormFile file)
{
if (file == null || file.Length == 0) return BadRequest("No file uploaded");
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
if (!Directory.Exists(uploadsPath)) Directory.CreateDirectory(uploadsPath);
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
var filePath = Path.Combine(uploadsPath, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
return Ok(new { url = $"/uploads/{fileName}", filename = file.FileName, size = file.Length });
}
[HttpGet("chat/{chatId:guid}/shared")]
public async Task<IActionResult> GetSharedMedia(Guid chatId, [FromServices] IMessageRepository messageRepository, [FromQuery] string? type, CancellationToken ct)
{
var messages = await messageRepository.GetChatMessagesAsync(chatId, 300, 0, ct);
// Filter out deleted messages
messages = messages.Where(m => !m.IsDeleted && !m.DeletedByUsers.Contains(_userContext.UserId)).ToList();
var result = new List<object>();
var filterType = type?.ToLower();
foreach (var m in messages)
{
if (filterType == "links")
{
var linkRegex = new Regex(@"https?://[^\s]+", RegexOptions.IgnoreCase);
var contentLinks = !string.IsNullOrEmpty(m.Content) ? linkRegex.Matches(m.Content).Select(match => match.Value).ToList() : new List<string>();
var mediaLinks = (m.Media ?? Enumerable.Empty<Media>()).Where(media => media.Type?.ToLower() == "link").Select(media => media.Url).ToList();
var allLinks = contentLinks.Concat(mediaLinks).Distinct().ToList();
if (allLinks.Any())
{
var sender = await _userRepository.GetByIdAsync(m.SenderId, ct);
result.Add(new
{
m.Id,
m.Content,
m.CreatedAt,
links = allLinks,
sender = sender != null ? new { sender.Id, sender.Username, sender.DisplayName, sender.Avatar } : null
});
}
continue;
}
if (m.Media == null || !m.Media.Any()) continue;
var filteredMedia = m.Media.Where(media => {
var mediaType = media.Type?.ToLower() ?? "file";
if (filterType == "media") return mediaType == "image" || mediaType == "video";
if (filterType == "files") return mediaType != "image" && mediaType != "video" && mediaType != "link";
return true;
}).ToList();
if (filteredMedia.Any())
{
var sender = await _userRepository.GetByIdAsync(m.SenderId, ct);
result.Add(new
{
m.Id,
m.ReplyToId,
m.Quote,
m.StoryId,
m.StoryMediaUrl,
m.StoryMediaType,
m.IsEdited,
m.Content,
m.Type,
m.CreatedAt,
media = filteredMedia.Select(media => new {
media.Id,
media.Type,
media.Url,
filename = media.Filename,
size = media.Size
}).ToList(),
sender = sender != null ? new { sender.Id, sender.Username, sender.DisplayName, sender.Avatar } : null
});
}
}
return Ok(result.OrderByDescending(x => ((dynamic)x).CreatedAt).ToList());
}
[HttpPost("chat/{chatId:guid}")]
public async Task<IActionResult> SendMessage(Guid chatId, [FromBody] SendMessageRequest request)
{
var attachments = request.Attachments?.Select(a =>
new Knot.Modules.Chats.Application.Messages.Send.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);
Result<Guid> result = await _sender.Send(command);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
}
public sealed record SendMessageRequest(
string? Content,
string Type,
List<AttachmentDto>? Attachments = null,
Guid? ReplyToId = null,
string? Quote = null,
Guid? ForwardedFromId = null);
public sealed record AttachmentDto(string Type, string Url, string? FileName, long? FileSize);
@@ -0,0 +1,544 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Application.Abstractions;
using Microsoft.Extensions.Logging;
using MediatR;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Messages.Send;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/stories")]
public sealed class StoriesController : ControllerBase
{
private readonly IdentityDbContext _context;
private readonly IUserContext _userContext;
private readonly IUserRepository _userRepository;
private readonly IChatRepository _chatRepository;
private readonly ISender _sender;
private readonly IHubContext<Knot.Modules.Chats.Infrastructure.SignalR.ChatHub> _hubContext;
private readonly IMessageRepository _messageRepository;
private readonly ILogger<StoriesController> _logger;
public StoriesController(
IdentityDbContext context,
IUserContext userContext,
IUserRepository userRepository,
IChatRepository chatRepository,
IMessageRepository messageRepository,
ISender sender,
IHubContext<Knot.Modules.Chats.Infrastructure.SignalR.ChatHub> hubContext,
ILogger<StoriesController> logger)
{
_context = context;
_userContext = userContext;
_userRepository = userRepository;
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_sender = sender;
_hubContext = hubContext;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> GetStories(CancellationToken ct)
{
var currentUserId = _userContext.UserId;
var friendships = await _context.Set<Friendship>()
.Where(f => f.Status == FriendshipStatus.Accepted && (f.UserId == currentUserId || f.FriendId == currentUserId))
.ToListAsync(ct);
var friendIds = friendships.Select(f => f.UserId == currentUserId ? f.FriendId : f.UserId).ToList();
friendIds.Add(currentUserId);
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(ct);
var groups = stories.GroupBy(s => s.UserId).ToList();
var result = new List<object>();
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, ct);
if (u != null) userMap[uid] = u;
}
foreach (var group in groups)
{
if (!userMap.TryGetValue(group.Key, out var user)) continue;
result.Add(new
{
user = new
{
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar
},
stories = group.Select(s => new
{
id = s.Id,
type = s.Type,
mediaUrl = s.MediaUrl,
content = s.Content,
bgColor = s.BgColor,
createdAt = s.CreatedAt,
expiresAt = s.ExpiresAt,
viewCount = s.Viewers.Count,
viewed = s.Viewers.Any(v => v.UserId == currentUserId),
reactions = s.Reactions.Select(r => new
{
id = r.Id,
userId = r.UserId,
emoji = r.Emoji,
createdAt = r.CreatedAt
}).ToList(),
replyCount = s.Replies.Count
}).OrderBy(s => s.createdAt).ToList(),
hasUnviewed = group.Any(s => !s.Viewers.Any(v => v.UserId == currentUserId))
});
}
var finalResult = result.OrderBy(r =>
{
var rDynamic = (dynamic)r;
if ((Guid)rDynamic.user.id == currentUserId) return 0;
return 1;
}).ToList();
return Ok(finalResult);
}
[HttpPost]
public async Task<IActionResult> CreateStory([FromBody] CreateStoryRequest request, CancellationToken ct)
{
var story = Story.Create(
_userContext.UserId,
request.Type,
request.MediaUrl,
request.Content,
request.BgColor);
_context.Stories.Add(story);
await _context.SaveChangesAsync(ct);
return Ok(new { id = story.Id });
}
[HttpGet("user/{userId}")]
public async Task<IActionResult> GetUserStories(Guid userId, CancellationToken ct)
{
var currentUserId = _userContext.UserId;
var stories = await _context.Stories
.Include(s => s.Viewers)
.Include(s => s.Reactions)
.Include(s => s.Replies)
.Where(s => s.UserId == userId)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync(ct);
var user = await _userRepository.GetByIdAsync(userId, ct);
if (user == null) return NotFound();
var result = new
{
user = new
{
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar
},
stories = stories.Select(s => new
{
id = s.Id,
type = s.Type,
mediaUrl = s.MediaUrl,
content = s.Content,
bgColor = s.BgColor,
createdAt = s.CreatedAt,
expiresAt = s.ExpiresAt,
viewCount = s.Viewers.Count,
viewed = s.Viewers.Any(v => v.UserId == currentUserId),
reactions = s.Reactions.Select(r => new
{
id = r.Id,
userId = r.UserId,
emoji = r.Emoji,
createdAt = r.CreatedAt
}).ToList(),
replyCount = s.Replies.Count
}).ToList()
};
return Ok(result);
}
[HttpPost("{id}/view")]
public async Task<IActionResult> ViewStory(Guid id, CancellationToken ct)
{
_logger.LogInformation("ViewStory called: StoryId={StoryId}, UserId={UserId}", id, _userContext.UserId);
try
{
var story = await _context.Stories
.Include(s => s.Viewers)
.FirstOrDefaultAsync(s => s.Id == id, ct);
if (story == null)
{
_logger.LogWarning("Story not found: {StoryId}", id);
return NotFound();
}
if (story.UserId == _userContext.UserId)
{
_logger.LogInformation("Owner view, skipping");
return Ok(new { message = "Owner view" });
}
if (!story.Viewers.Any(v => v.UserId == _userContext.UserId))
{
story.AddViewer(_userContext.UserId);
await _context.SaveChangesAsync(ct);
_logger.LogInformation("Viewer added. Total viewers: {Count}", story.Viewers.Count);
// Notify owner via SignalR - send to all, filter on client
var viewer = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
_logger.LogInformation("Sending story_viewed to owner {OwnerId}", story.UserId);
// Get updated story with viewers to be sure count is accurate
var updatedStory = await _context.Stories.Include(s => s.Viewers).FirstAsync(s => s.Id == story.Id, ct);
await _hubContext.Clients.All.SendAsync("story_viewed", new
{
storyId = story.Id,
userId = _userContext.UserId,
username = viewer?.Username,
displayName = viewer?.DisplayName,
avatar = viewer?.Avatar,
viewedAt = DateTime.UtcNow,
viewCount = updatedStory.Viewers.Count,
ownerId = story.UserId
}, ct);
_logger.LogInformation("story_viewed sent successfully");
}
else
{
_logger.LogInformation("User already viewed this story");
}
return Ok(new { message = "Story viewed" });
}
catch (DbUpdateException ex)
{
_logger.LogError(ex, "DbUpdateException in ViewStory");
return Ok(new { message = "Story already viewed" });
}
catch (Exception ex)
{
_logger.LogError(ex, "ViewStory error");
return StatusCode(500, ex.Message);
}
}
[HttpGet("{id}/viewers")]
public async Task<IActionResult> GetStoryViewers(Guid id, CancellationToken ct)
{
_logger.LogInformation("GetStoryViewers called: StoryId={StoryId}, UserId={UserId}", id, _userContext.UserId);
var story = await _context.Stories
.Include(s => s.Viewers)
.FirstOrDefaultAsync(s => s.Id == id, ct);
if (story == null)
{
_logger.LogWarning("Story not found: {StoryId}", id);
return NotFound();
}
if (story.UserId != _userContext.UserId)
{
_logger.LogWarning("Forbidden: User {UserId} is not owner of story {StoryId}", _userContext.UserId, id);
return Forbid();
}
_logger.LogInformation("Story has {Count} viewers", story.Viewers.Count);
var viewerIds = story.Viewers.Select(v => v.UserId).ToList();
var viewers = new List<object>();
foreach (var viewerId in viewerIds)
{
var user = await _userRepository.GetByIdAsync(viewerId, ct);
if (user == null) continue;
var viewerRecord = story.Viewers.First(v => v.UserId == viewerId);
viewers.Add(new
{
userId = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
viewedAt = viewerRecord.ViewedAt
});
}
_logger.LogInformation("Returning {Count} viewers", viewers.Count);
return Ok(viewers);
}
private string GetStoryQuote(Story story)
{
if (!string.IsNullOrEmpty(story.Content)) return story.Content;
return story.Type.ToLower() switch
{
"image" => "🖼 Фото",
"video" => "🎬 Видео",
_ => "История"
};
}
[HttpPost("{id}/reaction")]
public async Task<IActionResult> AddReaction(Guid id, [FromBody] AddStoryReactionRequest request, CancellationToken ct)
{
_logger.LogInformation("AddReaction called: StoryId={StoryId}, UserId={UserId}, Emoji={Emoji}", id, _userContext.UserId, request.Emoji);
try
{
var story = await _context.Stories.FindAsync(new object[] { id }, ct);
if (story == null)
{
_logger.LogWarning("Story not found: {StoryId}", id);
return NotFound();
}
// Check if reaction already exists
var existing = await _context.StoryReactions
.FirstOrDefaultAsync(r => r.StoryId == id && r.UserId == _userContext.UserId && r.Emoji == request.Emoji, ct);
if (existing != null)
{
_logger.LogInformation("Reaction already exists");
return Ok(new { message = "Reaction already exists" });
}
// Add reaction directly via DbSet
var reaction = new StoryReaction(id, _userContext.UserId, request.Emoji);
_context.StoryReactions.Add(reaction);
await _context.SaveChangesAsync(ct);
_logger.LogInformation("Reaction saved successfully");
// 1. Create/Find chat
var chatId = await GetOrCreatePersonalChatIdAsync(_userContext.UserId, story.UserId, ct);
// 2. Threading: find last story message in this chat
var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, ct);
if (lastStoryMessage != null)
{
// If message already exists for this story, add a reaction to it
var addReactionCommand = new Knot.Modules.Chats.Application.Messages.React.AddReactionCommand(
lastStoryMessage.Id, _userContext.UserId, request.Emoji, chatId);
await _sender.Send(addReactionCommand, ct);
}
else
{
// Create new message for the story
var storyQuote = GetStoryQuote(story);
var messageCommand = new SendMessageCommand(
ChatId: chatId,
SenderId: _userContext.UserId,
Content: request.Emoji,
Type: "text",
Quote: storyQuote,
StoryId: story.Id,
StoryMediaUrl: story.MediaUrl,
StoryMediaType: story.Type);
await _sender.Send(messageCommand, ct);
}
// 3. Notify story owner in real-time (existing StoryViewer listeners)
var reactor = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
await _hubContext.Clients.All.SendAsync("story_reaction", new
{
storyId = story.Id,
userId = _userContext.UserId,
username = reactor?.Username,
displayName = reactor?.DisplayName,
avatar = reactor?.Avatar,
emoji = request.Emoji,
createdAt = DateTime.UtcNow,
ownerId = story.UserId
}, ct);
return Ok(new { message = "Reaction added" });
}
catch (Exception ex)
{
_logger.LogError(ex, "AddReaction error");
return StatusCode(500, ex.Message);
}
}
[HttpDelete("{id}/reaction")]
public async Task<IActionResult> RemoveReaction(Guid id, [FromBody] RemoveStoryReactionRequest request, CancellationToken ct)
{
var reaction = await _context.StoryReactions
.FirstOrDefaultAsync(r => r.StoryId == id && r.UserId == _userContext.UserId && r.Emoji == request.Emoji, ct);
if (reaction == null) return Ok(new { message = "Reaction not found" });
_context.StoryReactions.Remove(reaction);
await _context.SaveChangesAsync(ct);
return Ok(new { message = "Reaction removed" });
}
[HttpPost("{id}/reply")]
public async Task<IActionResult> AddReply(Guid id, [FromBody] AddStoryReplyRequest request, CancellationToken ct)
{
_logger.LogInformation("AddReply called: StoryId={StoryId}, UserId={UserId}, Content={Content}", id, _userContext.UserId, request.Content);
try
{
var story = await _context.Stories.FindAsync(new object[] { id }, ct);
if (story == null)
{
_logger.LogWarning("Story not found: {StoryId}", id);
return NotFound();
}
// Add reply directly via DbSet
var reply = new StoryReply(id, _userContext.UserId, request.Content);
_context.StoryReplies.Add(reply);
await _context.SaveChangesAsync(ct);
_logger.LogInformation("Reply saved successfully");
// 1. Create/Find chat
var chatId = await GetOrCreatePersonalChatIdAsync(_userContext.UserId, story.UserId, ct);
// 2. Find last message for threading
var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, ct);
// 3. Send message to chat
var storyQuote = GetStoryQuote(story);
var messageCommand = new SendMessageCommand(
ChatId: chatId,
SenderId: _userContext.UserId,
Content: request.Content,
Type: "text",
Quote: storyQuote,
ReplyToId: lastStoryMessage?.Id,
StoryId: story.Id,
StoryMediaUrl: story.MediaUrl,
StoryMediaType: story.Type);
await _sender.Send(messageCommand, ct);
// 3. Notify story owner in real-time (existing StoryViewer listeners)
var replier = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
await _hubContext.Clients.All.SendAsync("story_reply", new
{
storyId = story.Id,
userId = _userContext.UserId,
username = replier?.Username,
displayName = replier?.DisplayName,
avatar = replier?.Avatar,
content = request.Content,
createdAt = DateTime.UtcNow,
ownerId = story.UserId
}, ct);
return Ok(new { message = "Reply added" });
}
catch (Exception ex)
{
_logger.LogError(ex, "AddReply error");
return StatusCode(500, ex.Message);
}
}
[HttpGet("{id}/replies")]
public async Task<IActionResult> GetReplies(Guid id, CancellationToken ct)
{
var story = await _context.Stories
.Include(s => s.Replies)
.FirstOrDefaultAsync(s => s.Id == id, ct);
if (story == null) return NotFound();
if (story.UserId != _userContext.UserId) return Forbid();
var replies = new List<object>();
foreach (var reply in story.Replies.OrderBy(r => r.CreatedAt))
{
var user = await _userRepository.GetByIdAsync(reply.UserId, ct);
if (user == null) continue;
replies.Add(new
{
id = reply.Id,
userId = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
content = reply.Content,
createdAt = reply.CreatedAt
});
}
return Ok(replies);
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteStory(Guid id, CancellationToken ct)
{
var story = await _context.Stories.FindAsync(new object[] { id }, ct);
if (story == null) return NotFound();
if (story.UserId != _userContext.UserId) return Forbid();
_context.Stories.Remove(story);
await _context.SaveChangesAsync(ct);
return Ok(new { message = "Story deleted" });
}
private async Task<Guid> GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken ct)
{
var userChats = await _chatRepository.GetUserChatsAsync(userId1, ct);
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, ct);
return result.Value;
}
}
public sealed record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor);
public sealed record AddStoryReactionRequest(string Emoji);
public sealed record RemoveStoryReactionRequest(string Emoji);
public sealed record AddStoryReplyRequest(string Content);
@@ -0,0 +1,239 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Application.Abstractions;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/users")]
public sealed class UsersController : ControllerBase
{
private readonly IUserRepository _userRepository;
private readonly IUserContext _userContext;
private readonly IIdentityUnitOfWork _unitOfWork;
public UsersController(IUserRepository userRepository, IUserContext userContext, IIdentityUnitOfWork unitOfWork)
{
_userRepository = userRepository;
_userContext = userContext;
_unitOfWork = unitOfWork;
}
[HttpGet("search")]
public async Task<IActionResult> Search([FromQuery] string q, CancellationToken ct)
{
var users = await _userRepository.SearchUsersAsync(q, ct);
var result = users.Select(user => new
{
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
isOnline = false,
lastSeen = DateTime.UtcNow
}).ToList();
return Ok(result);
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings([FromBody] UpdateSettingsRequest request, CancellationToken ct)
{
var user = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
if (user == null) return NotFound();
user.UpdateSettings(request.HideStoryViews ?? user.HideStoryViews);
await _unitOfWork.SaveChangesAsync(ct);
return Ok(new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
bio = user.Bio,
birthday = user.Birthday,
createdAt = user.CreatedAt,
hideStoryViews = user.HideStoryViews
});
}
[HttpPost("avatar")]
public async Task<IActionResult> UploadAvatar(IFormFile avatar, CancellationToken ct)
{
var user = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
if (user == null) return NotFound();
string? avatarUrl = null;
if (avatar != null && avatar.Length > 0)
{
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "avatars");
if (!Directory.Exists(uploadsPath)) Directory.CreateDirectory(uploadsPath);
var ext = Path.GetExtension(avatar.FileName);
var fileName = $"{Guid.NewGuid()}{ext}";
var filePath = Path.Combine(uploadsPath, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await avatar.CopyToAsync(stream, ct);
}
avatarUrl = $"/uploads/avatars/{fileName}";
}
else if (Request.Form.Files.Count > 0)
{
var file = Request.Form.Files[0];
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "avatars");
if (!Directory.Exists(uploadsPath)) Directory.CreateDirectory(uploadsPath);
var ext = Path.GetExtension(file.FileName);
var fileName = $"{Guid.NewGuid()}{ext}";
var filePath = Path.Combine(uploadsPath, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream, ct);
}
avatarUrl = $"/uploads/avatars/{fileName}";
}
else
{
return BadRequest("No file uploaded");
}
user.UpdateAvatar(avatarUrl);
await _unitOfWork.SaveChangesAsync(ct);
return Ok(new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
bio = user.Bio,
birthday = user.Birthday,
createdAt = user.CreatedAt
});
}
[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)
{
var user = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
if (user == null) return NotFound();
if (avatar == null || avatar.Length == 0) return BadRequest("No file");
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "avatars");
if (!Directory.Exists(uploadsPath)) Directory.CreateDirectory(uploadsPath);
var ext = ".jpg";
var fileName = $"{Guid.NewGuid()}{ext}";
var filePath = Path.Combine(uploadsPath, fileName);
try
{
using (var inputStream = avatar.OpenReadStream())
using (var image = await SixLabors.ImageSharp.Image.LoadAsync(inputStream))
{
// Clamp coordinates to image bounds
int startX = Math.Max(0, Math.Min(x, image.Width - 1));
int startY = Math.Max(0, Math.Min(y, image.Height - 1));
int rectWidth = Math.Max(1, Math.Min(width, image.Width - startX));
int rectHeight = Math.Max(1, Math.Min(height, image.Height - startY));
image.Mutate(ctx => ctx.Crop(new SixLabors.ImageSharp.Rectangle(startX, startY, rectWidth, rectHeight)));
image.Mutate(ctx => ctx.Resize(400, 400));
await image.SaveAsJpegAsync(filePath, ct);
}
}
catch (Exception ex)
{
return StatusCode(500, "Error processing image: " + ex.Message);
}
var avatarUrl = $"/uploads/avatars/{fileName}";
user.UpdateAvatar(avatarUrl);
await _unitOfWork.SaveChangesAsync(ct);
return Ok(new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
bio = user.Bio,
birthday = user.Birthday,
createdAt = user.CreatedAt
});
}
[HttpDelete("avatar")]
public async Task<IActionResult> DeleteAvatar(CancellationToken ct)
{
var user = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
if (user == null) return NotFound();
user.UpdateAvatar(null);
await _unitOfWork.SaveChangesAsync(ct);
return Ok(new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
bio = user.Bio,
birthday = user.Birthday,
createdAt = user.CreatedAt
});
}
[HttpPut("profile")]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request, CancellationToken ct)
{
var user = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
if (user == null) return NotFound();
user.UpdateProfile(request.DisplayName ?? user.DisplayName, request.Bio, request.Birthday);
await _unitOfWork.SaveChangesAsync(ct);
return Ok(new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
bio = user.Bio,
birthday = user.Birthday,
createdAt = user.CreatedAt
});
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetUser(Guid id, CancellationToken ct)
{
var user = await _userRepository.GetByIdAsync(id, ct);
if (user == null) return NotFound();
return Ok(new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
bio = user.Bio,
birthday = user.Birthday,
createdAt = user.CreatedAt,
isOnline = false,
lastSeen = DateTime.UtcNow
});
}
}
public sealed record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);
public sealed record UpdateSettingsRequest(bool? HideStoryViews);
@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/webrtc")]
public sealed class WebRtcController : ControllerBase
{
private readonly IConfiguration _configuration;
public WebRtcController(IConfiguration configuration)
{
_configuration = configuration;
}
[HttpGet("ice-servers")]
public IActionResult GetIceServers()
{
var turnUrl = _configuration["WebRtc:TurnUrl"];
var turnUsername = _configuration["WebRtc:TurnUsername"];
var turnPassword = _configuration["WebRtc:TurnPassword"];
var iceServers = new List<object>();
if (!string.IsNullOrEmpty(turnUrl) && !string.IsNullOrEmpty(turnUsername) && !string.IsNullOrEmpty(turnPassword))
{
// Use the VPS server as both STUN and TURN
var stunUrl = turnUrl.Replace("turn:", "stun:");
iceServers.Add(new { urls = new[] { stunUrl } });
iceServers.Add(new
{
urls = new[] { turnUrl, turnUrl + "?transport=tcp" },
username = turnUsername,
credential = turnPassword,
});
}
return Ok(new { iceServers });
}
}
+33
View File
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<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.AspNetCore.SignalR.Common" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<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
###
+179
View File
@@ -0,0 +1,179 @@
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:Database"] = builder.Configuration["DATABASE_URL"],
["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"]
};
// Добавляем только те, что реально заданы в 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();
// Настройка 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[] { "*" });
policy.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
// Настройка Swagger/OpenAPI
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Настройка маршрутизации
builder.Services.AddRouting(options =>
{
options.LowercaseUrls = true;
options.LowercaseQueryStrings = true;
});
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();
}
// Настройка конвейера запросов
app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.ExceptionHandlingMiddleware>();
app.UseCors();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// app.UseHttpsRedirection();
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
if (!Directory.Exists(uploadsPath))
{
Directory.CreateDirectory(uploadsPath);
}
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(uploadsPath),
RequestPath = "/uploads"
});
app.UseAuthentication();
app.UseAuthorization();
// Добавляем контроллеры
app.MapControllers();
// Добавляем 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"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Database": "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
}
}