Перепиливание под чистый DDD

This commit is contained in:
Халимов Рустам
2026-03-22 23:59:33 +03:00
parent 5da1a2f45d
commit 6e532b021d
302 changed files with 3595 additions and 3679 deletions

View File

@@ -0,0 +1,35 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Minio;
using Knot.Shared.Kernel.Storage;
using Knot.Shared.Kernel.Security;
using Knot.Modules.Storage.Infrastructure.Storage;
using System;
namespace Knot.Modules.Storage;
public static class DependencyInjection
{
public static IServiceCollection AddStorageModule(this IServiceCollection services, IConfiguration configuration)
{
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;
}
}

View File

@@ -0,0 +1,245 @@
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;
using Knot.Shared.Kernel.Storage;
namespace Knot.Modules.Storage.Infrastructure.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();
var tempUnencryptedPath = Path.GetTempFileName();
var tempEncryptedPath = Path.GetTempFileName();
byte[] ivParams;
try
{
// 1. Сохраняем входной поток (который может быть не seekable из-за HTTP/Kestrel)
// во временный файл
using (var unencryptedFs = new FileStream(tempUnencryptedPath, FileMode.Create, FileAccess.Write))
{
await fileStream.CopyToAsync(unencryptedFs);
}
// 2. Вычисляем SHA-256 для дедупликации, читая из локального временного файла
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 actualContentType = contentType ?? "application/octet-stream";
var metadata = new System.Collections.Generic.Dictionary<string, string>
{
{ "ContentType", actualContentType },
{ "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(actualContentType)
.WithHeaders(metadata);
await _minioClient.PutObjectAsync(putObjectArgs).ConfigureAwait(false);
}
return fileId;
}
finally
{
if (File.Exists(tempUnencryptedPath)) File.Delete(tempUnencryptedPath);
if (File.Exists(tempEncryptedPath)) File.Delete(tempEncryptedPath);
}
}
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 = string.Empty;
var statArgs = new StatObjectArgs().WithBucket(_bucketName).WithObject(fileId);
var stat = await _minioClient.StatObjectAsync(statArgs).ConfigureAwait(false);
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)
{
if (metaData.TryGetValue(key, out var val)) return val;
if (metaData.TryGetValue($"X-Amz-Meta-{key}", out var val2)) return val2;
return null;
}
if (GetMeta("Contenttype") is string ct)
{
contentType = ct;
}
if (GetMeta("Originalfilename") is string ofn)
{
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 требует правильного чтения/записи)
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 = string.IsNullOrEmpty(ivBase64) ? new byte[16] : Convert.FromBase64String(ivBase64);
using var cryptoStream = _encryptionService.CreateDecryptionStream(fsRead, iv);
await cryptoStream.CopyToAsync(msResult);
}
File.Delete(tempFilePath);
msResult.Position = 0;
return (msResult, contentType, fileName);
}
public async Task DeleteFileAsync(string fileId)
{
try
{
var removeArgs = new RemoveObjectArgs()
.WithBucket(_bucketName)
.WithObject(fileId);
await _minioClient.RemoveObjectAsync(removeArgs).ConfigureAwait(false);
}
catch (MinioException e)
{
Console.WriteLine($"[Bucket] Error deleting file {fileId}: {e.Message}");
}
}
public async Task<IEnumerable<(string FileId, long Size)>> ListFilesAsync()
{
var result = new List<(string FileId, long Size)>();
try
{
var listArgs = new ListObjectsArgs().WithBucket(_bucketName).WithRecursive(true);
await foreach (var item in _minioClient.ListObjectsEnumAsync(listArgs).ConfigureAwait(false))
{
result.Add((item.Key, (long)item.Size));
}
}
catch (Exception ex)
{
Console.WriteLine($"[Bucket] Error listing files: {ex.Message}");
}
return result;
}
public async Task<long> GetFileSizeAsync(string fileId, System.Threading.CancellationToken ct = default)
{
try
{
var statArgs = new StatObjectArgs().WithBucket(_bucketName).WithObject(fileId);
var stat = await _minioClient.StatObjectAsync(statArgs).ConfigureAwait(false);
return stat.Size;
}
catch
{
return 0;
}
}
public async Task<System.Collections.Generic.Dictionary<string, long>> GetFileSizesAsync(System.Collections.Generic.IEnumerable<string> fileIds, System.Threading.CancellationToken ct = default)
{
var result = new System.Collections.Generic.Dictionary<string, long>();
foreach (var fileId in fileIds)
{
result[fileId] = await GetFileSizeAsync(fileId, ct);
}
return result;
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" />
<PackageReference Include="Minio" Version="7.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,53 @@
using Carter;
using Microsoft.Extensions.Caching.Memory;
using Knot.Shared.Kernel.Storage;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
namespace Knot.Host.Presentation.Endpoints;
public sealed class FilesEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup("api/files");
group.MapGet("{id}", async (string id, [FromQuery] bool download, IFileStorageService fileStorage, IMemoryCache cache, CancellationToken ct) =>
{
try
{
var cacheKey = $"file_{id}";
if (!cache.TryGetValue(cacheKey, out (byte[] Bytes, string ContentType, string FileName) cachedData))
{
var result = await fileStorage.DownloadFileAsync(id);
using var ms = new MemoryStream();
if (result.Stream.CanSeek) result.Stream.Position = 0;
await result.Stream.CopyToAsync(ms, ct);
cachedData = (ms.ToArray(), result.ContentType, result.FileName);
var cacheEntryOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromMinutes(30));
cache.Set(cacheKey, cachedData, cacheEntryOptions);
result.Stream.Dispose();
}
var outStream = new MemoryStream(cachedData.Bytes);
if (download && !string.IsNullOrEmpty(cachedData.FileName))
{
return Results.File(outStream, cachedData.ContentType, fileDownloadName: cachedData.FileName, enableRangeProcessing: true);
}
return Results.File(outStream, cachedData.ContentType, enableRangeProcessing: true);
}
catch (Exception ex)
{
return Results.NotFound(new { error = "Файл не найден или ошибка доступа.", message = ex.Message });
}
});
}
}