Работа на новой архитектуре
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 ==================
|
||||||
|
|||||||
+22
-22
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user