fix: change s3
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "http://localhost:2001",
|
"applicationUrl": "http://localhost:2000",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "https://localhost:3000;http://localhost:2001",
|
"applicationUrl": "https://localhost:3000;http://localhost:2000",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,11 @@
|
|||||||
"Cors": {
|
"Cors": {
|
||||||
"Origins": "*"
|
"Origins": "*"
|
||||||
},
|
},
|
||||||
"CloudFile": {
|
"Minio": {
|
||||||
"BaseUrl": "https://s3.koda.id.vn",
|
"Endpoint": "https://minio.koda.id.vn",
|
||||||
"ApiKey": "xxxx",
|
"AccessKey": "CHANGE_ME",
|
||||||
"BucketPrefix": "mws-p-",
|
"SecretKey": "CHANGE_ME",
|
||||||
"HttpTimeoutSeconds": 30
|
"Bucket": "mws"
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
|
|||||||
@@ -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<CloudFileDocumentContentStore> 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<Stream> 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<bool> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<CloudFileMetadata?> UploadAsync(string bucket, string key, string contentType, Stream body, CancellationToken ct);
|
|
||||||
Task<Stream> DownloadAsync(string bucket, string key, CancellationToken ct);
|
|
||||||
Task<bool> 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<CloudFileOptions> opt,
|
|
||||||
ILogger<CloudFileHttpClient> 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<CloudFileMetadata?> 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<CloudFileUploadResponse>(responseText, Json);
|
|
||||||
return dto?.Metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Stream> 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<bool> 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; }
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
|
using Amazon.Runtime;
|
||||||
|
using Amazon.S3;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using mws.backend.dotnet.application.Auth;
|
using mws.backend.dotnet.application.Auth;
|
||||||
using mws.backend.dotnet.application.Common;
|
using mws.backend.dotnet.application.Common;
|
||||||
using mws.backend.dotnet.application.Documents;
|
using mws.backend.dotnet.application.Documents;
|
||||||
using mws.backend.dotnet.application.Permissions;
|
using mws.backend.dotnet.application.Permissions;
|
||||||
using mws.backend.dotnet.infrastructure.Authentication;
|
using mws.backend.dotnet.infrastructure.Authentication;
|
||||||
using mws.backend.dotnet.infrastructure.CloudFile;
|
|
||||||
using mws.backend.dotnet.infrastructure.Persistence;
|
using mws.backend.dotnet.infrastructure.Persistence;
|
||||||
|
using mws.backend.dotnet.infrastructure.Storage;
|
||||||
|
|
||||||
namespace mws.backend.dotnet.infrastructure;
|
namespace mws.backend.dotnet.infrastructure;
|
||||||
|
|
||||||
@@ -39,16 +42,22 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
services.AddScoped<IPermissionService, PermissionService>();
|
services.AddScoped<IPermissionService, PermissionService>();
|
||||||
|
|
||||||
services.Configure<CloudFileOptions>(configuration.GetSection("CloudFile"));
|
services.Configure<MinioOptions>(configuration.GetSection("Minio"));
|
||||||
services.AddHttpClient<ICloudFileHttpClient, CloudFileHttpClient>((sp, http) =>
|
services.AddSingleton<IAmazonS3>(sp =>
|
||||||
{
|
{
|
||||||
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<CloudFileOptions>>().Value;
|
var options = sp.GetRequiredService<IOptions<MinioOptions>>().Value;
|
||||||
#pragma warning disable S1075 // hardcoded URL — this is a configurable base address from appsettings
|
return new AmazonS3Client(
|
||||||
http.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/");
|
new BasicAWSCredentials(options.AccessKey, options.SecretKey),
|
||||||
#pragma warning restore S1075
|
new AmazonS3Config
|
||||||
http.Timeout = TimeSpan.FromSeconds(options.HttpTimeoutSeconds);
|
{
|
||||||
|
ServiceURL = options.Endpoint,
|
||||||
|
ForcePathStyle = true,
|
||||||
|
UseHttp = options.Endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase),
|
||||||
|
RequestChecksumCalculation = RequestChecksumCalculation.WHEN_REQUIRED,
|
||||||
|
ResponseChecksumValidation = ResponseChecksumValidation.WHEN_REQUIRED,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
services.AddScoped<IDocumentContentStore, CloudFileDocumentContentStore>();
|
services.AddScoped<IDocumentContentStore, S3DocumentContentStore>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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<MinioOptions> opt,
|
||||||
|
ILogger<S3DocumentContentStore> 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<Stream> 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<bool> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AWSSDK.S3" Version="4.0.103.3" />
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
|
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.11" />
|
||||||
|
|||||||
Reference in New Issue
Block a user