From 473e9bcef69d620700a08bd178788d281f994b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Fri, 20 Mar 2026 01:12:00 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=B0=D1=80=D1=85?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Host/Controllers/MessagesController.cs | 2 + backend/src/Host/Program.cs | 4 +- .../src/Modules/Chats/Domain/MediaMessage.cs | 2 +- backend/src/Modules/Chats/Domain/Message.cs | 6 +- .../Handlers/MessageSentDomainEventHandler.cs | 44 +++---- .../Persistence/MessageRepository.cs | 2 + .../Storage/S3FileStorageService.cs | 121 ++++++++++-------- .../presentation/components/MessageBubble.tsx | 4 +- 8 files changed, 105 insertions(+), 80 deletions(-) diff --git a/backend/src/Host/Controllers/MessagesController.cs b/backend/src/Host/Controllers/MessagesController.cs index bb9b34e..466c776 100644 --- a/backend/src/Host/Controllers/MessagesController.cs +++ b/backend/src/Host/Controllers/MessagesController.cs @@ -53,6 +53,8 @@ public sealed class MessagesController : ControllerBase } [HttpPost("upload")] + [DisableRequestSizeLimit] + [RequestFormLimits(MultipartBodyLengthLimit = 10L * 1024 * 1024 * 1024)] // 10 GB limit for form body public async Task UploadFile(IFormFile file, CancellationToken ct) { if (file == null || file.Length == 0) diff --git a/backend/src/Host/Program.cs b/backend/src/Host/Program.cs index f077954..6ae34a4 100644 --- a/backend/src/Host/Program.cs +++ b/backend/src/Host/Program.cs @@ -174,11 +174,11 @@ using (var scope = app.Services.CreateScope()) } // Настройка конвейера запросов +app.UseCors(); + app.UseMiddleware(); app.UseMiddleware(); -app.UseCors(); - if (app.Environment.IsDevelopment()) { app.UseSwagger(); diff --git a/backend/src/Modules/Chats/Domain/MediaMessage.cs b/backend/src/Modules/Chats/Domain/MediaMessage.cs index 467eb27..310463d 100644 --- a/backend/src/Modules/Chats/Domain/MediaMessage.cs +++ b/backend/src/Modules/Chats/Domain/MediaMessage.cs @@ -10,7 +10,7 @@ public class MediaMessage : Message public string? Caption { get => Content; private set => Content = value; } public MediaType MediaType { get; private set; } // image, video, file, voice - private readonly List _media = new(); + private List _media = new(); public override IReadOnlyCollection Media => _media.AsReadOnly(); private MediaMessage() : base() diff --git a/backend/src/Modules/Chats/Domain/Message.cs b/backend/src/Modules/Chats/Domain/Message.cs index 8318245..f7bc488 100644 --- a/backend/src/Modules/Chats/Domain/Message.cs +++ b/backend/src/Modules/Chats/Domain/Message.cs @@ -36,13 +36,13 @@ public abstract class Message : AggregateRoot public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId); // ================== Связанные коллекции (общего назначения) ================== - protected readonly List _readBy = new(); + protected List _readBy = new(); public IReadOnlyCollection ReadBy => _readBy.AsReadOnly(); - protected readonly List _deletedFor = new(); + protected List _deletedFor = new(); public IReadOnlyCollection DeletedFor => _deletedFor.AsReadOnly(); - protected readonly List _reactions = new(); + protected List _reactions = new(); public IReadOnlyCollection Reactions => _reactions.AsReadOnly(); // ================== Инфраструктурный конструктор EF ================== diff --git a/backend/src/Modules/Chats/Infrastructure/Handlers/MessageSentDomainEventHandler.cs b/backend/src/Modules/Chats/Infrastructure/Handlers/MessageSentDomainEventHandler.cs index 7d7ee0f..cda3927 100644 --- a/backend/src/Modules/Chats/Infrastructure/Handlers/MessageSentDomainEventHandler.cs +++ b/backend/src/Modules/Chats/Infrastructure/Handlers/MessageSentDomainEventHandler.cs @@ -74,30 +74,30 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler new + id = message.Id, + chatId = message.ChatId, + senderId = message.SenderId, + content = message.Content, + type = message.Type, + createdAt = message.CreatedAt, + forwardedFromId = message.ForwardedFromId, + forwardedFrom = forwardedFromObj, + replyToId = message.ReplyToId, + replyTo = replyToObj, + quote = message.Quote, + media = message.Media.Select(m => new { - m.Id, - m.Type, - m.Url, - Filename = m.Filename, - Size = m.Size + id = m.Id, + type = m.Type, + url = m.Url, + filename = m.Filename, + size = m.Size }).ToList(), - Sender = senderObj, - ReadBy = new List(), - StoryId = message.StoryId, - StoryMediaUrl = message.StoryMediaUrl, - StoryMediaType = message.StoryMediaType + sender = senderObj, + readBy = new List(), + storyId = message.StoryId, + storyMediaUrl = message.StoryMediaUrl, + storyMediaType = message.StoryMediaType }, cancellationToken); } } diff --git a/backend/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs b/backend/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs index 9ca74c1..06b9890 100644 --- a/backend/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs +++ b/backend/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs @@ -114,7 +114,9 @@ public sealed class MessageRepository : IMessageRepository if (!msg.ReadBy.Any(r => r.UserId == userId)) { var receipt = new ReadReceipt(msg.Id, userId); + var pushUpdate = Builders.Update.Push("ReadBy", receipt); + var updateModel = new UpdateOneModel(Builders.Filter.Eq(m => m.Id, msg.Id), pushUpdate); writes.Add(updateModel); } diff --git a/backend/src/Shared/Knot.Shared.Infrastructure/Storage/S3FileStorageService.cs b/backend/src/Shared/Knot.Shared.Infrastructure/Storage/S3FileStorageService.cs index 177ad2f..4243191 100644 --- a/backend/src/Shared/Knot.Shared.Infrastructure/Storage/S3FileStorageService.cs +++ b/backend/src/Shared/Knot.Shared.Infrastructure/Storage/S3FileStorageService.cs @@ -45,65 +45,69 @@ public class S3FileStorageService : IFileStorageService { 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(); + var tempUnencryptedPath = Path.GetTempFileName(); + var tempEncryptedPath = Path.GetTempFileName(); byte[] ivParams; try { - using (var tempFs = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write)) - using (var cryptoStream = _encryptionService.CreateEncryptionStream(tempFs, out ivParams)) + // 1. Сохраняем входной поток (который может быть не seekable из-за HTTP/Kestrel) + // во временный файл + using (var unencryptedFs = new FileStream(tempUnencryptedPath, FileMode.Create, FileAccess.Write)) { - await fileStream.CopyToAsync(cryptoStream); + await fileStream.CopyToAsync(unencryptedFs); } - // 3. Загружаем зашифрованный файл в MinIO - using var fileToUpload = new FileStream(tempFilePath, FileMode.Open, FileAccess.Read); - var metadata = new System.Collections.Generic.Dictionary + // 2. Вычисляем SHA-256 для дедупликации, читая из локального временного файла + string fileHash; + using (var unencryptedFs = new FileStream(tempUnencryptedPath, FileMode.Open, FileAccess.Read)) + using (var sha256 = SHA256.Create()) { - { "ContentType", contentType }, - { "OriginalFileName", fileName }, - { "IV", Convert.ToBase64String(ivParams) }, - { "KeyVersion", "1" }, - { "EncryptionAlgorithm", "AES-256-CBC" } // Как реализовано в потоке AES CBC - }; + var hashBytes = await sha256.ComputeHashAsync(unencryptedFs); + fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + } - var putObjectArgs = new PutObjectArgs() - .WithBucket(_bucketName) - .WithObject(fileId) - .WithStreamData(fileToUpload) - .WithObjectSize(fileToUpload.Length) - .WithContentType("application/octet-stream") - .WithHeaders(metadata); + var ext = Path.GetExtension(fileName); + if (string.IsNullOrEmpty(ext)) ext = ""; // For files without extension + var fileId = $"{fileHash}{ext}"; - await _minioClient.PutObjectAsync(putObjectArgs).ConfigureAwait(false); + // 3. Шифруем файл "на лету" во второй временный файл + using (var unencryptedFs = new FileStream(tempUnencryptedPath, FileMode.Open, FileAccess.Read)) + using (var encryptedFs = new FileStream(tempEncryptedPath, FileMode.Create, FileAccess.Write)) + using (var cryptoStream = _encryptionService.CreateEncryptionStream(encryptedFs, out ivParams)) + { + await unencryptedFs.CopyToAsync(cryptoStream); + } + + // 4. Загружаем зашифрованный файл в MinIO + using (var fileToUpload = new FileStream(tempEncryptedPath, FileMode.Open, FileAccess.Read)) + { + var metadata = new System.Collections.Generic.Dictionary + { + { "ContentType", contentType ?? "application/octet-stream" }, + { "OriginalFileName", Uri.EscapeDataString(fileName ?? "unknown") }, + { "IV", Convert.ToBase64String(ivParams) }, + { "KeyVersion", "1" }, + { "EncryptionAlgorithm", "AES-256-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); - } + if (File.Exists(tempUnencryptedPath)) File.Delete(tempUnencryptedPath); + if (File.Exists(tempEncryptedPath)) File.Delete(tempEncryptedPath); } } @@ -118,19 +122,36 @@ public class S3FileStorageService : IFileStorageService var statArgs = new StatObjectArgs().WithBucket(_bucketName).WithObject(fileId); var stat = await _minioClient.StatObjectAsync(statArgs).ConfigureAwait(false); - if (stat.MetaData.ContainsKey("Contenttype")) + var metaData = new Dictionary(stat.MetaData, StringComparer.OrdinalIgnoreCase); + + // Function to reliably get metadata by key with or without x-amz-meta- + string? GetMeta(string key) { - contentType = stat.MetaData["Contenttype"]; + if (metaData.TryGetValue(key, out var val)) return val; + if (metaData.TryGetValue($"X-Amz-Meta-{key}", out var val2)) return val2; + return null; } - if (stat.MetaData.ContainsKey("Originalfilename")) + if (GetMeta("Contenttype") is string ct) { - fileName = stat.MetaData["Originalfilename"]; + contentType = ct; } - if (stat.MetaData.ContainsKey("Iv")) + if (GetMeta("Originalfilename") is string ofn) { - ivBase64 = stat.MetaData["Iv"]; + try + { + fileName = Uri.UnescapeDataString(ofn); + } + catch + { + fileName = ofn; // fallback to unescaped if it was somehow valid + } + } + + if (GetMeta("Iv") is string ivStr) + { + ivBase64 = ivStr; } // Загружаем во временный файл (так как CryptoStream требует правильного чтения/записи) diff --git a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx index 509d3e2..93c70a6 100644 --- a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx +++ b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx @@ -570,7 +570,7 @@ function MessageBubble({ key={m.id} src={m.url} alt="" - className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]'}`} + className={`w-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square h-full' : isSingleGif ? 'h-auto max-h-[260px]' : 'h-auto max-h-[500px]'}`} onClick={() => setLightboxData({ index: idx })} /> ) @@ -791,7 +791,7 @@ function MessageBubble({ )} {/* Реакции */} {Object.keys(reactionGroups).length > 0 && ( -
+
{Object.entries(reactionGroups).map(([emoji, data]) => (