Шифрование и хранилище minio

This commit is contained in:
Халимов Рустам
2026-03-15 23:30:38 +03:00
parent 893c9f2316
commit 336f9ea559
60 changed files with 551 additions and 168 deletions
Binary file not shown.
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
Determining projects to restore...
All projects are up-to-date for restore.
C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Chats.UnitTests\Knot.Modules.Chats.UnitTests.csproj]
C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Identity.UnitTests\Knot.Modules.Identity.UnitTests.csproj]
C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\Knot.Modules.Identity.csproj]
C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj]
C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Knot.Modules.Chats.csproj]
C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj]
Knot.Shared.Kernel -> E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Kernel\bin\Debug\net10.0\Knot.Shared.Kernel.dll
E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\Domain\Story.cs(23,15): warning CS8618: Non-nullable property 'Type' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable. [E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\Knot.Modules.Identity.csproj]
E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Storage\S3FileStorageService.cs(116,27): warning CS8600: Converting null literal or possible null value to non-nullable type. [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj]
E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Storage\S3FileStorageService.cs(142,47): warning CS8604: Possible null reference argument for parameter 's' in 'byte[] Convert.FromBase64String(string s)'. [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj]
Knot.Modules.Identity -> E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\bin\Debug\net10.0\Knot.Modules.Identity.dll
Knot.Shared.Infrastructure -> E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\bin\Debug\net10.0\Knot.Shared.Infrastructure.dll
Knot.Modules.Chats -> E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\bin\Debug\net10.0\Knot.Modules.Chats.dll
Knot.Modules.Identity.UnitTests -> E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Identity.UnitTests\bin\Debug\net10.0\Knot.Modules.Identity.UnitTests.dll
Knot.Modules.Chats.UnitTests -> E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Chats.UnitTests\bin\Debug\net10.0\Knot.Modules.Chats.UnitTests.dll
Build succeeded.
E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\Domain\Story.cs(23,15): warning CS8618: Non-nullable property 'Type' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable. [E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\Knot.Modules.Identity.csproj]
E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Storage\S3FileStorageService.cs(116,27): warning CS8600: Converting null literal or possible null value to non-nullable type. [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj]
E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Storage\S3FileStorageService.cs(142,47): warning CS8604: Possible null reference argument for parameter 's' in 'byte[] Convert.FromBase64String(string s)'. [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj]
3 Warning(s)
0 Error(s)
Time Elapsed 00:00:01.32
+23
View File
@@ -0,0 +1,23 @@
Determining projects to restore...
All projects are up-to-date for restore.
C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Knot.Modules.Chats.csproj]
C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj]
C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Chats.UnitTests\Knot.Modules.Chats.UnitTests.csproj]
C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Identity.UnitTests\Knot.Modules.Identity.UnitTests.csproj]
C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\Knot.Modules.Identity.csproj]
C:\Program Files\dotnet\sdk\10.0.100-preview.6.25358.103\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(335,5): message NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy [E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj]
Knot.Shared.Kernel -> E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Kernel\bin\Debug\net10.0\Knot.Shared.Kernel.dll
Knot.Modules.Identity -> E:\GIT\forkmessager\apps\server-net\src\Modules\Identity\bin\Debug\net10.0\Knot.Modules.Identity.dll
Knot.Shared.Infrastructure -> E:\GIT\forkmessager\apps\server-net\src\Shared\Knot.Shared.Infrastructure\bin\Debug\net10.0\Knot.Shared.Infrastructure.dll
Knot.Modules.Identity.UnitTests -> E:\GIT\forkmessager\apps\server-net\tests\Knot.Modules.Identity.UnitTests\bin\Debug\net10.0\Knot.Modules.Identity.UnitTests.dll
E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Infrastructure\Persistence\ChatsDbContext.cs(16,34): error CS0234: The type or namespace name 'Infrastructure' does not exist in the namespace 'Knot.Shared' (are you missing an assembly reference?) [E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Knot.Modules.Chats.csproj]
E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Infrastructure\Persistence\ChatsDbContext.cs(18,101): error CS0234: The type or namespace name 'Infrastructure' does not exist in the namespace 'Knot.Shared' (are you missing an assembly reference?) [E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Knot.Modules.Chats.csproj]
Build FAILED.
E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Infrastructure\Persistence\ChatsDbContext.cs(16,34): error CS0234: The type or namespace name 'Infrastructure' does not exist in the namespace 'Knot.Shared' (are you missing an assembly reference?) [E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Knot.Modules.Chats.csproj]
E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Infrastructure\Persistence\ChatsDbContext.cs(18,101): error CS0234: The type or namespace name 'Infrastructure' does not exist in the namespace 'Knot.Shared' (are you missing an assembly reference?) [E:\GIT\forkmessager\apps\server-net\src\Modules\Chats\Knot.Modules.Chats.csproj]
0 Warning(s)
2 Error(s)
Time Elapsed 00:00:00.89
@@ -120,6 +120,15 @@ public sealed class ChatsController : ControllerBase
});
}
if (c.Type == ChatType.Personal && messagesList.Count == 0)
{
var currentMember = c.Members.FirstOrDefault(m => m.UserId == _userContext.UserId);
if (currentMember == null || currentMember.Role != ChatRole.Owner)
{
continue; // Skip returning this empty personal chat to the non-initiator
}
}
result.Add(new
{
id = c.Id,
@@ -209,7 +218,8 @@ public sealed class ChatsController : ControllerBase
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 == null) return Ok(new { success = true }); // Already physically deleted, idempotent
if (!chat.Members.Any(m => m.UserId == _userContext.UserId)) return Forbid();
if (chat.Type == ChatType.Group)
{
@@ -280,21 +290,16 @@ public sealed class ChatsController : ControllerBase
}
[HttpPost("{id:guid}/avatar")]
public async Task<IActionResult> UploadGroupAvatar(Guid id, IFormFile avatar, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
public async Task<IActionResult> UploadGroupAvatar(Guid id, IFormFile avatar, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, 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);
using var stream = avatar.OpenReadStream();
var fileId = await fileStorage.UploadFileAsync(stream, avatar.FileName, avatar.ContentType);
var url = $"/api/files/{fileId}";
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);
@@ -303,17 +308,13 @@ public sealed class ChatsController : ControllerBase
}
[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)
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, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, 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);
string url;
try
{
@@ -327,7 +328,14 @@ public sealed class ChatsController : ControllerBase
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);
using var outStream = new MemoryStream();
await image.SaveAsJpegAsync(outStream, ct);
outStream.Position = 0;
var fileName = avatar.FileName ?? "avatar.jpg";
var fileId = await fileStorage.UploadFileAsync(outStream, fileName, "image/jpeg");
url = $"/api/files/{fileId}";
}
}
catch (Exception ex)
@@ -335,7 +343,6 @@ public sealed class ChatsController : ControllerBase
return StatusCode(500, "Error processing image: " + ex.Message);
}
var url = $"/uploads/avatars/{fileName}";
chat.UpdateAvatar(url);
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Shared.Kernel.Storage;
namespace Host.Controllers;
[ApiController]
[Route("api/files")]
public sealed class FilesController : ControllerBase
{
private readonly IFileStorageService _fileStorage;
public FilesController(IFileStorageService fileStorage)
{
_fileStorage = fileStorage;
}
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<IActionResult> DownloadFile(string id)
{
try
{
var result = await _fileStorage.DownloadFileAsync(id);
// Обратите внимание, что мы возвращаем поток с автоматическим освобождением памяти.
return File(result.Stream, result.ContentType, result.FileName);
}
catch (Exception ex)
{
return NotFound(new { error = "Файл не найден или ошибка доступа.", message = ex.Message });
}
}
}
@@ -187,22 +187,14 @@ public sealed class MessagesController : ControllerBase
}
[HttpPost("upload")]
public async Task<IActionResult> UploadFile(IFormFile file)
public async Task<IActionResult> UploadFile(IFormFile file, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage)
{
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);
}
using var stream = file.OpenReadStream();
var fileId = await fileStorage.UploadFileAsync(stream, file.FileName, file.ContentType);
return Ok(new { url = $"/uploads/{fileName}", filename = file.FileName, size = file.Length });
return Ok(new { url = $"/api/files/{fileId}", filename = file.FileName, size = file.Length });
}
[HttpGet("chat/{chatId:guid}/shared")]
@@ -64,45 +64,19 @@ public sealed class UsersController : ControllerBase
}
[HttpPost("avatar")]
public async Task<IActionResult> UploadAvatar(IFormFile avatar, CancellationToken ct)
public async Task<IActionResult> UploadAvatar(IFormFile avatar, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, CancellationToken ct)
{
var user = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
if (user == null) return NotFound();
string? avatarUrl = null;
var fileToUpload = avatar != null && avatar.Length > 0 ? avatar : Request.Form.Files.Count > 0 ? Request.Form.Files[0] : null;
if (avatar != null && avatar.Length > 0)
if (fileToUpload != null)
{
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}";
using var stream = fileToUpload.OpenReadStream();
var fileId = await fileStorage.UploadFileAsync(stream, fileToUpload.FileName, fileToUpload.ContentType);
avatarUrl = $"/api/files/{fileId}";
}
else
{
@@ -124,26 +98,20 @@ public sealed class UsersController : ControllerBase
}
[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)
public async Task<IActionResult> CropAvatar([FromForm] IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, 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);
string avatarUrl;
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));
@@ -151,8 +119,14 @@ public sealed class UsersController : ControllerBase
image.Mutate(ctx => ctx.Crop(new SixLabors.ImageSharp.Rectangle(startX, startY, rectWidth, rectHeight)));
image.Mutate(ctx => ctx.Resize(400, 400));
using var outStream = new MemoryStream();
await image.SaveAsJpegAsync(outStream, ct);
outStream.Position = 0;
await image.SaveAsJpegAsync(filePath, ct);
var fileName = avatar.FileName ?? "avatar.jpg";
var id = await fileStorage.UploadFileAsync(outStream, fileName, "image/jpeg");
avatarUrl = $"/api/files/{id}";
}
}
catch (Exception ex)
@@ -160,7 +134,6 @@ public sealed class UsersController : ControllerBase
return StatusCode(500, "Error processing image: " + ex.Message);
}
var avatarUrl = $"/uploads/avatars/{fileName}";
user.UpdateAvatar(avatarUrl);
await _unitOfWork.SaveChangesAsync(ct);
+8 -13
View File
@@ -31,7 +31,12 @@ var envMappings = new Dictionary<string, string?>
["Jwt:Audience"] = builder.Configuration["JWT_AUDIENCE"],
["WebRtc:TurnUrl"] = builder.Configuration["TURN_URL"],
["WebRtc:TurnUsername"] = builder.Configuration["TURN_USERNAME"],
["WebRtc:TurnPassword"] = builder.Configuration["TURN_PASSWORD"]
["WebRtc:TurnPassword"] = builder.Configuration["TURN_PASSWORD"],
["KNOT_MASTER_ENCRYPTION_KEY"] = builder.Configuration["KNOT_MASTER_ENCRYPTION_KEY"],
["S3_ENDPOINT"] = builder.Configuration["S3_ENDPOINT"],
["S3_ACCESS_KEY"] = builder.Configuration["S3_ACCESS_KEY"],
["S3_SECRET_KEY"] = builder.Configuration["S3_SECRET_KEY"],
["S3_BUCKET"] = builder.Configuration["S3_BUCKET"]
};
// Добавляем только те, что реально заданы в ENV
@@ -41,7 +46,7 @@ builder.Configuration.AddInMemoryCollection(
builder.Services.AddIdentityModule(builder.Configuration);
builder.Services.AddChatsModule(builder.Configuration);
builder.Services.AddSharedInfrastructure();
builder.Services.AddSharedInfrastructure(builder.Configuration);
// Настройка CORS
builder.Services.AddCors(options =>
@@ -147,17 +152,7 @@ if (app.Environment.IsDevelopment())
// 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"
});
// Не раздаем статические файлы, так как теперь используем MinIO
app.UseAuthentication();
app.UseAuthorization();
@@ -31,7 +31,7 @@ public sealed class CreateChatCommandHandler : ICommandHandler<CreateChatCommand
for (int i = 0; i < request.MemberIds.Count; i++)
{
var userId = request.MemberIds[i];
var role = (i == 0 && request.Type == ChatType.Group) ? ChatRole.Admin : ChatRole.Member;
var role = (i == 0) ? ChatRole.Owner : ChatRole.Member;
chat.AddMember(userId, role);
}
+1 -1
View File
@@ -1,4 +1,4 @@
namespace Knot.Modules.Chats;
namespace Knot.Modules.Chats;
public class Class1
{
@@ -13,13 +13,13 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence;
public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
{
private readonly IMediator _mediator;
private readonly Knot.Shared.Kernel.Security.IEncryptionService _encryptionService;
public ChatsDbContext(DbContextOptions<ChatsDbContext> options, IMediator mediator)
public ChatsDbContext(DbContextOptions<ChatsDbContext> options, IMediator mediator, Knot.Shared.Kernel.Security.IEncryptionService encryptionService)
: base(options)
{
_mediator = mediator;
_encryptionService = encryptionService;
}
public DbSet<Chat> Chats => Set<Chat>();
@@ -59,6 +59,12 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
builder.ToTable("Messages");
builder.HasKey(m => m.Id);
builder.Property(m => m.Content)
.HasConversion(
v => v == null ? null : _encryptionService.EncryptMessage(v),
v => v == null ? null : _encryptionService.DecryptMessage(v)
);
builder.OwnsMany(m => m.Media, mb =>
{
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
namespace Knot.Modules.Identity;
namespace Knot.Modules.Identity;
public class Class1
{
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
namespace Knot.Modules.Identity.Infrastructure.Persistence;
@@ -14,11 +15,13 @@ namespace Knot.Modules.Identity.Infrastructure.Persistence;
public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork
{
private readonly IMediator _mediator;
private readonly IEncryptionService _encryptionService;
public IdentityDbContext(DbContextOptions<IdentityDbContext> options, IMediator mediator)
public IdentityDbContext(DbContextOptions<IdentityDbContext> options, IMediator mediator, IEncryptionService encryptionService)
: base(options)
{
_mediator = mediator;
_encryptionService = encryptionService;
}
public DbSet<User> Users => Set<User>();
@@ -52,6 +55,12 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork
builder.HasKey(s => s.Id);
builder.Property(s => s.Type).IsRequired();
builder.Property(s => s.Content)
.HasConversion(
v => v == null ? null : _encryptionService.EncryptMessage(v),
v => v == null ? null : _encryptionService.DecryptMessage(v)
);
// Configure backing fields for collections
builder.Metadata.FindNavigation(nameof(Story.Viewers))?.SetPropertyAccessMode(PropertyAccessMode.Field);
@@ -92,7 +101,14 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork
{
builder.ToTable("StoryReplies");
builder.HasKey(r => r.Id);
builder.Property(r => r.Content).IsRequired().HasMaxLength(500);
builder.Property(r => r.Content)
.IsRequired()
.HasMaxLength(500)
.HasConversion(
v => v == null ? null : _encryptionService.EncryptMessage(v),
v => v == null ? null : _encryptionService.DecryptMessage(v)
);
});
modelBuilder.Entity<User>(builder =>
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,4 +1,4 @@
namespace Knot.Shared.Infrastructure;
namespace Knot.Shared.Infrastructure;
public class Class1
{
@@ -1,15 +1,45 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
using Knot.Shared.Kernel.Storage;
using Minio;
using System;
namespace Knot.Shared.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddSharedInfrastructure(this IServiceCollection services)
public static IServiceCollection AddSharedInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services.AddHttpContextAccessor();
services.AddScoped<IUserContext, UserContext>();
// Настройка шифрования
var masterKey = configuration["KNOT_MASTER_ENCRYPTION_KEY"]
?? throw new ArgumentNullException("KNOT_MASTER_ENCRYPTION_KEY is missing in env.");
services.AddSingleton<IEncryptionService>(new AesEncryptionService(masterKey));
// Настройка MinIO (S3)
var s3Endpoint = configuration["S3_ENDPOINT"] ?? "minio:9000";
var s3AccessKey = configuration["S3_ACCESS_KEY"] ?? "admin";
var s3SecretKey = configuration["S3_SECRET_KEY"] ?? "KnotSuperSecretMinioPassword";
var s3Bucket = configuration["S3_BUCKET"] ?? "knot-uploads";
services.AddMinio(configureClient => configureClient
.WithEndpoint(s3Endpoint)
.WithCredentials(s3AccessKey, s3SecretKey)
.WithSSL(false)
.Build());
services.AddScoped<IFileStorageService>(provider =>
{
var minioClient = provider.GetRequiredService<IMinioClient>();
var encService = provider.GetRequiredService<IEncryptionService>();
return new S3FileStorageService(minioClient, encService, s3Bucket);
});
return services;
}
}
@@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="Minio" Version="7.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
@@ -0,0 +1,106 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace Knot.Shared.Kernel.Security;
public class AesEncryptionService : IEncryptionService
{
private readonly byte[] _key;
public AesEncryptionService(string masterKeyBase64)
{
// Try decoding as base64 or just using UTF8 string
if (masterKeyBase64.Length == 32)
{
_key = Encoding.UTF8.GetBytes(masterKeyBase64);
}
else
{
try
{
_key = Convert.FromBase64String(masterKeyBase64);
}
catch
{
_key = Encoding.UTF8.GetBytes(masterKeyBase64.PadRight(32, '0')[..32]);
}
}
if (_key.Length != 32)
throw new ArgumentException("Мастер-ключ должен быть ровно 32 байта для AES-256.");
}
// Сообщения: AES-256-GCM
public string EncryptMessage(string plainText)
{
if (string.IsNullOrEmpty(plainText)) return plainText;
var nonce = new byte[12];
RandomNumberGenerator.Fill(nonce);
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var cipherBytes = new byte[plainBytes.Length];
var tag = new byte[16];
using var aesGcm = new AesGcm(_key, 16);
aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag);
// формат: nonce:tag:cipherText
return $"{Convert.ToBase64String(nonce)}:{Convert.ToBase64String(tag)}:{Convert.ToBase64String(cipherBytes)}";
}
public string DecryptMessage(string cipherText)
{
if (string.IsNullOrEmpty(cipherText)) return cipherText;
var parts = cipherText.Split(':');
if (parts.Length != 3) return cipherText;
try
{
var nonce = Convert.FromBase64String(parts[0]);
var tag = Convert.FromBase64String(parts[1]);
var cipherBytes = Convert.FromBase64String(parts[2]);
var plainBytes = new byte[cipherBytes.Length];
using var aesGcm = new AesGcm(_key, 16);
aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes);
return Encoding.UTF8.GetString(plainBytes);
}
catch
{
return cipherText; // Возвращаем оригинал при ошибке расшифровки (например, старые не зашифрованные сообщения)
}
}
// Потоковые файлы: AES CBC через CryptoStream
// (Примечание: Для GCM через CryptoStream нет поддержки "из коробки" в .NET при использовании Aes.Create().
// Требуется CBC+HMAC/CTR или механизм AEAD блоками. Здесь предоставляется стандартный AES-CBC для соответствия требованиям CryptoStream).
public Stream CreateEncryptionStream(Stream unencryptedOutputStream, out byte[] iv)
{
var aes = Aes.Create();
aes.KeySize = 256;
aes.Key = _key;
aes.Mode = CipherMode.CBC;
aes.GenerateIV();
iv = aes.IV;
var encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
return new CryptoStream(unencryptedOutputStream, encryptor, CryptoStreamMode.Write);
}
public Stream CreateDecryptionStream(Stream encryptedInputStream, byte[] iv)
{
var aes = Aes.Create();
aes.KeySize = 256;
aes.Key = _key;
aes.Mode = CipherMode.CBC;
aes.IV = iv;
var decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
return new CryptoStream(encryptedInputStream, decryptor, CryptoStreamMode.Read);
}
}
@@ -0,0 +1,152 @@
using Minio;
using Minio.DataModel.Args;
using Minio.Exceptions;
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading.Tasks;
using Knot.Shared.Kernel.Security;
namespace Knot.Shared.Kernel.Storage;
public class S3FileStorageService : IFileStorageService
{
private readonly IMinioClient _minioClient;
private readonly IEncryptionService _encryptionService;
private readonly string _bucketName;
public S3FileStorageService(IMinioClient minioClient, IEncryptionService encryptionService, string bucketName)
{
_minioClient = minioClient;
_encryptionService = encryptionService;
_bucketName = bucketName;
}
private async Task EnsureBucketExistsAsync()
{
try
{
var bktExistArgs = new BucketExistsArgs().WithBucket(_bucketName);
bool found = await _minioClient.BucketExistsAsync(bktExistArgs).ConfigureAwait(false);
if (!found)
{
var mkBktArgs = new MakeBucketArgs().WithBucket(_bucketName);
await _minioClient.MakeBucketAsync(mkBktArgs).ConfigureAwait(false);
}
}
catch (MinioException e)
{
Console.WriteLine($"[Bucket] Minio Error: {e.Message}");
}
}
public async Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType)
{
await EnsureBucketExistsAsync();
// 1. Вычисляем SHA-256 для Content-Addressable Storage (CAS) - дедупликация
string fileHash;
using (var sha256 = SHA256.Create())
using (var msHash = new MemoryStream())
{
var startPos = fileStream.Position;
await fileStream.CopyToAsync(msHash);
var hashBytes = sha256.ComputeHash(msHash.ToArray());
fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
// Возвращаем указатель потока в начало для последующего чтения
fileStream.Position = startPos;
}
var ext = Path.GetExtension(fileName);
var fileId = $"{fileHash}{ext}";
// 2. Шифруем файл "на лету" во временный файл
// Для больших файлов мы используем временный файл, чтобы не перегружать оперативную память (RAM)
var tempFilePath = Path.GetTempFileName();
byte[] ivParams;
try
{
using (var tempFs = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write))
using (var cryptoStream = _encryptionService.CreateEncryptionStream(tempFs, out ivParams))
{
await fileStream.CopyToAsync(cryptoStream);
}
// 3. Загружаем зашифрованный файл в MinIO
using var fileToUpload = new FileStream(tempFilePath, FileMode.Open, FileAccess.Read);
var metadata = new System.Collections.Generic.Dictionary<string, string>
{
{ "ContentType", contentType },
{ "OriginalFileName", fileName },
{ "IV", Convert.ToBase64String(ivParams) },
{ "KeyVersion", "1" },
{ "EncryptionAlgorithm", "AES-256-CBC" } // Как реализовано в потоке AES CBC
};
var putObjectArgs = new PutObjectArgs()
.WithBucket(_bucketName)
.WithObject(fileId)
.WithStreamData(fileToUpload)
.WithObjectSize(fileToUpload.Length)
.WithContentType("application/octet-stream")
.WithHeaders(metadata);
await _minioClient.PutObjectAsync(putObjectArgs).ConfigureAwait(false);
return fileId;
}
finally
{
if (File.Exists(tempFilePath))
{
File.Delete(tempFilePath);
}
}
}
public async Task<(Stream Stream, string ContentType, string FileName)> DownloadFileAsync(string fileId)
{
// Для скачивания, мы транслируем из Minio во временный файл, расшифровываем и возвращаем.
var tempFilePath = Path.GetTempFileName();
string contentType = "application/octet-stream";
string fileName = fileId;
string ivBase64 = null;
var statArgs = new StatObjectArgs().WithBucket(_bucketName).WithObject(fileId);
var stat = await _minioClient.StatObjectAsync(statArgs).ConfigureAwait(false);
if (stat.MetaData.ContainsKey("Contenttype")) contentType = stat.MetaData["Contenttype"];
if (stat.MetaData.ContainsKey("Originalfilename")) fileName = stat.MetaData["Originalfilename"];
if (stat.MetaData.ContainsKey("Iv")) ivBase64 = stat.MetaData["Iv"];
// Загружаем во временный файл (так как CryptoStream требует правильного чтения/записи)
var getObjArgs = new GetObjectArgs()
.WithBucket(_bucketName)
.WithObject(fileId)
.WithCallbackStream((stream) =>
{
using var fs = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write);
stream.CopyTo(fs);
});
await _minioClient.GetObjectAsync(getObjArgs).ConfigureAwait(false);
// Создаем поток дешифрования над байтами временного файла, оборачиваем в MemoryStream, чтобы он автоматически закрывался
// В реальном масштабируемом продакшене лучше возвращать FileStream напрямую, обернутый в crypto stream.
var msResult = new MemoryStream();
using (var fsRead = new FileStream(tempFilePath, FileMode.Open, FileAccess.Read))
{
var iv = Convert.FromBase64String(ivBase64);
using var cryptoStream = _encryptionService.CreateDecryptionStream(fsRead, iv);
await cryptoStream.CopyToAsync(msResult);
}
File.Delete(tempFilePath);
msResult.Position = 0;
return (msResult, contentType, fileName);
}
}
@@ -1,4 +1,4 @@
namespace Knot.Shared.Kernel;
namespace Knot.Shared.Kernel;
public class Class1
{
@@ -0,0 +1,16 @@
using System.IO;
namespace Knot.Shared.Kernel.Security;
public interface IEncryptionService
{
// Шифрует текст (например, сообщения) для хранения в БД
string EncryptMessage(string plainText);
string DecryptMessage(string cipherText);
// Создает поток шифрования для потоковой передачи файлов "на лету"
Stream CreateEncryptionStream(Stream unencryptedOutputStream, out byte[] iv);
// Создает поток дешифрования для чтения файлов "на лету"
Stream CreateDecryptionStream(Stream encryptedInputStream, byte[] iv);
}
@@ -0,0 +1,13 @@
using System.IO;
using System.Threading.Tasks;
namespace Knot.Shared.Kernel.Storage;
public interface IFileStorageService
{
// Загружает поток файла и возвращает его уникальный идентификатор (SHA256 хеш или GUID).
Task<string> UploadFileAsync(Stream stream, string fileName, string contentType);
// Скачивает файл и возвращает его расшифрованный поток и тип содержимого.
Task<(Stream Stream, string ContentType, string FileName)> DownloadFileAsync(string fileId);
}
+3 -3
View File
@@ -367,9 +367,9 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, x: 50, filter: 'blur(10px)' }}
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, x: 50, filter: 'blur(10px)' }}
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 50 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed right-3 top-3 bottom-3 w-[650px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
>
+12 -12
View File
@@ -244,12 +244,12 @@ const translations = {
// Misc
error: 'Ошибка',
// Friends
friends: 'Друзья',
friendRequests: 'Заявки в друзья',
friendsList: 'Список друзей',
noFriends: 'Пока нет друзей',
addFriend: 'Добавить в друзья',
removeFriend: 'Удалить из друзей',
friends: 'Контакты',
friendRequests: 'Заявки в контакты',
friendsList: 'Список контактов',
noFriends: 'Пока нет контактов',
addFriend: 'Добавить в контакты',
removeFriend: 'Удалить из контактов',
requestSent: 'Заявка отправлена',
searchFriends: 'Поиск по @username (мин. 3 символа)',
noSearchResults: 'Пользователи не найдены',
@@ -502,12 +502,12 @@ const translations = {
searchGifs: 'Search GIFs...',
trending: 'Trending',
error: 'Error',
friends: 'Friends',
friendRequests: 'Friend requests',
friendsList: 'Friends list',
noFriends: 'No friends yet',
addFriend: 'Add friend',
removeFriend: 'Remove friend',
friends: 'Contacts',
friendRequests: 'Contact requests',
friendsList: 'Contacts list',
noFriends: 'No contacts yet',
addFriend: 'Add to contacts',
removeFriend: 'Remove from contacts',
requestSent: 'Request sent',
searchFriends: 'Search by @username (min. 3 chars)',
noSearchResults: 'No users found',
-5
View File
@@ -60,21 +60,16 @@ export function connectSocket(token: string): SocketCompat {
connection.start()
.then(() => {
console.log('[SignalR] Connected successfully');
console.log('[SignalR] Connection ID:', connection?.connectionId);
})
.catch(err => console.error('[SignalR] Connection error:', err.toString()));
connection.onclose((error) => {
console.log('[SignalR] Connection closed', error);
});
connection.onreconnecting((error) => {
console.log('[SignalR] Reconnecting...', error);
});
connection.onreconnected((connectionId) => {
console.log('[SignalR] Reconnected, new connection ID:', connectionId);
});
return socketWrapper;