From 4078bf01f4c2176aea22ae767539bbd5d6754cbb Mon Sep 17 00:00:00 2001 From: namdh861 Date: Tue, 15 Sep 2026 21:13:37 +0700 Subject: [PATCH] fix: change s3 --- api/Properties/launchSettings.json | 4 +- api/appsettings.json | 10 +- .../CloudFileDocumentContentStore.cs | 71 --------- .../CloudFile/CloudFileHttpClient.cs | 146 ------------------ infrastructure/CloudFile/CloudFileOptions.cs | 9 -- infrastructure/DependencyInjection.cs | 27 ++-- infrastructure/Storage/MinioOptions.cs | 9 ++ .../Storage/S3DocumentContentStore.cs | 84 ++++++++++ infrastructure/infrastructure.csproj | 1 + 9 files changed, 119 insertions(+), 242 deletions(-) delete mode 100644 infrastructure/CloudFile/CloudFileDocumentContentStore.cs delete mode 100644 infrastructure/CloudFile/CloudFileHttpClient.cs delete mode 100644 infrastructure/CloudFile/CloudFileOptions.cs create mode 100644 infrastructure/Storage/MinioOptions.cs create mode 100644 infrastructure/Storage/S3DocumentContentStore.cs diff --git a/api/Properties/launchSettings.json b/api/Properties/launchSettings.json index 100412e..3c9b724 100644 --- a/api/Properties/launchSettings.json +++ b/api/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:2001", + "applicationUrl": "http://localhost:2000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://localhost:3000;http://localhost:2001", + "applicationUrl": "https://localhost:3000;http://localhost:2000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/api/appsettings.json b/api/appsettings.json index 57610ae..2d242b3 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -11,11 +11,11 @@ "Cors": { "Origins": "*" }, - "CloudFile": { - "BaseUrl": "https://s3.koda.id.vn", - "ApiKey": "xxxx", - "BucketPrefix": "mws-p-", - "HttpTimeoutSeconds": 30 + "Minio": { + "Endpoint": "https://minio.koda.id.vn", + "AccessKey": "CHANGE_ME", + "SecretKey": "CHANGE_ME", + "Bucket": "mws" }, "Logging": { "LogLevel": { diff --git a/infrastructure/CloudFile/CloudFileDocumentContentStore.cs b/infrastructure/CloudFile/CloudFileDocumentContentStore.cs deleted file mode 100644 index 4288a0d..0000000 --- a/infrastructure/CloudFile/CloudFileDocumentContentStore.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Microsoft.Extensions.Logging; -using mws.backend.dotnet.application.Common; -using mws.backend.dotnet.application.Documents; - -namespace mws.backend.dotnet.infrastructure.CloudFile; - -public class CloudFileDocumentContentStore( - ICloudFileHttpClient cf, - ILogger log) : IDocumentContentStore -{ - private static string Bucket(Guid projectId) => $"mws-p-{projectId:N}"; - private static string Key(Guid documentId) => $"{documentId:N}.html"; - - public Task EnsureBucketAsync(Guid projectId, CancellationToken ct) => - cf.EnsureBucketAsync(Bucket(projectId), ct); - - public async Task UploadAsync(Guid projectId, Guid documentId, string contentType, Stream body, CancellationToken ct) - { - try - { - var meta = await cf.UploadAsync(Bucket(projectId), Key(documentId), contentType, body, ct); - log.LogDebug("Uploaded document {DocumentId} to cloudfile: {Hash}", documentId, meta?.ContentHash); - } - catch (HttpRequestException ex) - { - log.LogError(ex, "CloudFile upload failed for document {DocumentId}", documentId); - throw new StorageUnavailableException("Cloud file upload failed", ex); - } - } - - public async Task DownloadAsync(Guid projectId, Guid documentId, CancellationToken ct) - { - try - { - return await cf.DownloadAsync(Bucket(projectId), Key(documentId), ct); - } - catch (HttpRequestException ex) - { - log.LogError(ex, "CloudFile download failed for document {DocumentId}", documentId); - throw new StorageUnavailableException("Cloud file download failed", ex); - } - } - - public async Task ExistsAsync(Guid projectId, Guid documentId, CancellationToken ct) - { - try - { - return await cf.ExistsAsync(Bucket(projectId), Key(documentId), ct); - } - catch (HttpRequestException) - { - return false; - } - } - - public async Task DeleteAsync(Guid projectId, Guid documentId, CancellationToken ct) - { - var bucket = Bucket(projectId); - var key = Key(documentId); - log.LogInformation("CloudFile delete: bucket={Bucket}, key={Key}", bucket, key); - try - { - await cf.DeleteAsync(bucket, key, ct); - log.LogInformation("CloudFile delete OK for document {DocumentId}", documentId); - } - catch (HttpRequestException ex) - { - log.LogWarning(ex, "CloudFile delete failed for document {DocumentId} (bucket={Bucket}, key={Key})", documentId, bucket, key); - } - } -} diff --git a/infrastructure/CloudFile/CloudFileHttpClient.cs b/infrastructure/CloudFile/CloudFileHttpClient.cs deleted file mode 100644 index 3c9c48e..0000000 --- a/infrastructure/CloudFile/CloudFileHttpClient.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System.Net; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace mws.backend.dotnet.infrastructure.CloudFile; - -public interface ICloudFileHttpClient -{ - Task UploadAsync(string bucket, string key, string contentType, Stream body, CancellationToken ct); - Task DownloadAsync(string bucket, string key, CancellationToken ct); - Task ExistsAsync(string bucket, string key, CancellationToken ct); - Task DeleteAsync(string bucket, string key, CancellationToken ct); - Task EnsureBucketAsync(string bucket, CancellationToken ct); -} - -public class CloudFileHttpClient( - HttpClient http, - IOptions opt, - ILogger log) : ICloudFileHttpClient -{ - private static readonly JsonSerializerOptions Json = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - private HttpRequestMessage Req(HttpMethod method, string url) - { - var request = new HttpRequestMessage(method, url); - request.Headers.Add("X-API-Key", opt.Value.ApiKey); - return request; - } - - public async Task UploadAsync(string bucket, string key, string contentType, Stream body, CancellationToken ct) - { - using var form = new MultipartFormDataContent - { - { new StreamContent(body), "file", key }, - { new StringContent(bucket), "bucket" }, - { new StringContent(key), "key" } - }; - - var request = Req(HttpMethod.Post, "/api/files/upload"); - request.Content = form; - - var resp = await http.SendAsync(request, ct); - var responseText = await resp.Content.ReadAsStringAsync(ct); - - if (!resp.IsSuccessStatusCode) - { - log.LogWarning("CloudFile upload failed {Status}: {Body}", resp.StatusCode, responseText); - resp.EnsureSuccessStatusCode(); - } - - var dto = JsonSerializer.Deserialize(responseText, Json); - return dto?.Metadata; - } - - public async Task DownloadAsync(string bucket, string key, CancellationToken ct) - { - var request = Req(HttpMethod.Get, $"/api/files/download/{bucket}/{Uri.EscapeDataString(key)}"); - var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); - - if (resp.StatusCode == HttpStatusCode.NotFound) - { - return Stream.Null; - } - - resp.EnsureSuccessStatusCode(); - return await resp.Content.ReadAsStreamAsync(ct); - } - - public async Task ExistsAsync(string bucket, string key, CancellationToken ct) - { - var request = Req(HttpMethod.Get, $"/api/files/{bucket}/metadata/{Uri.EscapeDataString(key)}"); - var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); - return resp.IsSuccessStatusCode; - } - - public async Task DeleteAsync(string bucket, string key, CancellationToken ct) - { - var request = Req(HttpMethod.Delete, $"/api/files/{bucket}/{Uri.EscapeDataString(key)}"); - var resp = await http.SendAsync(request, ct); - - if (resp.StatusCode != HttpStatusCode.NotFound) - { - resp.EnsureSuccessStatusCode(); - } - } - - public async Task EnsureBucketAsync(string bucket, CancellationToken ct) - { - var request = Req(HttpMethod.Post, "/api/buckets"); - var json = JsonSerializer.Serialize(new { name = bucket }, Json); - request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); - - var resp = await http.SendAsync(request, ct); - var responseText = await resp.Content.ReadAsStringAsync(ct); - - if (!resp.IsSuccessStatusCode && resp.StatusCode != HttpStatusCode.BadRequest) - { - log.LogWarning("CloudFile ensureBucket failed {Status}: {Body}", resp.StatusCode, responseText); - resp.EnsureSuccessStatusCode(); - } - } -} - -public class CloudFileUploadResponse -{ - public bool Success { get; set; } - public CloudFileMetadata? Metadata { get; set; } -} - -public class CloudFileMetadata -{ - [JsonPropertyName("fileId")] - public string FileId { get; set; } = string.Empty; - - [JsonPropertyName("fileName")] - public string FileName { get; set; } = string.Empty; - - [JsonPropertyName("extension")] - public string Extension { get; set; } = string.Empty; - - [JsonPropertyName("contentType")] - public string ContentType { get; set; } = string.Empty; - - [JsonPropertyName("size")] - public long Size { get; set; } - - [JsonPropertyName("bucket")] - public string Bucket { get; set; } = string.Empty; - - [JsonPropertyName("key")] - public string Key { get; set; } = string.Empty; - - [JsonPropertyName("contentHash")] - public string ContentHash { get; set; } = string.Empty; - - [JsonPropertyName("createdAt")] - public DateTime CreatedAt { get; set; } - - [JsonPropertyName("modifiedAt")] - public DateTime ModifiedAt { get; set; } -} diff --git a/infrastructure/CloudFile/CloudFileOptions.cs b/infrastructure/CloudFile/CloudFileOptions.cs deleted file mode 100644 index ae303f7..0000000 --- a/infrastructure/CloudFile/CloudFileOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace mws.backend.dotnet.infrastructure.CloudFile; - -public class CloudFileOptions -{ - public string BaseUrl { get; set; } = string.Empty; - public string ApiKey { get; set; } = string.Empty; - public string BucketPrefix { get; set; } = "mws-p-"; - public int HttpTimeoutSeconds { get; set; } = 30; -} diff --git a/infrastructure/DependencyInjection.cs b/infrastructure/DependencyInjection.cs index a8fa17f..534d478 100644 --- a/infrastructure/DependencyInjection.cs +++ b/infrastructure/DependencyInjection.cs @@ -1,13 +1,16 @@ +using Amazon.Runtime; +using Amazon.S3; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using mws.backend.dotnet.application.Auth; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Documents; using mws.backend.dotnet.application.Permissions; using mws.backend.dotnet.infrastructure.Authentication; -using mws.backend.dotnet.infrastructure.CloudFile; using mws.backend.dotnet.infrastructure.Persistence; +using mws.backend.dotnet.infrastructure.Storage; namespace mws.backend.dotnet.infrastructure; @@ -39,16 +42,22 @@ public static class DependencyInjection services.AddScoped(); - services.Configure(configuration.GetSection("CloudFile")); - services.AddHttpClient((sp, http) => + services.Configure(configuration.GetSection("Minio")); + services.AddSingleton(sp => { - var options = sp.GetRequiredService>().Value; -#pragma warning disable S1075 // hardcoded URL — this is a configurable base address from appsettings - http.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/"); -#pragma warning restore S1075 - http.Timeout = TimeSpan.FromSeconds(options.HttpTimeoutSeconds); + var options = sp.GetRequiredService>().Value; + return new AmazonS3Client( + new BasicAWSCredentials(options.AccessKey, options.SecretKey), + new AmazonS3Config + { + ServiceURL = options.Endpoint, + ForcePathStyle = true, + UseHttp = options.Endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase), + RequestChecksumCalculation = RequestChecksumCalculation.WHEN_REQUIRED, + ResponseChecksumValidation = ResponseChecksumValidation.WHEN_REQUIRED, + }); }); - services.AddScoped(); + services.AddScoped(); return services; } diff --git a/infrastructure/Storage/MinioOptions.cs b/infrastructure/Storage/MinioOptions.cs new file mode 100644 index 0000000..5809768 --- /dev/null +++ b/infrastructure/Storage/MinioOptions.cs @@ -0,0 +1,9 @@ +namespace mws.backend.dotnet.infrastructure.Storage; + +public class MinioOptions +{ + public string Endpoint { get; set; } = "https://minio.koda.id.vn"; + public string AccessKey { get; set; } = string.Empty; + public string SecretKey { get; set; } = string.Empty; + public string Bucket { get; set; } = "mws"; +} diff --git a/infrastructure/Storage/S3DocumentContentStore.cs b/infrastructure/Storage/S3DocumentContentStore.cs new file mode 100644 index 0000000..8c8221a --- /dev/null +++ b/infrastructure/Storage/S3DocumentContentStore.cs @@ -0,0 +1,84 @@ +using System.Net; +using Amazon.S3; +using Amazon.S3.Model; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Documents; + +namespace mws.backend.dotnet.infrastructure.Storage; + +public class S3DocumentContentStore( + IAmazonS3 s3, + IOptions opt, + ILogger log) : IDocumentContentStore +{ + private string Bucket => opt.Value.Bucket; + + private static string Key(Guid projectId, Guid documentId) => $"{projectId:N}/{documentId:N}.html"; + + public Task EnsureBucketAsync(Guid projectId, CancellationToken ct) => Task.CompletedTask; + + public async Task UploadAsync(Guid projectId, Guid documentId, string contentType, Stream body, CancellationToken ct) + { + try + { + await s3.PutObjectAsync(new PutObjectRequest + { + BucketName = Bucket, + Key = Key(projectId, documentId), + InputStream = body, + ContentType = contentType, + AutoCloseStream = false, + }, ct); + } + catch (AmazonS3Exception ex) + { + log.LogError(ex, "MinIO upload failed for document {DocumentId}", documentId); + throw new StorageUnavailableException("Document upload failed", ex); + } + } + + public async Task DownloadAsync(Guid projectId, Guid documentId, CancellationToken ct) + { + try + { + var response = await s3.GetObjectAsync(Bucket, Key(projectId, documentId), ct); + return response.ResponseStream; + } + catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return Stream.Null; + } + catch (AmazonS3Exception ex) + { + log.LogError(ex, "MinIO download failed for document {DocumentId}", documentId); + throw new StorageUnavailableException("Document download failed", ex); + } + } + + public async Task ExistsAsync(Guid projectId, Guid documentId, CancellationToken ct) + { + try + { + await s3.GetObjectMetadataAsync(Bucket, Key(projectId, documentId), ct); + return true; + } + catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + } + + public async Task DeleteAsync(Guid projectId, Guid documentId, CancellationToken ct) + { + try + { + await s3.DeleteObjectAsync(Bucket, Key(projectId, documentId), ct); + } + catch (AmazonS3Exception ex) + { + log.LogWarning(ex, "MinIO delete failed for document {DocumentId}", documentId); + } + } +} diff --git a/infrastructure/infrastructure.csproj b/infrastructure/infrastructure.csproj index a512d5a..e6efd78 100644 --- a/infrastructure/infrastructure.csproj +++ b/infrastructure/infrastructure.csproj @@ -13,6 +13,7 @@ +