feat(dev): containerize local dependencies
Some checks failed
ci / release-gate (push) Has been cancelled
Some checks failed
ci / release-gate (push) Has been cancelled
This commit is contained in:
@@ -4,6 +4,9 @@ using System.Text.RegularExpressions;
|
||||
using AlibabaCloud.OSS.V2.Credentials;
|
||||
using AlibabaCloud.OSS.V2.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Minio;
|
||||
using Minio.DataModel.Args;
|
||||
using Minio.Exceptions;
|
||||
using Tiku.Application.Storage;
|
||||
using Oss = AlibabaCloud.OSS.V2;
|
||||
|
||||
@@ -11,7 +14,8 @@ namespace Tiku.Infrastructure.Storage;
|
||||
|
||||
public sealed partial class AliyunOssObjectStorageService(
|
||||
IOptions<ObjectStorageOptions> storageOptions,
|
||||
IOptions<AliyunOssOptions> aliyunOptions)
|
||||
IOptions<AliyunOssOptions> aliyunOptions,
|
||||
IOptions<S3CompatibleOptions> s3Options)
|
||||
: IObjectStorageService, IDisposable
|
||||
{
|
||||
private static readonly HashSet<string> SupportedProviders =
|
||||
@@ -31,6 +35,7 @@ public sealed partial class AliyunOssObjectStorageService(
|
||||
|
||||
private readonly object clientLock = new();
|
||||
private Oss.Client? client;
|
||||
private IMinioClient? s3Client;
|
||||
private bool disposed;
|
||||
|
||||
public void Dispose()
|
||||
@@ -41,6 +46,8 @@ public sealed partial class AliyunOssObjectStorageService(
|
||||
{
|
||||
client?.Dispose();
|
||||
client = null;
|
||||
s3Client?.Dispose();
|
||||
s3Client = null;
|
||||
disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -138,14 +145,14 @@ public sealed partial class AliyunOssObjectStorageService(
|
||||
public void AssertWritableLocation(StorageAssetLocation location)
|
||||
{
|
||||
var provider = NormalizeProvider(location.Provider);
|
||||
if (provider == ObjectStorageProviders.AliyunOss &&
|
||||
if (provider is ObjectStorageProviders.AliyunOss or ObjectStorageProviders.LocalDev &&
|
||||
(string.IsNullOrWhiteSpace(location.Bucket) || string.IsNullOrWhiteSpace(location.ObjectKey)))
|
||||
throw new ObjectStorageException(
|
||||
$"{provider} asset requires bucket and objectKey.",
|
||||
"ASSET_OBJECT_LOCATION_REQUIRED");
|
||||
}
|
||||
|
||||
public Task<ObjectStorageSignedUrl> SignUploadAsync(
|
||||
public async Task<ObjectStorageSignedUrl> SignUploadAsync(
|
||||
ObjectStorageUploadSignRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -156,29 +163,39 @@ public sealed partial class AliyunOssObjectStorageService(
|
||||
var mimeType = ValidateMimeType(request.MimeType);
|
||||
ValidateFileSize(request.FileSizeBytes);
|
||||
|
||||
return provider == ObjectStorageProviders.LocalDev
|
||||
? Task.FromResult(LocalSignedUrl(
|
||||
if (provider == ObjectStorageProviders.LocalDev)
|
||||
{
|
||||
await EnsureS3BucketAsync(request.Bucket, cancellationToken);
|
||||
var url = await GetS3Client().PresignedPutObjectAsync(new PresignedPutObjectArgs()
|
||||
.WithBucket(request.Bucket)
|
||||
.WithObject(objectKey)
|
||||
.WithExpiry(ExpirySeconds(request.ExpiresIn)));
|
||||
return new ObjectStorageSignedUrl(
|
||||
provider,
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
"PUT",
|
||||
request.ExpiresIn,
|
||||
"attachment",
|
||||
new Uri(url, UriKind.Absolute),
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["content-type"] = mimeType
|
||||
}))
|
||||
: Task.FromResult(SignAliyunOss(
|
||||
},
|
||||
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
|
||||
request.ExpiresIn,
|
||||
"s3-compatible-presigned-put");
|
||||
}
|
||||
|
||||
return SignAliyunOss(
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
"PUT",
|
||||
request.ExpiresIn,
|
||||
mimeType,
|
||||
null,
|
||||
null));
|
||||
null);
|
||||
}
|
||||
|
||||
public Task<ObjectStorageSignedUrl> SignDownloadAsync(
|
||||
public async Task<ObjectStorageSignedUrl> SignDownloadAsync(
|
||||
ObjectStorageDownloadSignRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -186,7 +203,7 @@ public sealed partial class AliyunOssObjectStorageService(
|
||||
var provider = NormalizeProvider(request.Provider);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.CdnUrl))
|
||||
return Task.FromResult(new ObjectStorageSignedUrl(
|
||||
return new ObjectStorageSignedUrl(
|
||||
provider,
|
||||
request.Bucket,
|
||||
request.ObjectKey,
|
||||
@@ -195,20 +212,36 @@ public sealed partial class AliyunOssObjectStorageService(
|
||||
new Dictionary<string, string>(),
|
||||
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
|
||||
request.ExpiresIn,
|
||||
"public-or-provider-managed"));
|
||||
"public-or-provider-managed");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ObjectKey))
|
||||
throw new ObjectStorageException("Asset requires objectKey or cdnUrl.", "ASSET_LOCATION_REQUIRED");
|
||||
|
||||
var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
|
||||
if (provider is ObjectStorageProviders.LocalDev or ObjectStorageProviders.QiniuKodo)
|
||||
return Task.FromResult(LocalSignedUrl(
|
||||
if (provider == ObjectStorageProviders.LocalDev)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Bucket))
|
||||
throw new ObjectStorageException("S3-compatible asset requires bucket.",
|
||||
"ASSET_OBJECT_LOCATION_REQUIRED");
|
||||
var url = await GetS3Client().PresignedGetObjectAsync(new PresignedGetObjectArgs()
|
||||
.WithBucket(request.Bucket)
|
||||
.WithObject(objectKey)
|
||||
.WithExpiry(ExpirySeconds(request.ExpiresIn)));
|
||||
return new ObjectStorageSignedUrl(
|
||||
provider,
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
"GET",
|
||||
new Uri(url, UriKind.Absolute),
|
||||
new Dictionary<string, string>(),
|
||||
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
|
||||
request.ExpiresIn,
|
||||
request.Disposition));
|
||||
"s3-compatible-presigned-get");
|
||||
}
|
||||
|
||||
if (provider == ObjectStorageProviders.QiniuKodo)
|
||||
return LocalSignedUrl(provider, request.Bucket, objectKey, "GET", request.ExpiresIn,
|
||||
request.Disposition);
|
||||
|
||||
if (provider != ObjectStorageProviders.AliyunOss)
|
||||
throw new ObjectStorageException($"{provider} asset requires cdnUrl.", "ASSET_LOCATION_REQUIRED");
|
||||
@@ -216,14 +249,14 @@ public sealed partial class AliyunOssObjectStorageService(
|
||||
if (string.IsNullOrWhiteSpace(request.Bucket))
|
||||
throw new ObjectStorageException("Aliyun OSS asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED");
|
||||
|
||||
return Task.FromResult(SignAliyunOss(
|
||||
return SignAliyunOss(
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
"GET",
|
||||
request.ExpiresIn,
|
||||
null,
|
||||
request.FileName,
|
||||
request.Disposition));
|
||||
request.Disposition);
|
||||
}
|
||||
|
||||
public async Task<ObjectStorageMetadata> HeadObjectAsync(
|
||||
@@ -241,18 +274,34 @@ public sealed partial class AliyunOssObjectStorageService(
|
||||
var checksumSha256 = request.DeclaredChecksumSha256?.Trim().ToLowerInvariant();
|
||||
|
||||
if (provider == ObjectStorageProviders.LocalDev)
|
||||
return new ObjectStorageMetadata(
|
||||
provider,
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
true,
|
||||
fileSizeBytes,
|
||||
mimeType,
|
||||
checksumSha256,
|
||||
checksumSha256,
|
||||
DateTimeOffset.UtcNow.ToString("O"),
|
||||
new Dictionary<string, string>(),
|
||||
"local-dev-declared-metadata");
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Bucket))
|
||||
throw new ObjectStorageException("S3-compatible asset requires bucket.",
|
||||
"ASSET_OBJECT_LOCATION_REQUIRED");
|
||||
try
|
||||
{
|
||||
var result = await GetS3Client().StatObjectAsync(new StatObjectArgs()
|
||||
.WithBucket(request.Bucket)
|
||||
.WithObject(objectKey), cancellationToken);
|
||||
return new ObjectStorageMetadata(
|
||||
provider,
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
true,
|
||||
result.Size,
|
||||
result.ContentType,
|
||||
checksumSha256,
|
||||
result.ETag,
|
||||
result.LastModified.ToString("O"),
|
||||
new Dictionary<string, string>(),
|
||||
"s3-compatible-stat-object");
|
||||
}
|
||||
catch (ObjectNotFoundException)
|
||||
{
|
||||
throw new ObjectStorageException("Uploaded object was not found in S3-compatible storage.",
|
||||
"STORAGE_OBJECT_NOT_FOUND");
|
||||
}
|
||||
}
|
||||
|
||||
if (provider != ObjectStorageProviders.AliyunOss)
|
||||
throw new ObjectStorageException(
|
||||
@@ -298,17 +347,47 @@ public sealed partial class AliyunOssObjectStorageService(
|
||||
throw new ObjectStorageException("Writable asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED");
|
||||
|
||||
if (provider == ObjectStorageProviders.LocalDev)
|
||||
return new ObjectStorageWriteResult(
|
||||
provider,
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
BuildPublicUrl(request.Bucket, objectKey),
|
||||
fileSizeBytes,
|
||||
mimeType,
|
||||
checksumSha256 ?? await ComputeSha256Async(request.Content, cancellationToken),
|
||||
null,
|
||||
new Dictionary<string, string>(),
|
||||
"local-dev-write-placeholder");
|
||||
{
|
||||
await EnsureS3BucketAsync(request.Bucket, cancellationToken);
|
||||
MemoryStream? bufferedContent = null;
|
||||
var uploadContent = request.Content;
|
||||
if (checksumSha256 is null && !request.Content.CanSeek)
|
||||
{
|
||||
bufferedContent = new MemoryStream();
|
||||
await request.Content.CopyToAsync(bufferedContent, cancellationToken);
|
||||
bufferedContent.Position = 0;
|
||||
uploadContent = bufferedContent;
|
||||
}
|
||||
|
||||
var resolvedChecksum = checksumSha256 ?? await ComputeSha256Async(uploadContent, cancellationToken);
|
||||
var objectSize = fileSizeBytes ?? (uploadContent.CanSeek
|
||||
? uploadContent.Length - uploadContent.Position
|
||||
: -1);
|
||||
try
|
||||
{
|
||||
await GetS3Client().PutObjectAsync(new PutObjectArgs()
|
||||
.WithBucket(request.Bucket)
|
||||
.WithObject(objectKey)
|
||||
.WithStreamData(uploadContent)
|
||||
.WithObjectSize(objectSize)
|
||||
.WithContentType(mimeType), cancellationToken);
|
||||
return new ObjectStorageWriteResult(
|
||||
provider,
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
BuildPublicUrl(request.Bucket, objectKey),
|
||||
fileSizeBytes ?? objectSize,
|
||||
mimeType,
|
||||
resolvedChecksum,
|
||||
null,
|
||||
new Dictionary<string, string>(),
|
||||
"s3-compatible-put-object");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (bufferedContent is not null) await bufferedContent.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
if (provider != ObjectStorageProviders.AliyunOss)
|
||||
throw new ObjectStorageException(
|
||||
@@ -353,6 +432,30 @@ public sealed partial class AliyunOssObjectStorageService(
|
||||
{
|
||||
var provider = NormalizeProvider(request.Provider);
|
||||
var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
|
||||
if (provider == ObjectStorageProviders.LocalDev)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Bucket))
|
||||
throw new ObjectStorageException("S3-compatible asset requires bucket.",
|
||||
"ASSET_OBJECT_LOCATION_REQUIRED");
|
||||
var content = new MemoryStream();
|
||||
try
|
||||
{
|
||||
await GetS3Client().GetObjectAsync(new GetObjectArgs()
|
||||
.WithBucket(request.Bucket)
|
||||
.WithObject(objectKey)
|
||||
.WithCallbackStream(async (stream, token) =>
|
||||
await stream.CopyToAsync(content, token)), cancellationToken);
|
||||
content.Position = 0;
|
||||
return content;
|
||||
}
|
||||
catch (ObjectNotFoundException)
|
||||
{
|
||||
await content.DisposeAsync();
|
||||
throw new ObjectStorageException("Object was not found in S3-compatible storage.",
|
||||
"STORAGE_OBJECT_NOT_FOUND");
|
||||
}
|
||||
}
|
||||
|
||||
if (provider != ObjectStorageProviders.AliyunOss)
|
||||
throw new ObjectStorageException(
|
||||
$"{provider} does not support managed server-side reads.",
|
||||
@@ -457,6 +560,43 @@ public sealed partial class AliyunOssObjectStorageService(
|
||||
}
|
||||
}
|
||||
|
||||
private IMinioClient GetS3Client()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
if (s3Client is not null) return s3Client;
|
||||
|
||||
lock (clientLock)
|
||||
{
|
||||
if (s3Client is not null) return s3Client;
|
||||
var options = s3Options.Value;
|
||||
if (!options.IsConfigured)
|
||||
throw new ObjectStorageNotConfiguredException(
|
||||
"S3-compatible storage is not configured: S3_ENDPOINT, S3_ACCESS_KEY and S3_SECRET_KEY are required.");
|
||||
|
||||
var builder = new MinioClient()
|
||||
.WithEndpoint(options.Endpoint!.Trim())
|
||||
.WithCredentials(options.AccessKey!.Trim(), options.SecretKey!.Trim())
|
||||
.WithSSL(options.Secure);
|
||||
if (!string.IsNullOrWhiteSpace(options.Region)) builder = builder.WithRegion(options.Region.Trim());
|
||||
s3Client = builder.Build();
|
||||
return s3Client;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureS3BucketAsync(string bucket, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bucket);
|
||||
var client = GetS3Client();
|
||||
var exists = await client.BucketExistsAsync(new BucketExistsArgs().WithBucket(bucket), cancellationToken);
|
||||
if (!exists)
|
||||
await client.MakeBucketAsync(new MakeBucketArgs().WithBucket(bucket), cancellationToken);
|
||||
}
|
||||
|
||||
private static int ExpirySeconds(TimeSpan expiresIn)
|
||||
{
|
||||
return Math.Clamp((int)Math.Ceiling(expiresIn.TotalSeconds), 1, 7 * 24 * 60 * 60);
|
||||
}
|
||||
|
||||
private static Oss.Client CreateClient(AliyunOssOptions currentOptions)
|
||||
{
|
||||
if (!currentOptions.IsConfigured)
|
||||
@@ -625,4 +765,4 @@ public sealed partial class AliyunOssObjectStorageService(
|
||||
|
||||
[GeneratedRegex("/{2,}")]
|
||||
private static partial Regex ConsecutiveSlashRegex();
|
||||
}
|
||||
}
|
||||
|
||||
17
Tiku.Infrastructure/Storage/S3CompatibleOptions.cs
Normal file
17
Tiku.Infrastructure/Storage/S3CompatibleOptions.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Tiku.Infrastructure.Storage;
|
||||
|
||||
public sealed class S3CompatibleOptions
|
||||
{
|
||||
public const string SectionName = "Storage:S3Compatible";
|
||||
|
||||
public string? Endpoint { get; set; }
|
||||
public string? AccessKey { get; set; }
|
||||
public string? SecretKey { get; set; }
|
||||
public string? Region { get; set; }
|
||||
public bool Secure { get; set; }
|
||||
|
||||
public bool IsConfigured =>
|
||||
!string.IsNullOrWhiteSpace(Endpoint) &&
|
||||
!string.IsNullOrWhiteSpace(AccessKey) &&
|
||||
!string.IsNullOrWhiteSpace(SecretKey);
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis"/>
|
||||
<PackageReference Include="StackExchange.Redis"/>
|
||||
<PackageReference Include="Microsoft.SemanticKernel"/>
|
||||
<PackageReference Include="Minio"/>
|
||||
<PackageReference Include="Npgsql"/>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL"/>
|
||||
<PackageReference Include="Senparc.Weixin"/>
|
||||
|
||||
Reference in New Issue
Block a user