forked from gongxuegit/tiku-backend.net
feat: add aliyun oss storage foundation
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="AlibabaCloud.OSS.V2" Version="0.2.0" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||||
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -98,6 +98,17 @@ Tiku.IntegrationTests # EF 模型/持久化约束测试
|
|||||||
- 当前先在小范围权限判断里落模板,后续题库筛选、权限集合、菜单/内容树投影等热路径再逐步使用。
|
- 当前先在小范围权限判断里落模板,后续题库筛选、权限集合、菜单/内容树投影等热路径再逐步使用。
|
||||||
- 不是为了炫技替换所有 LINQ;只在明确高频、低收益分配明显的路径使用。
|
- 不是为了炫技替换所有 LINQ;只在明确高频、低收益分配明显的路径使用。
|
||||||
|
|
||||||
|
### 对象存储
|
||||||
|
|
||||||
|
- 旧 Nest 后端资源层使用 Node `ali-oss`,不是 Supabase Storage 专用模型。
|
||||||
|
- 新后端使用 `AlibabaCloud.OSS.V2` 作为阿里云 OSS SDK,对齐旧版的 provider / bucket / objectKey / signed URL / HEAD metadata 语义。
|
||||||
|
- Application 层只依赖 `IObjectStorageService`,不直接依赖阿里云 SDK;SDK 细节收敛在 Infrastructure。
|
||||||
|
- 默认对象 key 要带租户前缀,例如 `{tenantId}/...`,避免多租户资源混放后靠人工约定隔离。
|
||||||
|
- 上传签名前会校验 MIME allowlist、文件大小、provider 是否支持托管上传。
|
||||||
|
- 下载时如果资源已有可信 `cdnUrl`,直接返回 provider-managed URL;否则由后端在通过业务授权后签发临时 URL。
|
||||||
|
- 上传确认使用 OSS HEAD metadata,后续可用于校验大小、MIME、ETag、SHA256 metadata 和安全扫描状态。
|
||||||
|
- 不再把 Supabase Storage 作为默认后端存储;如果历史数据里存在 Supabase provider,只作为迁移兼容对象处理,不作为新架构依赖。
|
||||||
|
|
||||||
## 数据库策略
|
## 数据库策略
|
||||||
|
|
||||||
当前数据库以 PostgreSQL 为核心能力,而不是把 PostgreSQL 当成普通 KV 存储:
|
当前数据库以 PostgreSQL 为核心能力,而不是把 PostgreSQL 当成普通 KV 存储:
|
||||||
@@ -213,6 +224,18 @@ Tiku.Infrastructure/Persistence/Migrations/20260725220742_InitialSchema.cs
|
|||||||
- 后台 Worker 使用最小权限的应用服务,不直接绕过业务规则写库。
|
- 后台 Worker 使用最小权限的应用服务,不直接绕过业务规则写库。
|
||||||
- 对账、导入、批处理任务要求幂等键和可重跑设计。
|
- 对账、导入、批处理任务要求幂等键和可重跑设计。
|
||||||
- 生产环境连接串、密钥、对象存储凭据不进入仓库。
|
- 生产环境连接串、密钥、对象存储凭据不进入仓库。
|
||||||
|
- OSS 凭据通过配置/环境变量/secret 注入,兼容旧版环境变量:
|
||||||
|
- `STORAGE_DEFAULT_PROVIDER`
|
||||||
|
- `STORAGE_DEFAULT_BUCKET`
|
||||||
|
- `STORAGE_PUBLIC_BASE_URL`
|
||||||
|
- `STORAGE_REQUIRE_TENANT_PREFIX`
|
||||||
|
- `STORAGE_MAX_UPLOAD_BYTES`
|
||||||
|
- `ALIYUN_OSS_REGION`
|
||||||
|
- `ALIYUN_OSS_ENDPOINT`
|
||||||
|
- `ALIYUN_OSS_ACCESS_KEY_ID`
|
||||||
|
- `ALIYUN_OSS_ACCESS_KEY_SECRET`
|
||||||
|
- `ALIYUN_OSS_STS_TOKEN`
|
||||||
|
- `ALIYUN_OSS_INTERNAL`
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ using Tiku.Application;
|
|||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Infrastructure;
|
using Tiku.Infrastructure;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
using Tiku.Infrastructure.Storage;
|
||||||
|
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||||
@@ -138,6 +139,39 @@ try
|
|||||||
"Host=localhost;Database=tiku;Username=postgres";
|
"Host=localhost;Database=tiku;Username=postgres";
|
||||||
|
|
||||||
builder.Services.AddInfrastructure(connectionString);
|
builder.Services.AddInfrastructure(connectionString);
|
||||||
|
builder.Services.Configure<ObjectStorageOptions>(
|
||||||
|
builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
|
||||||
|
builder.Services.Configure<AliyunOssOptions>(
|
||||||
|
builder.Configuration.GetSection(AliyunOssOptions.SectionName));
|
||||||
|
builder.Services.PostConfigure<ObjectStorageOptions>(options =>
|
||||||
|
{
|
||||||
|
options.DefaultProvider = builder.Configuration["STORAGE_DEFAULT_PROVIDER"] ?? options.DefaultProvider;
|
||||||
|
options.DefaultBucket = builder.Configuration["STORAGE_DEFAULT_BUCKET"] ?? options.DefaultBucket;
|
||||||
|
options.PublicBaseUrl = builder.Configuration["STORAGE_PUBLIC_BASE_URL"] ?? options.PublicBaseUrl;
|
||||||
|
options.AllowedMimePrefixes = SplitLegacyList(
|
||||||
|
builder.Configuration["STORAGE_ALLOWED_MIME_PREFIXES"],
|
||||||
|
options.AllowedMimePrefixes);
|
||||||
|
options.AllowedMimeTypes = SplitLegacyList(
|
||||||
|
builder.Configuration["STORAGE_ALLOWED_MIME_TYPES"],
|
||||||
|
options.AllowedMimeTypes);
|
||||||
|
options.RequireTenantPrefix = bool.TryParse(builder.Configuration["STORAGE_REQUIRE_TENANT_PREFIX"], out var requireTenantPrefix)
|
||||||
|
? requireTenantPrefix
|
||||||
|
: options.RequireTenantPrefix;
|
||||||
|
options.MaxUploadBytes = long.TryParse(builder.Configuration["STORAGE_MAX_UPLOAD_BYTES"], out var maxUploadBytes)
|
||||||
|
? maxUploadBytes
|
||||||
|
: options.MaxUploadBytes;
|
||||||
|
});
|
||||||
|
builder.Services.PostConfigure<AliyunOssOptions>(options =>
|
||||||
|
{
|
||||||
|
options.Region = builder.Configuration["ALIYUN_OSS_REGION"] ?? options.Region;
|
||||||
|
options.Endpoint = builder.Configuration["ALIYUN_OSS_ENDPOINT"] ?? options.Endpoint;
|
||||||
|
options.AccessKeyId = builder.Configuration["ALIYUN_OSS_ACCESS_KEY_ID"] ?? options.AccessKeyId;
|
||||||
|
options.AccessKeySecret = builder.Configuration["ALIYUN_OSS_ACCESS_KEY_SECRET"] ?? options.AccessKeySecret;
|
||||||
|
options.SecurityToken = builder.Configuration["ALIYUN_OSS_STS_TOKEN"] ?? options.SecurityToken;
|
||||||
|
options.UseInternalEndpoint = bool.TryParse(builder.Configuration["ALIYUN_OSS_INTERNAL"], out var useInternalEndpoint)
|
||||||
|
? useInternalEndpoint
|
||||||
|
: options.UseInternalEndpoint;
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.AddOptions<JwtOptions>()
|
builder.Services.AddOptions<JwtOptions>()
|
||||||
.Bind(builder.Configuration.GetSection("Security:Jwt"))
|
.Bind(builder.Configuration.GetSection("Security:Jwt"))
|
||||||
@@ -250,4 +284,9 @@ finally
|
|||||||
Log.CloseAndFlush();
|
Log.CloseAndFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static string[] SplitLegacyList(string? value, string[] fallback) =>
|
||||||
|
string.IsNullOrWhiteSpace(value)
|
||||||
|
? fallback
|
||||||
|
: value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -23,5 +23,12 @@
|
|||||||
"PermitLimit": 1200,
|
"PermitLimit": 1200,
|
||||||
"WindowSeconds": 60,
|
"WindowSeconds": 60,
|
||||||
"QueueLimit": 0
|
"QueueLimit": 0
|
||||||
|
},
|
||||||
|
"Storage": {
|
||||||
|
"AliyunOss": {
|
||||||
|
"Region": "cn-hangzhou",
|
||||||
|
"Endpoint": "",
|
||||||
|
"UseInternalEndpoint": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,37 @@
|
|||||||
"WindowSeconds": 60,
|
"WindowSeconds": 60,
|
||||||
"QueueLimit": 0
|
"QueueLimit": 0
|
||||||
},
|
},
|
||||||
|
"Storage": {
|
||||||
|
"DefaultProvider": "aliyun_oss",
|
||||||
|
"DefaultBucket": "tenant-assets",
|
||||||
|
"PublicBaseUrl": "",
|
||||||
|
"MaxUploadBytes": 524288000,
|
||||||
|
"AllowedMimePrefixes": [
|
||||||
|
"image/",
|
||||||
|
"video/",
|
||||||
|
"audio/"
|
||||||
|
],
|
||||||
|
"AllowedMimeTypes": [
|
||||||
|
"application/pdf",
|
||||||
|
"application/json",
|
||||||
|
"text/csv",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
],
|
||||||
|
"RequireTenantPrefix": true,
|
||||||
|
"AliyunOss": {
|
||||||
|
"Region": "",
|
||||||
|
"Endpoint": "",
|
||||||
|
"AccessKeyId": "",
|
||||||
|
"AccessKeySecret": "",
|
||||||
|
"SecurityToken": "",
|
||||||
|
"UseInternalEndpoint": false,
|
||||||
|
"UsePathStyle": false,
|
||||||
|
"UseCName": false,
|
||||||
|
"PresignDefaultMinutes": 15
|
||||||
|
}
|
||||||
|
},
|
||||||
"Security": {
|
"Security": {
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Issuer": "tiku-backend",
|
"Issuer": "tiku-backend",
|
||||||
|
|||||||
25
Tiku.Application/Storage/IObjectStorageService.cs
Normal file
25
Tiku.Application/Storage/IObjectStorageService.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
namespace Tiku.Application.Storage;
|
||||||
|
|
||||||
|
public interface IObjectStorageService
|
||||||
|
{
|
||||||
|
string ConfiguredDefaultProvider();
|
||||||
|
string ConfiguredDefaultBucket();
|
||||||
|
string NormalizeProvider(string? value, string? fallback = null);
|
||||||
|
string ValidateObjectKey(Guid tenantId, string objectKey);
|
||||||
|
string ValidateMimeType(string mimeType);
|
||||||
|
long? ValidateFileSize(long? fileSizeBytes);
|
||||||
|
void AssertUploadProvider(string provider);
|
||||||
|
void AssertWritableLocation(StorageAssetLocation location);
|
||||||
|
|
||||||
|
Task<ObjectStorageSignedUrl> SignUploadAsync(
|
||||||
|
ObjectStorageUploadSignRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ObjectStorageSignedUrl> SignDownloadAsync(
|
||||||
|
ObjectStorageDownloadSignRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ObjectStorageMetadata> HeadObjectAsync(
|
||||||
|
ObjectStorageHeadRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
79
Tiku.Application/Storage/ObjectStorageContracts.cs
Normal file
79
Tiku.Application/Storage/ObjectStorageContracts.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
namespace Tiku.Application.Storage;
|
||||||
|
|
||||||
|
public static class ObjectStorageProviders
|
||||||
|
{
|
||||||
|
public const string ExternalUrl = "external_url";
|
||||||
|
public const string SupabaseStorage = "supabase_storage";
|
||||||
|
public const string AliyunOss = "aliyun_oss";
|
||||||
|
public const string TencentCos = "tencent_cos";
|
||||||
|
public const string QiniuKodo = "qiniu_kodo";
|
||||||
|
public const string LocalDev = "local_dev";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record StorageAssetLocation(
|
||||||
|
string Provider,
|
||||||
|
string? Bucket,
|
||||||
|
string? ObjectKey,
|
||||||
|
string? CdnUrl = null);
|
||||||
|
|
||||||
|
public sealed record ObjectStorageUploadSignRequest(
|
||||||
|
Guid TenantId,
|
||||||
|
string Provider,
|
||||||
|
string Bucket,
|
||||||
|
string ObjectKey,
|
||||||
|
string FileName,
|
||||||
|
string MimeType,
|
||||||
|
long? FileSizeBytes,
|
||||||
|
TimeSpan ExpiresIn,
|
||||||
|
bool Upsert = false);
|
||||||
|
|
||||||
|
public sealed record ObjectStorageDownloadSignRequest(
|
||||||
|
Guid TenantId,
|
||||||
|
string Provider,
|
||||||
|
string? Bucket,
|
||||||
|
string? ObjectKey,
|
||||||
|
TimeSpan ExpiresIn,
|
||||||
|
string? CdnUrl = null,
|
||||||
|
string? FileName = null,
|
||||||
|
string Disposition = "attachment");
|
||||||
|
|
||||||
|
public sealed record ObjectStorageHeadRequest(
|
||||||
|
Guid TenantId,
|
||||||
|
string Provider,
|
||||||
|
string? Bucket,
|
||||||
|
string? ObjectKey,
|
||||||
|
string? DeclaredMimeType = null,
|
||||||
|
long? DeclaredFileSizeBytes = null,
|
||||||
|
string? DeclaredChecksumSha256 = null);
|
||||||
|
|
||||||
|
public sealed record ObjectStorageSignedUrl(
|
||||||
|
string Provider,
|
||||||
|
string? Bucket,
|
||||||
|
string? ObjectKey,
|
||||||
|
string Method,
|
||||||
|
Uri Url,
|
||||||
|
IReadOnlyDictionary<string, string> Headers,
|
||||||
|
DateTimeOffset ExpiresAt,
|
||||||
|
TimeSpan ExpiresIn,
|
||||||
|
string SignatureMode);
|
||||||
|
|
||||||
|
public sealed record ObjectStorageMetadata(
|
||||||
|
string Provider,
|
||||||
|
string? Bucket,
|
||||||
|
string? ObjectKey,
|
||||||
|
bool Exists,
|
||||||
|
long? SizeBytes,
|
||||||
|
string? MimeType,
|
||||||
|
string? ChecksumSha256,
|
||||||
|
string? ETag,
|
||||||
|
string? LastModified,
|
||||||
|
IReadOnlyDictionary<string, string> RawHeaders,
|
||||||
|
string VerificationSource);
|
||||||
|
|
||||||
|
public class ObjectStorageException(string message, string code) : InvalidOperationException(message)
|
||||||
|
{
|
||||||
|
public string Code { get; } = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ObjectStorageNotConfiguredException(string message)
|
||||||
|
: ObjectStorageException(message, "STORAGE_PROVIDER_NOT_CONFIGURED");
|
||||||
@@ -5,12 +5,14 @@ using Tiku.Application.Auth;
|
|||||||
using Tiku.Application.Catalog;
|
using Tiku.Application.Catalog;
|
||||||
using Tiku.Application.Content;
|
using Tiku.Application.Content;
|
||||||
using Tiku.Application.QuestionBanks;
|
using Tiku.Application.QuestionBanks;
|
||||||
|
using Tiku.Application.Storage;
|
||||||
using Tiku.Application.StudyContent;
|
using Tiku.Application.StudyContent;
|
||||||
using Tiku.Infrastructure.Auth;
|
using Tiku.Infrastructure.Auth;
|
||||||
using Tiku.Infrastructure.Catalog;
|
using Tiku.Infrastructure.Catalog;
|
||||||
using Tiku.Infrastructure.Content;
|
using Tiku.Infrastructure.Content;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
using Tiku.Infrastructure.QuestionBanks;
|
using Tiku.Infrastructure.QuestionBanks;
|
||||||
|
using Tiku.Infrastructure.Storage;
|
||||||
using Tiku.Infrastructure.StudyContent;
|
using Tiku.Infrastructure.StudyContent;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure;
|
namespace Tiku.Infrastructure;
|
||||||
@@ -40,6 +42,15 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
|
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
|
||||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||||
|
services.AddOptions<AliyunOssOptions>()
|
||||||
|
.Validate(
|
||||||
|
AliyunOssOptions.BeValid,
|
||||||
|
"Aliyun OSS presign default expiration must be positive.");
|
||||||
|
services.AddOptions<ObjectStorageOptions>()
|
||||||
|
.Validate(
|
||||||
|
ObjectStorageOptions.BeValid,
|
||||||
|
"Storage options must include a default provider, default bucket and positive max upload bytes.");
|
||||||
|
services.AddSingleton<IObjectStorageService, AliyunOssObjectStorageService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
564
Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs
Normal file
564
Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using AlibabaCloud.OSS.V2.Credentials;
|
||||||
|
using AlibabaCloud.OSS.V2.Models;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tiku.Application.Storage;
|
||||||
|
using Oss = AlibabaCloud.OSS.V2;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Storage;
|
||||||
|
|
||||||
|
public sealed partial class AliyunOssObjectStorageService(
|
||||||
|
IOptions<ObjectStorageOptions> storageOptions,
|
||||||
|
IOptions<AliyunOssOptions> aliyunOptions)
|
||||||
|
: IObjectStorageService, IDisposable
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> SupportedProviders =
|
||||||
|
[
|
||||||
|
ObjectStorageProviders.ExternalUrl,
|
||||||
|
ObjectStorageProviders.SupabaseStorage,
|
||||||
|
ObjectStorageProviders.AliyunOss,
|
||||||
|
ObjectStorageProviders.TencentCos,
|
||||||
|
ObjectStorageProviders.QiniuKodo,
|
||||||
|
ObjectStorageProviders.LocalDev
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly HashSet<string> UploadableProviders =
|
||||||
|
[
|
||||||
|
ObjectStorageProviders.LocalDev,
|
||||||
|
ObjectStorageProviders.AliyunOss
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly object clientLock = new();
|
||||||
|
private Oss.Client? client;
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
public string ConfiguredDefaultProvider() =>
|
||||||
|
NormalizeProvider(storageOptions.Value.DefaultProvider, ObjectStorageProviders.LocalDev);
|
||||||
|
|
||||||
|
public string ConfiguredDefaultBucket() =>
|
||||||
|
string.IsNullOrWhiteSpace(storageOptions.Value.DefaultBucket)
|
||||||
|
? "tenant-assets"
|
||||||
|
: storageOptions.Value.DefaultBucket.Trim();
|
||||||
|
|
||||||
|
public string NormalizeProvider(string? value, string? fallback = null)
|
||||||
|
{
|
||||||
|
var provider = (string.IsNullOrWhiteSpace(value) ? fallback : value)?.Trim() ?? ConfiguredDefaultProvider();
|
||||||
|
if (!SupportedProviders.Contains(provider))
|
||||||
|
{
|
||||||
|
throw new ObjectStorageException($"Unsupported storage provider: {provider}", "UNSUPPORTED_STORAGE_PROVIDER");
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ValidateObjectKey(Guid tenantId, string objectKey)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(objectKey);
|
||||||
|
|
||||||
|
var clean = CanonicalObjectKey(objectKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(clean) ||
|
||||||
|
clean.Contains("..", StringComparison.Ordinal) ||
|
||||||
|
clean.Contains('\\', StringComparison.Ordinal) ||
|
||||||
|
clean.Contains("%2f", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
!SafeObjectKeyRegex().IsMatch(clean))
|
||||||
|
{
|
||||||
|
throw new ObjectStorageException("Invalid objectKey.", "INVALID_OBJECT_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storageOptions.Value.RequireTenantPrefix && !clean.StartsWith($"{tenantId:N}/", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new ObjectStorageException(
|
||||||
|
"objectKey must be scoped by tenantId prefix.",
|
||||||
|
"OBJECT_KEY_TENANT_PREFIX_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ValidateMimeType(string mimeType)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(mimeType);
|
||||||
|
|
||||||
|
var normalized = mimeType.Trim().ToLowerInvariant();
|
||||||
|
if (normalized.Length > 160 ||
|
||||||
|
normalized.Contains('\r', StringComparison.Ordinal) ||
|
||||||
|
normalized.Contains('\n', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new ObjectStorageException("Invalid mimeType.", "INVALID_MIME_TYPE");
|
||||||
|
}
|
||||||
|
|
||||||
|
var exact = storageOptions.Value.AllowedMimeTypes
|
||||||
|
.Select(item => item.Trim().ToLowerInvariant())
|
||||||
|
.Where(item => item.Length > 0)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
var prefixes = storageOptions.Value.AllowedMimePrefixes
|
||||||
|
.Select(item => item.Trim().ToLowerInvariant())
|
||||||
|
.Where(item => item.Length > 0)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (!exact.Contains(normalized) &&
|
||||||
|
!prefixes.Any(prefix => normalized.StartsWith(prefix, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
throw new ObjectStorageException($"mimeType is not allowed: {normalized}", "MIME_TYPE_NOT_ALLOWED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long? ValidateFileSize(long? fileSizeBytes)
|
||||||
|
{
|
||||||
|
if (fileSizeBytes is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileSizeBytes < 0)
|
||||||
|
{
|
||||||
|
throw new ObjectStorageException("fileSizeBytes must be non-negative.", "INVALID_FILE_SIZE");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileSizeBytes > storageOptions.Value.MaxUploadBytes)
|
||||||
|
{
|
||||||
|
throw new ObjectStorageException("file exceeds configured storage max upload bytes.", "FILE_TOO_LARGE");
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileSizeBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AssertUploadProvider(string provider)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeProvider(provider);
|
||||||
|
if (!UploadableProviders.Contains(normalized))
|
||||||
|
{
|
||||||
|
throw new ObjectStorageException(
|
||||||
|
$"{normalized} does not support managed upload signing.",
|
||||||
|
"UPLOAD_PROVIDER_NOT_SUPPORTED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AssertWritableLocation(StorageAssetLocation location)
|
||||||
|
{
|
||||||
|
var provider = NormalizeProvider(location.Provider);
|
||||||
|
if (provider == ObjectStorageProviders.AliyunOss &&
|
||||||
|
(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(
|
||||||
|
ObjectStorageUploadSignRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var provider = NormalizeProvider(request.Provider);
|
||||||
|
AssertUploadProvider(provider);
|
||||||
|
var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
|
||||||
|
var mimeType = ValidateMimeType(request.MimeType);
|
||||||
|
ValidateFileSize(request.FileSizeBytes);
|
||||||
|
|
||||||
|
return provider == ObjectStorageProviders.LocalDev
|
||||||
|
? Task.FromResult(LocalSignedUrl(
|
||||||
|
provider,
|
||||||
|
request.Bucket,
|
||||||
|
objectKey,
|
||||||
|
"PUT",
|
||||||
|
request.ExpiresIn,
|
||||||
|
"attachment",
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["content-type"] = mimeType
|
||||||
|
}))
|
||||||
|
: Task.FromResult(SignAliyunOss(
|
||||||
|
request.Bucket,
|
||||||
|
objectKey,
|
||||||
|
"PUT",
|
||||||
|
request.ExpiresIn,
|
||||||
|
mimeType: mimeType,
|
||||||
|
fileName: null,
|
||||||
|
disposition: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ObjectStorageSignedUrl> SignDownloadAsync(
|
||||||
|
ObjectStorageDownloadSignRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var provider = NormalizeProvider(request.Provider);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.CdnUrl))
|
||||||
|
{
|
||||||
|
return Task.FromResult(new ObjectStorageSignedUrl(
|
||||||
|
provider,
|
||||||
|
request.Bucket,
|
||||||
|
request.ObjectKey,
|
||||||
|
"GET",
|
||||||
|
new Uri(request.CdnUrl, UriKind.Absolute),
|
||||||
|
new Dictionary<string, string>(),
|
||||||
|
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
|
||||||
|
request.ExpiresIn,
|
||||||
|
"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(
|
||||||
|
provider,
|
||||||
|
request.Bucket,
|
||||||
|
objectKey,
|
||||||
|
"GET",
|
||||||
|
request.ExpiresIn,
|
||||||
|
request.Disposition));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider != ObjectStorageProviders.AliyunOss)
|
||||||
|
{
|
||||||
|
throw new ObjectStorageException($"{provider} asset requires cdnUrl.", "ASSET_LOCATION_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Bucket))
|
||||||
|
{
|
||||||
|
throw new ObjectStorageException("Aliyun OSS asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(SignAliyunOss(
|
||||||
|
request.Bucket,
|
||||||
|
objectKey,
|
||||||
|
"GET",
|
||||||
|
request.ExpiresIn,
|
||||||
|
mimeType: null,
|
||||||
|
request.FileName,
|
||||||
|
request.Disposition));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ObjectStorageMetadata> HeadObjectAsync(
|
||||||
|
ObjectStorageHeadRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var provider = NormalizeProvider(request.Provider);
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ObjectKey))
|
||||||
|
{
|
||||||
|
throw new ObjectStorageException("Asset requires objectKey.", "ASSET_LOCATION_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
|
||||||
|
var fileSizeBytes = ValidateFileSize(request.DeclaredFileSizeBytes);
|
||||||
|
var mimeType = request.DeclaredMimeType is null ? null : ValidateMimeType(request.DeclaredMimeType);
|
||||||
|
var checksumSha256 = request.DeclaredChecksumSha256?.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
if (provider == ObjectStorageProviders.LocalDev)
|
||||||
|
{
|
||||||
|
return new ObjectStorageMetadata(
|
||||||
|
provider,
|
||||||
|
request.Bucket,
|
||||||
|
objectKey,
|
||||||
|
Exists: true,
|
||||||
|
fileSizeBytes,
|
||||||
|
mimeType,
|
||||||
|
checksumSha256,
|
||||||
|
checksumSha256,
|
||||||
|
DateTimeOffset.UtcNow.ToString("O"),
|
||||||
|
new Dictionary<string, string>(),
|
||||||
|
"local-dev-declared-metadata");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider != ObjectStorageProviders.AliyunOss)
|
||||||
|
{
|
||||||
|
throw new ObjectStorageException(
|
||||||
|
$"{provider} does not support upload confirmation.",
|
||||||
|
"UPLOAD_CONFIRM_PROVIDER_NOT_SUPPORTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Bucket))
|
||||||
|
{
|
||||||
|
throw new ObjectStorageException("Aliyun OSS asset requires bucket and objectKey.", "ASSET_OBJECT_LOCATION_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await GetClient().HeadObjectAsync(new HeadObjectRequest
|
||||||
|
{
|
||||||
|
Bucket = request.Bucket,
|
||||||
|
Key = objectKey
|
||||||
|
}, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
return MetadataFromHeadResult(request.Bucket, objectKey, result);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (TryGetStatusCode(exception) == 404)
|
||||||
|
{
|
||||||
|
throw new ObjectStorageException(
|
||||||
|
"Uploaded object was not found in Aliyun OSS.",
|
||||||
|
"STORAGE_OBJECT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (clientLock)
|
||||||
|
{
|
||||||
|
client?.Dispose();
|
||||||
|
client = null;
|
||||||
|
disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectStorageSignedUrl SignAliyunOss(
|
||||||
|
string bucket,
|
||||||
|
string objectKey,
|
||||||
|
string method,
|
||||||
|
TimeSpan expiresIn,
|
||||||
|
string? mimeType,
|
||||||
|
string? fileName,
|
||||||
|
string? disposition)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(bucket);
|
||||||
|
var expiresAt = ResolveExpiration(expiresIn);
|
||||||
|
|
||||||
|
PresignResult result;
|
||||||
|
if (method == "PUT")
|
||||||
|
{
|
||||||
|
result = GetClient().Presign(new PutObjectRequest
|
||||||
|
{
|
||||||
|
Bucket = bucket,
|
||||||
|
Key = objectKey,
|
||||||
|
ContentType = mimeType
|
||||||
|
}, expiresAt.UtcDateTime);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result = GetClient().Presign(new GetObjectRequest
|
||||||
|
{
|
||||||
|
Bucket = bucket,
|
||||||
|
Key = objectKey,
|
||||||
|
ResponseContentDisposition = BuildContentDisposition(fileName, disposition)
|
||||||
|
}, expiresAt.UtcDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToSignedUrl(result, ObjectStorageProviders.AliyunOss, bucket, objectKey, method, expiresIn, "aliyun-oss-signature-url-v4");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectStorageSignedUrl LocalSignedUrl(
|
||||||
|
string provider,
|
||||||
|
string? bucket,
|
||||||
|
string? objectKey,
|
||||||
|
string method,
|
||||||
|
TimeSpan expiresIn,
|
||||||
|
string? disposition,
|
||||||
|
IReadOnlyDictionary<string, string>? headers = null)
|
||||||
|
{
|
||||||
|
var expiresAt = ResolveExpiration(expiresIn);
|
||||||
|
var baseUrl = storageOptions.Value.PublicBaseUrl?.Trim().TrimEnd('/');
|
||||||
|
var path = $"{Uri.EscapeDataString(bucket ?? "default")}/{Uri.EscapeDataString(objectKey ?? "missing")}";
|
||||||
|
var url = string.IsNullOrWhiteSpace(baseUrl)
|
||||||
|
? $"https://local-storage.invalid/{provider}/{path}?expiresAt={Uri.EscapeDataString(expiresAt.ToString("O"))}&disposition={disposition ?? "attachment"}"
|
||||||
|
: $"{baseUrl}/{path}?expiresAt={Uri.EscapeDataString(expiresAt.ToString("O"))}&disposition={disposition ?? "attachment"}";
|
||||||
|
|
||||||
|
return new ObjectStorageSignedUrl(
|
||||||
|
provider,
|
||||||
|
bucket,
|
||||||
|
objectKey,
|
||||||
|
method,
|
||||||
|
new Uri(url),
|
||||||
|
headers ?? new Dictionary<string, string>(),
|
||||||
|
expiresAt,
|
||||||
|
expiresIn,
|
||||||
|
"local-placeholder");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Oss.Client GetClient()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
|
||||||
|
if (client is not null)
|
||||||
|
{
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (clientLock)
|
||||||
|
{
|
||||||
|
client ??= CreateClient(aliyunOptions.Value);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Oss.Client CreateClient(AliyunOssOptions currentOptions)
|
||||||
|
{
|
||||||
|
if (!currentOptions.IsConfigured)
|
||||||
|
{
|
||||||
|
throw new ObjectStorageNotConfiguredException(
|
||||||
|
"aliyun_oss is not configured: ALIYUN_OSS_ACCESS_KEY_ID, ALIYUN_OSS_ACCESS_KEY_SECRET and ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var configuration = Oss.Configuration.LoadDefault();
|
||||||
|
configuration.Region = currentOptions.Region?.Trim();
|
||||||
|
configuration.Endpoint = NullIfWhiteSpace(currentOptions.Endpoint);
|
||||||
|
configuration.UseInternalEndpoint = currentOptions.UseInternalEndpoint;
|
||||||
|
configuration.UsePathStyle = currentOptions.UsePathStyle;
|
||||||
|
configuration.UseCName = currentOptions.UseCName;
|
||||||
|
configuration.DisableSsl = false;
|
||||||
|
configuration.CredentialsProvider = string.IsNullOrWhiteSpace(currentOptions.SecurityToken)
|
||||||
|
? new StaticCredentialsProvider(
|
||||||
|
currentOptions.AccessKeyId!.Trim(),
|
||||||
|
currentOptions.AccessKeySecret!.Trim())
|
||||||
|
: new StaticCredentialsProvider(
|
||||||
|
currentOptions.AccessKeyId!.Trim(),
|
||||||
|
currentOptions.AccessKeySecret!.Trim(),
|
||||||
|
currentOptions.SecurityToken!.Trim());
|
||||||
|
|
||||||
|
return new Oss.Client(configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ObjectStorageSignedUrl ToSignedUrl(
|
||||||
|
PresignResult result,
|
||||||
|
string provider,
|
||||||
|
string bucket,
|
||||||
|
string objectKey,
|
||||||
|
string method,
|
||||||
|
TimeSpan expiresIn,
|
||||||
|
string signatureMode)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(result.Url, UriKind.Absolute, out var url))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Aliyun OSS SDK returned an invalid presigned URL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ObjectStorageSignedUrl(
|
||||||
|
provider,
|
||||||
|
bucket,
|
||||||
|
objectKey,
|
||||||
|
string.IsNullOrWhiteSpace(result.Method) ? method : result.Method,
|
||||||
|
url,
|
||||||
|
result.SignedHeaders is null
|
||||||
|
? new Dictionary<string, string>()
|
||||||
|
: new Dictionary<string, string>(result.SignedHeaders, StringComparer.OrdinalIgnoreCase),
|
||||||
|
result.Expiration.HasValue
|
||||||
|
? new DateTimeOffset(DateTime.SpecifyKind(result.Expiration.Value, DateTimeKind.Utc))
|
||||||
|
: DateTimeOffset.UtcNow.Add(expiresIn),
|
||||||
|
expiresIn,
|
||||||
|
signatureMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ObjectStorageMetadata MetadataFromHeadResult(
|
||||||
|
string bucket,
|
||||||
|
string objectKey,
|
||||||
|
HeadObjectResult result)
|
||||||
|
{
|
||||||
|
var headers = NormalizeHeaders(result.Headers);
|
||||||
|
foreach (var metadata in result.Metadata ?? new Dictionary<string, string>())
|
||||||
|
{
|
||||||
|
headers[$"x-oss-meta-{metadata.Key}".ToLowerInvariant()] = metadata.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ObjectStorageMetadata(
|
||||||
|
ObjectStorageProviders.AliyunOss,
|
||||||
|
bucket,
|
||||||
|
objectKey,
|
||||||
|
Exists: true,
|
||||||
|
result.ContentLength,
|
||||||
|
NullIfWhiteSpace(result.ContentType),
|
||||||
|
FirstHeader(headers, "x-oss-meta-sha256", "x-oss-meta-checksum-sha256")?.Trim().ToLowerInvariant(),
|
||||||
|
CleanEtag(result.ETag ?? FirstHeader(headers, "etag")),
|
||||||
|
NullIfWhiteSpace(result.LastModified) ?? FirstHeader(headers, "last-modified"),
|
||||||
|
headers,
|
||||||
|
"aliyun-oss-head-object");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> NormalizeHeaders(IDictionary<string, string>? headers)
|
||||||
|
{
|
||||||
|
var normalized = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (headers is null)
|
||||||
|
{
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (key, value) in headers)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(key) && value is not null)
|
||||||
|
{
|
||||||
|
normalized[key.ToLowerInvariant()] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? TryGetStatusCode(Exception exception)
|
||||||
|
{
|
||||||
|
var type = exception.GetType();
|
||||||
|
var property = type.GetProperty("StatusCode", BindingFlags.Public | BindingFlags.Instance) ??
|
||||||
|
type.GetProperty("Status", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
return property?.GetValue(exception) switch
|
||||||
|
{
|
||||||
|
int statusCode => statusCode,
|
||||||
|
long statusCode => checked((int)statusCode),
|
||||||
|
string value when int.TryParse(value, out var statusCode) => statusCode,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset ResolveExpiration(TimeSpan expiresIn)
|
||||||
|
{
|
||||||
|
if (expiresIn <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(expiresIn), expiresIn, "Signed URL expiration must be positive.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTimeOffset.UtcNow.Add(expiresIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CanonicalObjectKey(string objectKey) =>
|
||||||
|
ConsecutiveSlashRegex().Replace(objectKey.Trim().TrimStart('/'), "/");
|
||||||
|
|
||||||
|
private static string? BuildContentDisposition(string? fileName, string? disposition)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedDisposition = string.Equals(disposition, "inline", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "inline"
|
||||||
|
: "attachment";
|
||||||
|
return $"{normalizedDisposition}; filename=\"{Uri.EscapeDataString(fileName.Trim())}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FirstHeader(IReadOnlyDictionary<string, string> headers, params string[] keys)
|
||||||
|
{
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
if (headers.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? CleanEtag(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim().Trim('"');
|
||||||
|
|
||||||
|
private static string? NullIfWhiteSpace(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
[GeneratedRegex("""^[A-Za-z0-9][A-Za-z0-9._~!$&'()+,;=@/-]{0,1023}$""")]
|
||||||
|
private static partial Regex SafeObjectKeyRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex("/{2,}")]
|
||||||
|
private static partial Regex ConsecutiveSlashRegex();
|
||||||
|
}
|
||||||
26
Tiku.Infrastructure/Storage/AliyunOssOptions.cs
Normal file
26
Tiku.Infrastructure/Storage/AliyunOssOptions.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Tiku.Infrastructure.Storage;
|
||||||
|
|
||||||
|
public sealed class AliyunOssOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Storage:AliyunOss";
|
||||||
|
|
||||||
|
public string? Region { get; set; }
|
||||||
|
public string? Endpoint { get; set; }
|
||||||
|
public string? AccessKeyId { get; set; }
|
||||||
|
public string? AccessKeySecret { get; set; }
|
||||||
|
public string? SecurityToken { get; set; }
|
||||||
|
public bool UseInternalEndpoint { get; set; }
|
||||||
|
public bool UsePathStyle { get; set; }
|
||||||
|
public bool UseCName { get; set; }
|
||||||
|
public int PresignDefaultMinutes { get; set; } = 15;
|
||||||
|
|
||||||
|
public bool IsConfigured =>
|
||||||
|
(!string.IsNullOrWhiteSpace(Region) || !string.IsNullOrWhiteSpace(Endpoint)) &&
|
||||||
|
!string.IsNullOrWhiteSpace(AccessKeyId) &&
|
||||||
|
!string.IsNullOrWhiteSpace(AccessKeySecret);
|
||||||
|
|
||||||
|
public static bool BeValid(AliyunOssOptions options)
|
||||||
|
{
|
||||||
|
return options.PresignDefaultMinutes > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Tiku.Infrastructure/Storage/ObjectStorageOptions.cs
Normal file
28
Tiku.Infrastructure/Storage/ObjectStorageOptions.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
namespace Tiku.Infrastructure.Storage;
|
||||||
|
|
||||||
|
public sealed class ObjectStorageOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Storage";
|
||||||
|
|
||||||
|
public string DefaultProvider { get; set; } = "local_dev";
|
||||||
|
public string DefaultBucket { get; set; } = "tenant-assets";
|
||||||
|
public string? PublicBaseUrl { get; set; }
|
||||||
|
public long MaxUploadBytes { get; set; } = 1024L * 1024 * 500;
|
||||||
|
public string[] AllowedMimePrefixes { get; set; } = ["image/", "video/", "audio/"];
|
||||||
|
public string[] AllowedMimeTypes { get; set; } =
|
||||||
|
[
|
||||||
|
"application/pdf",
|
||||||
|
"application/json",
|
||||||
|
"text/csv",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
];
|
||||||
|
|
||||||
|
public bool RequireTenantPrefix { get; set; } = true;
|
||||||
|
|
||||||
|
public static bool BeValid(ObjectStorageOptions options) =>
|
||||||
|
options.MaxUploadBytes > 0 &&
|
||||||
|
!string.IsNullOrWhiteSpace(options.DefaultProvider) &&
|
||||||
|
!string.IsNullOrWhiteSpace(options.DefaultBucket);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AlibabaCloud.OSS.V2" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
|
|||||||
134
Tiku.UnitTests/Storage/AliyunOssObjectStorageServiceTests.cs
Normal file
134
Tiku.UnitTests/Storage/AliyunOssObjectStorageServiceTests.cs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tiku.Application.Storage;
|
||||||
|
using Tiku.Infrastructure.Storage;
|
||||||
|
|
||||||
|
namespace Tiku.UnitTests.Storage;
|
||||||
|
|
||||||
|
public sealed class AliyunOssObjectStorageServiceTests
|
||||||
|
{
|
||||||
|
private static readonly Guid TenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_object_key_requires_tenant_prefix_like_original_storage_service()
|
||||||
|
{
|
||||||
|
using var service = CreateConfiguredService();
|
||||||
|
|
||||||
|
var exception = Assert.Throws<ObjectStorageException>(() =>
|
||||||
|
service.ValidateObjectKey(TenantId, "other-tenant/file.pdf"));
|
||||||
|
|
||||||
|
Assert.Equal("OBJECT_KEY_TENANT_PREFIX_REQUIRED", exception.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_mime_type_and_size_use_configured_allow_list()
|
||||||
|
{
|
||||||
|
using var service = CreateConfiguredService(new ObjectStorageOptions
|
||||||
|
{
|
||||||
|
MaxUploadBytes = 100,
|
||||||
|
AllowedMimePrefixes = ["image/"],
|
||||||
|
AllowedMimeTypes = ["application/pdf"]
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("image/png", service.ValidateMimeType(" Image/PNG "));
|
||||||
|
Assert.Equal(99, service.ValidateFileSize(99));
|
||||||
|
Assert.Equal("MIME_TYPE_NOT_ALLOWED", Assert.Throws<ObjectStorageException>(() => service.ValidateMimeType("text/html")).Code);
|
||||||
|
Assert.Equal("FILE_TOO_LARGE", Assert.Throws<ObjectStorageException>(() => service.ValidateFileSize(101)).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sign_download_requires_complete_aliyun_oss_options()
|
||||||
|
{
|
||||||
|
using var service = CreateUnconfiguredService();
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<ObjectStorageNotConfiguredException>(() =>
|
||||||
|
service.SignDownloadAsync(new ObjectStorageDownloadSignRequest(
|
||||||
|
TenantId,
|
||||||
|
ObjectStorageProviders.AliyunOss,
|
||||||
|
"bucket",
|
||||||
|
$"{TenantId:N}/path/file.pdf",
|
||||||
|
TimeSpan.FromMinutes(15))));
|
||||||
|
|
||||||
|
Assert.Contains("ALIYUN_OSS_ACCESS_KEY_ID", exception.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sign_download_generates_aliyun_oss_presigned_get_url()
|
||||||
|
{
|
||||||
|
using var service = CreateConfiguredService();
|
||||||
|
|
||||||
|
var signedUrl = await service.SignDownloadAsync(new ObjectStorageDownloadSignRequest(
|
||||||
|
TenantId,
|
||||||
|
ObjectStorageProviders.AliyunOss,
|
||||||
|
"tiku-assets",
|
||||||
|
$"{TenantId:N}/handbook.pdf",
|
||||||
|
TimeSpan.FromMinutes(15),
|
||||||
|
FileName: "handbook.pdf",
|
||||||
|
Disposition: "attachment"));
|
||||||
|
|
||||||
|
Assert.Equal(ObjectStorageProviders.AliyunOss, signedUrl.Provider);
|
||||||
|
Assert.Equal("GET", signedUrl.Method);
|
||||||
|
Assert.Equal("aliyun-oss-signature-url-v4", signedUrl.SignatureMode);
|
||||||
|
Assert.Contains("tiku-assets", signedUrl.Url.Host, StringComparison.Ordinal);
|
||||||
|
Assert.Contains($"{TenantId:N}/handbook.pdf", signedUrl.Url.AbsoluteUri, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("x-oss-", signedUrl.Url.Query, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sign_upload_generates_aliyun_oss_presigned_put_url_with_content_type_header()
|
||||||
|
{
|
||||||
|
using var service = CreateConfiguredService();
|
||||||
|
|
||||||
|
var signedUrl = await service.SignUploadAsync(new ObjectStorageUploadSignRequest(
|
||||||
|
TenantId,
|
||||||
|
ObjectStorageProviders.AliyunOss,
|
||||||
|
"tiku-assets",
|
||||||
|
$"{TenantId:N}/upload.pdf",
|
||||||
|
"upload.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
1024,
|
||||||
|
TimeSpan.FromMinutes(10)));
|
||||||
|
|
||||||
|
Assert.Equal("PUT", signedUrl.Method);
|
||||||
|
Assert.Contains("x-oss-", signedUrl.Url.Query, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cdn_url_download_returns_public_or_provider_managed_url_without_oss_signing()
|
||||||
|
{
|
||||||
|
using var service = CreateUnconfiguredService();
|
||||||
|
|
||||||
|
var signedUrl = await service.SignDownloadAsync(new ObjectStorageDownloadSignRequest(
|
||||||
|
TenantId,
|
||||||
|
ObjectStorageProviders.ExternalUrl,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
TimeSpan.FromMinutes(5),
|
||||||
|
"https://cdn.example.com/file.pdf"));
|
||||||
|
|
||||||
|
Assert.Equal("public-or-provider-managed", signedUrl.SignatureMode);
|
||||||
|
Assert.Equal("https://cdn.example.com/file.pdf", signedUrl.Url.AbsoluteUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Aliyun_oss_options_allow_empty_optional_configuration()
|
||||||
|
{
|
||||||
|
Assert.True(AliyunOssOptions.BeValid(new AliyunOssOptions()));
|
||||||
|
Assert.False(AliyunOssOptions.BeValid(new AliyunOssOptions
|
||||||
|
{
|
||||||
|
PresignDefaultMinutes = 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AliyunOssObjectStorageService CreateConfiguredService(ObjectStorageOptions? storageOptions = null) =>
|
||||||
|
new(
|
||||||
|
Options.Create(storageOptions ?? new ObjectStorageOptions()),
|
||||||
|
Options.Create(new AliyunOssOptions
|
||||||
|
{
|
||||||
|
Region = "cn-hangzhou",
|
||||||
|
AccessKeyId = "test-access-key-id",
|
||||||
|
AccessKeySecret = "test-access-key-secret"
|
||||||
|
}));
|
||||||
|
|
||||||
|
private static AliyunOssObjectStorageService CreateUnconfiguredService() =>
|
||||||
|
new(Options.Create(new ObjectStorageOptions()), Options.Create(new AliyunOssOptions()));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user