Перепиливание под чистый DDD
This commit is contained in:
35
backend/src/Modules/Storage/DependencyInjection.cs
Normal file
35
backend/src/Modules/Storage/DependencyInjection.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
22
backend/src/Modules/Storage/Knot.Modules.Storage.csproj
Normal file
22
backend/src/Modules/Storage/Knot.Modules.Storage.csproj
Normal 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>
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user