Работа на новой архитектуре

This commit is contained in:
Халимов Рустам
2026-03-20 01:12:00 +03:00
parent a48a0b0977
commit 473e9bcef6
8 changed files with 105 additions and 80 deletions
@@ -53,6 +53,8 @@ public sealed class MessagesController : ControllerBase
} }
[HttpPost("upload")] [HttpPost("upload")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 10L * 1024 * 1024 * 1024)] // 10 GB limit for form body
public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken ct) public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken ct)
{ {
if (file == null || file.Length == 0) if (file == null || file.Length == 0)
+2 -2
View File
@@ -174,11 +174,11 @@ using (var scope = app.Services.CreateScope())
} }
// Настройка конвейера запросов // Настройка конвейера запросов
app.UseCors();
app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.ExceptionHandlingMiddleware>(); app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.ExceptionHandlingMiddleware>();
app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.AdminAuthMiddleware>(); app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.AdminAuthMiddleware>();
app.UseCors();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseSwagger(); app.UseSwagger();
@@ -10,7 +10,7 @@ public class MediaMessage : Message
public string? Caption { get => Content; private set => Content = value; } public string? Caption { get => Content; private set => Content = value; }
public MediaType MediaType { get; private set; } // image, video, file, voice public MediaType MediaType { get; private set; } // image, video, file, voice
private readonly List<Media> _media = new(); private List<Media> _media = new();
public override IReadOnlyCollection<Media> Media => _media.AsReadOnly(); public override IReadOnlyCollection<Media> Media => _media.AsReadOnly();
private MediaMessage() : base() private MediaMessage() : base()
+3 -3
View File
@@ -36,13 +36,13 @@ public abstract class Message : AggregateRoot<Guid>
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId); public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
// ================== Связанные коллекции (общего назначения) ================== // ================== Связанные коллекции (общего назначения) ==================
protected readonly List<ReadReceipt> _readBy = new(); protected List<ReadReceipt> _readBy = new();
public IReadOnlyCollection<ReadReceipt> ReadBy => _readBy.AsReadOnly(); public IReadOnlyCollection<ReadReceipt> ReadBy => _readBy.AsReadOnly();
protected readonly List<DeletedMessage> _deletedFor = new(); protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly(); public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
protected readonly List<Reaction> _reactions = new(); protected List<Reaction> _reactions = new();
public IReadOnlyCollection<Reaction> Reactions => _reactions.AsReadOnly(); public IReadOnlyCollection<Reaction> Reactions => _reactions.AsReadOnly();
// ================== Инфраструктурный конструктор EF ================== // ================== Инфраструктурный конструктор EF ==================
@@ -74,30 +74,30 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
await _hubContext.Clients.Group(notification.ChatId.ToString()) await _hubContext.Clients.Group(notification.ChatId.ToString())
.SendAsync("new_message", new .SendAsync("new_message", new
{ {
message.Id, id = message.Id,
message.ChatId, chatId = message.ChatId,
message.SenderId, senderId = message.SenderId,
Content = message.Content, content = message.Content,
Type = message.Type, type = message.Type,
message.CreatedAt, createdAt = message.CreatedAt,
message.ForwardedFromId, forwardedFromId = message.ForwardedFromId,
ForwardedFrom = forwardedFromObj, forwardedFrom = forwardedFromObj,
message.ReplyToId, replyToId = message.ReplyToId,
ReplyTo = replyToObj, replyTo = replyToObj,
Quote = message.Quote, quote = message.Quote,
Media = message.Media.Select(m => new media = message.Media.Select(m => new
{ {
m.Id, id = m.Id,
m.Type, type = m.Type,
m.Url, url = m.Url,
Filename = m.Filename, filename = m.Filename,
Size = m.Size size = m.Size
}).ToList(), }).ToList(),
Sender = senderObj, sender = senderObj,
ReadBy = new List<object>(), readBy = new List<object>(),
StoryId = message.StoryId, storyId = message.StoryId,
StoryMediaUrl = message.StoryMediaUrl, storyMediaUrl = message.StoryMediaUrl,
StoryMediaType = message.StoryMediaType storyMediaType = message.StoryMediaType
}, cancellationToken); }, cancellationToken);
} }
} }
@@ -114,7 +114,9 @@ public sealed class MessageRepository : IMessageRepository
if (!msg.ReadBy.Any(r => r.UserId == userId)) if (!msg.ReadBy.Any(r => r.UserId == userId))
{ {
var receipt = new ReadReceipt(msg.Id, userId); var receipt = new ReadReceipt(msg.Id, userId);
var pushUpdate = Builders<Message>.Update.Push("ReadBy", receipt); var pushUpdate = Builders<Message>.Update.Push("ReadBy", receipt);
var updateModel = new UpdateOneModel<Message>(Builders<Message>.Filter.Eq(m => m.Id, msg.Id), pushUpdate); var updateModel = new UpdateOneModel<Message>(Builders<Message>.Filter.Eq(m => m.Id, msg.Id), pushUpdate);
writes.Add(updateModel); writes.Add(updateModel);
} }
@@ -45,45 +45,50 @@ public class S3FileStorageService : IFileStorageService
{ {
await EnsureBucketExistsAsync(); await EnsureBucketExistsAsync();
// 1. Вычисляем SHA-256 для Content-Addressable Storage (CAS) - дедупликация var tempUnencryptedPath = Path.GetTempFileName();
string fileHash; var tempEncryptedPath = Path.GetTempFileName();
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; byte[] ivParams;
try try
{ {
using (var tempFs = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write)) // 1. Сохраняем входной поток (который может быть не seekable из-за HTTP/Kestrel)
using (var cryptoStream = _encryptionService.CreateEncryptionStream(tempFs, out ivParams)) // во временный файл
using (var unencryptedFs = new FileStream(tempUnencryptedPath, FileMode.Create, FileAccess.Write))
{ {
await fileStream.CopyToAsync(cryptoStream); await fileStream.CopyToAsync(unencryptedFs);
} }
// 3. Загружаем зашифрованный файл в MinIO // 2. Вычисляем SHA-256 для дедупликации, читая из локального временного файла
using var fileToUpload = new FileStream(tempFilePath, FileMode.Open, FileAccess.Read); string fileHash;
using (var unencryptedFs = new FileStream(tempUnencryptedPath, FileMode.Open, FileAccess.Read))
using (var sha256 = SHA256.Create())
{
var hashBytes = await sha256.ComputeHashAsync(unencryptedFs);
fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
}
var ext = Path.GetExtension(fileName);
if (string.IsNullOrEmpty(ext)) ext = ""; // For files without extension
var fileId = $"{fileHash}{ext}";
// 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<string, string> var metadata = new System.Collections.Generic.Dictionary<string, string>
{ {
{ "ContentType", contentType }, { "ContentType", contentType ?? "application/octet-stream" },
{ "OriginalFileName", fileName }, { "OriginalFileName", Uri.EscapeDataString(fileName ?? "unknown") },
{ "IV", Convert.ToBase64String(ivParams) }, { "IV", Convert.ToBase64String(ivParams) },
{ "KeyVersion", "1" }, { "KeyVersion", "1" },
{ "EncryptionAlgorithm", "AES-256-CBC" } // Как реализовано в потоке AES CBC { "EncryptionAlgorithm", "AES-256-CBC" }
}; };
var putObjectArgs = new PutObjectArgs() var putObjectArgs = new PutObjectArgs()
@@ -95,15 +100,14 @@ public class S3FileStorageService : IFileStorageService
.WithHeaders(metadata); .WithHeaders(metadata);
await _minioClient.PutObjectAsync(putObjectArgs).ConfigureAwait(false); await _minioClient.PutObjectAsync(putObjectArgs).ConfigureAwait(false);
}
return fileId; return fileId;
} }
finally finally
{ {
if (File.Exists(tempFilePath)) if (File.Exists(tempUnencryptedPath)) File.Delete(tempUnencryptedPath);
{ if (File.Exists(tempEncryptedPath)) File.Delete(tempEncryptedPath);
File.Delete(tempFilePath);
}
} }
} }
@@ -118,19 +122,36 @@ public class S3FileStorageService : IFileStorageService
var statArgs = new StatObjectArgs().WithBucket(_bucketName).WithObject(fileId); var statArgs = new StatObjectArgs().WithBucket(_bucketName).WithObject(fileId);
var stat = await _minioClient.StatObjectAsync(statArgs).ConfigureAwait(false); var stat = await _minioClient.StatObjectAsync(statArgs).ConfigureAwait(false);
if (stat.MetaData.ContainsKey("Contenttype")) var metaData = new Dictionary<string, string>(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 требует правильного чтения/записи) // Загружаем во временный файл (так как CryptoStream требует правильного чтения/записи)
@@ -570,7 +570,7 @@ function MessageBubble({
key={m.id} key={m.id}
src={m.url} src={m.url}
alt="" 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 })} onClick={() => setLightboxData({ index: idx })}
/> />
) )
@@ -791,7 +791,7 @@ function MessageBubble({
)} )}
{/* Реакции */} {/* Реакции */}
{Object.keys(reactionGroups).length > 0 && ( {Object.keys(reactionGroups).length > 0 && (
<div className={`flex flex-wrap gap-1 mt-1.5 ${isMine ? 'justify-end' : 'justify-start'}`}> <div className="flex flex-wrap gap-1 mt-1.5 justify-start">
{Object.entries(reactionGroups).map(([emoji, data]) => ( {Object.entries(reactionGroups).map(([emoji, data]) => (
<button <button
key={emoji} key={emoji}