using System.Reflection; using System.Security.Cryptography; 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.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 async Task WriteObjectAsync( ObjectStorageWriteRequest 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); var fileSizeBytes = ValidateFileSize(request.FileSizeBytes); var checksumSha256 = NormalizeChecksum(request.ChecksumSha256); if (request.Content.CanSeek) { request.Content.Position = 0; } if (string.IsNullOrWhiteSpace(request.Bucket)) { 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(), "local-dev-write-placeholder"); } if (provider != ObjectStorageProviders.AliyunOss) { throw new ObjectStorageException( $"{provider} does not support managed server-side writes.", "WRITE_PROVIDER_NOT_SUPPORTED"); } var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); if (request.Metadata is not null) { foreach (var (key, value) in request.Metadata) { if (!string.IsNullOrWhiteSpace(key) && value is not null) { metadata[key.Trim()] = value; } } } if (!string.IsNullOrWhiteSpace(checksumSha256)) { metadata["sha256"] = checksumSha256; } var result = await GetClient().PutObjectAsync(new PutObjectRequest { Bucket = request.Bucket, Key = objectKey, Body = request.Content, ContentType = mimeType, ContentLength = fileSizeBytes, Metadata = metadata.Count == 0 ? null : metadata, ForbidOverwrite = !request.Upsert }, cancellationToken: cancellationToken); return new ObjectStorageWriteResult( provider, request.Bucket, objectKey, BuildPublicUrl(request.Bucket, objectKey), fileSizeBytes, mimeType, checksumSha256, CleanEtag(result.ETag), NormalizeHeaders(result.Headers), "aliyun-oss-put-object"); } 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 Uri? BuildPublicUrl(string bucket, string objectKey) { var baseUrl = storageOptions.Value.PublicBaseUrl?.Trim().TrimEnd('/'); if (string.IsNullOrWhiteSpace(baseUrl)) { return null; } var path = string.Join( '/', bucket.Trim('/'), objectKey .Split('/', StringSplitOptions.RemoveEmptyEntries) .Select(Uri.EscapeDataString)); return new Uri($"{baseUrl}/{path}", UriKind.Absolute); } 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(); private static string? NormalizeChecksum(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } var normalized = value.Trim().ToLowerInvariant(); return normalized.Length == 64 ? normalized : null; } private static async Task ComputeSha256Async(Stream stream, CancellationToken cancellationToken) { if (stream.CanSeek) { stream.Position = 0; } var hash = await SHA256.HashDataAsync(stream, cancellationToken); if (stream.CanSeek) { stream.Position = 0; } return Convert.ToHexString(hash).ToLowerInvariant(); } [GeneratedRegex("""^[A-Za-z0-9][A-Za-z0-9._~!$&'()+,;=@/-]{0,1023}$""")] private static partial Regex SafeObjectKeyRegex(); [GeneratedRegex("/{2,}")] private static partial Regex ConsecutiveSlashRegex(); }