Очистка мусора
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
@Host_HostAddress = http://localhost:5059
|
||||
|
||||
GET {{Host_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user