diff --git a/Directory.Packages.props b/Directory.Packages.props index 49ea63a..59a5902 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,6 +24,7 @@ + diff --git a/README.md b/README.md index 7022199..3167ec4 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,17 @@ Tiku.IntegrationTests # EF 模型/持久化约束测试 - 当前先在小范围权限判断里落模板,后续题库筛选、权限集合、菜单/内容树投影等热路径再逐步使用。 - 不是为了炫技替换所有 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 存储: @@ -213,6 +224,18 @@ Tiku.Infrastructure/Persistence/Migrations/20260725220742_InitialSchema.cs - 后台 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` ## 常用命令 diff --git a/Tiku.Api/Program.cs b/Tiku.Api/Program.cs index 5a62e14..7e4c160 100644 --- a/Tiku.Api/Program.cs +++ b/Tiku.Api/Program.cs @@ -18,6 +18,7 @@ using Tiku.Application; using Tiku.Application.Security; using Tiku.Infrastructure; using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Storage; Log.Logger = new LoggerConfiguration() .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) @@ -138,6 +139,39 @@ try "Host=localhost;Database=tiku;Username=postgres"; builder.Services.AddInfrastructure(connectionString); + builder.Services.Configure( + builder.Configuration.GetSection(ObjectStorageOptions.SectionName)); + builder.Services.Configure( + builder.Configuration.GetSection(AliyunOssOptions.SectionName)); + builder.Services.PostConfigure(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(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() .Bind(builder.Configuration.GetSection("Security:Jwt")) @@ -250,4 +284,9 @@ finally Log.CloseAndFlush(); } +static string[] SplitLegacyList(string? value, string[] fallback) => + string.IsNullOrWhiteSpace(value) + ? fallback + : value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + public partial class Program; diff --git a/Tiku.Api/appsettings.Development.json b/Tiku.Api/appsettings.Development.json index 65ebbc4..2692155 100644 --- a/Tiku.Api/appsettings.Development.json +++ b/Tiku.Api/appsettings.Development.json @@ -23,5 +23,12 @@ "PermitLimit": 1200, "WindowSeconds": 60, "QueueLimit": 0 + }, + "Storage": { + "AliyunOss": { + "Region": "cn-hangzhou", + "Endpoint": "", + "UseInternalEndpoint": false + } } } diff --git a/Tiku.Api/appsettings.json b/Tiku.Api/appsettings.json index 7616b9d..c4a375d 100644 --- a/Tiku.Api/appsettings.json +++ b/Tiku.Api/appsettings.json @@ -49,6 +49,37 @@ "WindowSeconds": 60, "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": { "Jwt": { "Issuer": "tiku-backend", diff --git a/Tiku.Application/Storage/IObjectStorageService.cs b/Tiku.Application/Storage/IObjectStorageService.cs new file mode 100644 index 0000000..ab3a243 --- /dev/null +++ b/Tiku.Application/Storage/IObjectStorageService.cs @@ -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 SignUploadAsync( + ObjectStorageUploadSignRequest request, + CancellationToken cancellationToken = default); + + Task SignDownloadAsync( + ObjectStorageDownloadSignRequest request, + CancellationToken cancellationToken = default); + + Task HeadObjectAsync( + ObjectStorageHeadRequest request, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Storage/ObjectStorageContracts.cs b/Tiku.Application/Storage/ObjectStorageContracts.cs new file mode 100644 index 0000000..7021ec5 --- /dev/null +++ b/Tiku.Application/Storage/ObjectStorageContracts.cs @@ -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 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 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"); diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 31f1e30..faa47f9 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -5,12 +5,14 @@ using Tiku.Application.Auth; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.QuestionBanks; +using Tiku.Application.Storage; using Tiku.Application.StudyContent; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Catalog; using Tiku.Infrastructure.Content; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.QuestionBanks; +using Tiku.Infrastructure.Storage; using Tiku.Infrastructure.StudyContent; namespace Tiku.Infrastructure; @@ -40,6 +42,15 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddOptions() + .Validate( + AliyunOssOptions.BeValid, + "Aliyun OSS presign default expiration must be positive."); + services.AddOptions() + .Validate( + ObjectStorageOptions.BeValid, + "Storage options must include a default provider, default bucket and positive max upload bytes."); + services.AddSingleton(); return services; } diff --git a/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs b/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs new file mode 100644 index 0000000..70a171e --- /dev/null +++ b/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs @@ -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 storageOptions, + IOptions aliyunOptions) + : IObjectStorageService, IDisposable +{ + private static readonly HashSet SupportedProviders = + [ + ObjectStorageProviders.ExternalUrl, + ObjectStorageProviders.SupabaseStorage, + ObjectStorageProviders.AliyunOss, + ObjectStorageProviders.TencentCos, + ObjectStorageProviders.QiniuKodo, + ObjectStorageProviders.LocalDev + ]; + + private static readonly HashSet 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 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(StringComparer.OrdinalIgnoreCase) + { + ["content-type"] = mimeType + })) + : Task.FromResult(SignAliyunOss( + request.Bucket, + objectKey, + "PUT", + request.ExpiresIn, + mimeType: mimeType, + fileName: null, + disposition: null)); + } + + public Task 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(), + 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 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(), + "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? 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(), + 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() + : new Dictionary(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()) + { + 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 NormalizeHeaders(IDictionary? headers) + { + var normalized = new Dictionary(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 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(); +} diff --git a/Tiku.Infrastructure/Storage/AliyunOssOptions.cs b/Tiku.Infrastructure/Storage/AliyunOssOptions.cs new file mode 100644 index 0000000..916b0be --- /dev/null +++ b/Tiku.Infrastructure/Storage/AliyunOssOptions.cs @@ -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; + } +} diff --git a/Tiku.Infrastructure/Storage/ObjectStorageOptions.cs b/Tiku.Infrastructure/Storage/ObjectStorageOptions.cs new file mode 100644 index 0000000..2e1b425 --- /dev/null +++ b/Tiku.Infrastructure/Storage/ObjectStorageOptions.cs @@ -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); +} diff --git a/Tiku.Infrastructure/Tiku.Infrastructure.csproj b/Tiku.Infrastructure/Tiku.Infrastructure.csproj index ddcaad4..800c076 100644 --- a/Tiku.Infrastructure/Tiku.Infrastructure.csproj +++ b/Tiku.Infrastructure/Tiku.Infrastructure.csproj @@ -6,6 +6,7 @@ + diff --git a/Tiku.UnitTests/Storage/AliyunOssObjectStorageServiceTests.cs b/Tiku.UnitTests/Storage/AliyunOssObjectStorageServiceTests.cs new file mode 100644 index 0000000..944573a --- /dev/null +++ b/Tiku.UnitTests/Storage/AliyunOssObjectStorageServiceTests.cs @@ -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(() => + 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(() => service.ValidateMimeType("text/html")).Code); + Assert.Equal("FILE_TOO_LARGE", Assert.Throws(() => service.ValidateFileSize(101)).Code); + } + + [Fact] + public async Task Sign_download_requires_complete_aliyun_oss_options() + { + using var service = CreateUnconfiguredService(); + + var exception = await Assert.ThrowsAsync(() => + 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())); +}