Шифрование и хранилище minio
This commit is contained in:
21
LICENSE
21
LICENSE
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Knot Messenger
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
BIN
apps/server-net/build_log.txt
Normal file
BIN
apps/server-net/build_log.txt
Normal file
Binary file not shown.
BIN
apps/server-net/build_log_err.txt
Normal file
BIN
apps/server-net/build_log_err.txt
Normal file
Binary file not shown.
27
apps/server-net/out.txt
Normal file
27
apps/server-net/out.txt
Normal 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
apps/server-net/out8.txt
Normal file
23
apps/server-net/out8.txt
Normal 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);
|
||||
|
||||
33
apps/server-net/src/Host/Controllers/FilesController.cs
Normal file
33
apps/server-net/src/Host/Controllers/FilesController.cs
Normal file
@@ -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);
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,12 +27,30 @@ services:
|
||||
TURN_URL: ${TURN_URL}
|
||||
TURN_USERNAME: ${TURN_USERNAME}
|
||||
TURN_PASSWORD: ${TURN_PASSWORD}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
S3_BUCKET: ${S3_BUCKET}
|
||||
KNOT_MASTER_ENCRYPTION_KEY: ${KNOT_MASTER_ENCRYPTION_KEY}
|
||||
depends_on:
|
||||
- db
|
||||
- minio
|
||||
ports:
|
||||
- "5059:8080"
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
container_name: knot-minio
|
||||
restart: always
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${S3_ACCESS_KEY}
|
||||
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY}
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
command: server /data --console-address ":9001"
|
||||
volumes:
|
||||
- ./apps/server-net/uploads:/app/uploads
|
||||
- minio_data:/data
|
||||
|
||||
web:
|
||||
build:
|
||||
@@ -51,3 +69,4 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
|
||||
Reference in New Issue
Block a user