Files
tiku-backend.net/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs
xiong a517ffc6a7
Some checks failed
ci / release-gate (push) Has been cancelled
feat(dev): containerize local dependencies
2026-08-04 13:27:25 +08:00

769 lines
30 KiB
C#

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 Minio;
using Minio.DataModel.Args;
using Minio.Exceptions;
using Tiku.Application.Storage;
using Oss = AlibabaCloud.OSS.V2;
namespace Tiku.Infrastructure.Storage;
public sealed partial class AliyunOssObjectStorageService(
IOptions<ObjectStorageOptions> storageOptions,
IOptions<AliyunOssOptions> aliyunOptions,
IOptions<S3CompatibleOptions> s3Options)
: IObjectStorageService, IDisposable
{
private static readonly HashSet<string> SupportedProviders =
[
ObjectStorageProviders.ExternalUrl,
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 IMinioClient? s3Client;
private bool disposed;
public void Dispose()
{
if (disposed) return;
lock (clientLock)
{
client?.Dispose();
client = null;
s3Client?.Dispose();
s3Client = null;
disposed = true;
}
}
public string ConfiguredDefaultProvider()
{
return NormalizeProvider(storageOptions.Value.DefaultProvider, ObjectStorageProviders.LocalDev);
}
public string ConfiguredDefaultBucket()
{
return 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 is ObjectStorageProviders.AliyunOss or ObjectStorageProviders.LocalDev &&
(string.IsNullOrWhiteSpace(location.Bucket) || string.IsNullOrWhiteSpace(location.ObjectKey)))
throw new ObjectStorageException(
$"{provider} asset requires bucket and objectKey.",
"ASSET_OBJECT_LOCATION_REQUIRED");
}
public async 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);
if (provider == ObjectStorageProviders.LocalDev)
{
await EnsureS3BucketAsync(request.Bucket, cancellationToken);
var url = await GetS3Client().PresignedPutObjectAsync(new PresignedPutObjectArgs()
.WithBucket(request.Bucket)
.WithObject(objectKey)
.WithExpiry(ExpirySeconds(request.ExpiresIn)));
return new ObjectStorageSignedUrl(
provider,
request.Bucket,
objectKey,
"PUT",
new Uri(url, UriKind.Absolute),
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["content-type"] = mimeType
},
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
request.ExpiresIn,
"s3-compatible-presigned-put");
}
return SignAliyunOss(
request.Bucket,
objectKey,
"PUT",
request.ExpiresIn,
mimeType,
null,
null);
}
public async Task<ObjectStorageSignedUrl> SignDownloadAsync(
ObjectStorageDownloadSignRequest request,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var provider = NormalizeProvider(request.Provider);
if (!string.IsNullOrWhiteSpace(request.CdnUrl))
return 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 == ObjectStorageProviders.LocalDev)
{
if (string.IsNullOrWhiteSpace(request.Bucket))
throw new ObjectStorageException("S3-compatible asset requires bucket.",
"ASSET_OBJECT_LOCATION_REQUIRED");
var url = await GetS3Client().PresignedGetObjectAsync(new PresignedGetObjectArgs()
.WithBucket(request.Bucket)
.WithObject(objectKey)
.WithExpiry(ExpirySeconds(request.ExpiresIn)));
return new ObjectStorageSignedUrl(
provider,
request.Bucket,
objectKey,
"GET",
new Uri(url, UriKind.Absolute),
new Dictionary<string, string>(),
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
request.ExpiresIn,
"s3-compatible-presigned-get");
}
if (provider == ObjectStorageProviders.QiniuKodo)
return LocalSignedUrl(provider, request.Bucket, objectKey, "GET", request.ExpiresIn,
request.Disposition);
if (provider != ObjectStorageProviders.AliyunOss)
throw new ObjectStorageException($"{provider} asset requires cdnUrl.", "ASSET_LOCATION_REQUIRED");
if (string.IsNullOrWhiteSpace(request.Bucket))
throw new ObjectStorageException("Aliyun OSS asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED");
return SignAliyunOss(
request.Bucket,
objectKey,
"GET",
request.ExpiresIn,
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)
{
if (string.IsNullOrWhiteSpace(request.Bucket))
throw new ObjectStorageException("S3-compatible asset requires bucket.",
"ASSET_OBJECT_LOCATION_REQUIRED");
try
{
var result = await GetS3Client().StatObjectAsync(new StatObjectArgs()
.WithBucket(request.Bucket)
.WithObject(objectKey), cancellationToken);
return new ObjectStorageMetadata(
provider,
request.Bucket,
objectKey,
true,
result.Size,
result.ContentType,
checksumSha256,
result.ETag,
result.LastModified.ToString("O"),
new Dictionary<string, string>(),
"s3-compatible-stat-object");
}
catch (ObjectNotFoundException)
{
throw new ObjectStorageException("Uploaded object was not found in S3-compatible storage.",
"STORAGE_OBJECT_NOT_FOUND");
}
}
if (provider != ObjectStorageProviders.AliyunOss)
throw new ObjectStorageException(
$"{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<ObjectStorageWriteResult> 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)
{
await EnsureS3BucketAsync(request.Bucket, cancellationToken);
MemoryStream? bufferedContent = null;
var uploadContent = request.Content;
if (checksumSha256 is null && !request.Content.CanSeek)
{
bufferedContent = new MemoryStream();
await request.Content.CopyToAsync(bufferedContent, cancellationToken);
bufferedContent.Position = 0;
uploadContent = bufferedContent;
}
var resolvedChecksum = checksumSha256 ?? await ComputeSha256Async(uploadContent, cancellationToken);
var objectSize = fileSizeBytes ?? (uploadContent.CanSeek
? uploadContent.Length - uploadContent.Position
: -1);
try
{
await GetS3Client().PutObjectAsync(new PutObjectArgs()
.WithBucket(request.Bucket)
.WithObject(objectKey)
.WithStreamData(uploadContent)
.WithObjectSize(objectSize)
.WithContentType(mimeType), cancellationToken);
return new ObjectStorageWriteResult(
provider,
request.Bucket,
objectKey,
BuildPublicUrl(request.Bucket, objectKey),
fileSizeBytes ?? objectSize,
mimeType,
resolvedChecksum,
null,
new Dictionary<string, string>(),
"s3-compatible-put-object");
}
finally
{
if (bufferedContent is not null) await bufferedContent.DisposeAsync();
}
}
if (provider != ObjectStorageProviders.AliyunOss)
throw new ObjectStorageException(
$"{provider} does not support managed server-side writes.",
"WRITE_PROVIDER_NOT_SUPPORTED");
var metadata = new Dictionary<string, string>(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 async Task<Stream> OpenReadAsync(
ObjectStorageReadRequest request,
CancellationToken cancellationToken = default)
{
var provider = NormalizeProvider(request.Provider);
var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
if (provider == ObjectStorageProviders.LocalDev)
{
if (string.IsNullOrWhiteSpace(request.Bucket))
throw new ObjectStorageException("S3-compatible asset requires bucket.",
"ASSET_OBJECT_LOCATION_REQUIRED");
var content = new MemoryStream();
try
{
await GetS3Client().GetObjectAsync(new GetObjectArgs()
.WithBucket(request.Bucket)
.WithObject(objectKey)
.WithCallbackStream(async (stream, token) =>
await stream.CopyToAsync(content, token)), cancellationToken);
content.Position = 0;
return content;
}
catch (ObjectNotFoundException)
{
await content.DisposeAsync();
throw new ObjectStorageException("Object was not found in S3-compatible storage.",
"STORAGE_OBJECT_NOT_FOUND");
}
}
if (provider != ObjectStorageProviders.AliyunOss)
throw new ObjectStorageException(
$"{provider} does not support managed server-side reads.",
"READ_PROVIDER_NOT_SUPPORTED");
if (string.IsNullOrWhiteSpace(request.Bucket))
throw new ObjectStorageException("Aliyun OSS asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED");
var result = await GetClient().GetObjectAsync(
new GetObjectRequest { Bucket = request.Bucket, Key = objectKey },
HttpCompletionOption.ResponseHeadersRead,
null,
cancellationToken);
return result.Body ?? throw new ObjectStorageException(
"Object storage returned an empty response stream.",
"STORAGE_OBJECT_EMPTY_RESPONSE");
}
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 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 IMinioClient GetS3Client()
{
ObjectDisposedException.ThrowIf(disposed, this);
if (s3Client is not null) return s3Client;
lock (clientLock)
{
if (s3Client is not null) return s3Client;
var options = s3Options.Value;
if (!options.IsConfigured)
throw new ObjectStorageNotConfiguredException(
"S3-compatible storage is not configured: S3_ENDPOINT, S3_ACCESS_KEY and S3_SECRET_KEY are required.");
var builder = new MinioClient()
.WithEndpoint(options.Endpoint!.Trim())
.WithCredentials(options.AccessKey!.Trim(), options.SecretKey!.Trim())
.WithSSL(options.Secure);
if (!string.IsNullOrWhiteSpace(options.Region)) builder = builder.WithRegion(options.Region.Trim());
s3Client = builder.Build();
return s3Client;
}
}
private async Task EnsureS3BucketAsync(string bucket, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bucket);
var client = GetS3Client();
var exists = await client.BucketExistsAsync(new BucketExistsArgs().WithBucket(bucket), cancellationToken);
if (!exists)
await client.MakeBucketAsync(new MakeBucketArgs().WithBucket(bucket), cancellationToken);
}
private static int ExpirySeconds(TimeSpan expiresIn)
{
return Math.Clamp((int)Math.Ceiling(expiresIn.TotalSeconds), 1, 7 * 24 * 60 * 60);
}
private static Oss.Client CreateClient(AliyunOssOptions currentOptions)
{
if (!currentOptions.IsConfigured)
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,
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)
{
return 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)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim().Trim('"');
}
private static string? NullIfWhiteSpace(string? value)
{
return 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<string> 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();
}