628 lines
24 KiB
C#
628 lines
24 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 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.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 void Dispose()
|
|
{
|
|
if (disposed) return;
|
|
|
|
lock (clientLock)
|
|
{
|
|
client?.Dispose();
|
|
client = 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 == 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,
|
|
null,
|
|
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,
|
|
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,
|
|
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 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)
|
|
return new ObjectStorageWriteResult(
|
|
provider,
|
|
request.Bucket,
|
|
objectKey,
|
|
BuildPublicUrl(request.Bucket, objectKey),
|
|
fileSizeBytes,
|
|
mimeType,
|
|
checksumSha256 ?? await ComputeSha256Async(request.Content, cancellationToken),
|
|
null,
|
|
new Dictionary<string, string>(),
|
|
"local-dev-write-placeholder");
|
|
|
|
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.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 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();
|
|
} |