forked from xiongyuxing/tiku-backend.net
feat: add aliyun oss storage foundation
This commit is contained in:
@@ -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<IContentNavigationQueryService, ContentNavigationQueryService>();
|
||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||
services.AddOptions<AliyunOssOptions>()
|
||||
.Validate(
|
||||
AliyunOssOptions.BeValid,
|
||||
"Aliyun OSS presign default expiration must be positive.");
|
||||
services.AddOptions<ObjectStorageOptions>()
|
||||
.Validate(
|
||||
ObjectStorageOptions.BeValid,
|
||||
"Storage options must include a default provider, default bucket and positive max upload bytes.");
|
||||
services.AddSingleton<IObjectStorageService, AliyunOssObjectStorageService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
564
Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs
Normal file
564
Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs
Normal file
@@ -0,0 +1,564 @@
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using AlibabaCloud.OSS.V2.Credentials;
|
||||
using AlibabaCloud.OSS.V2.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Storage;
|
||||
using Oss = AlibabaCloud.OSS.V2;
|
||||
|
||||
namespace Tiku.Infrastructure.Storage;
|
||||
|
||||
public sealed partial class AliyunOssObjectStorageService(
|
||||
IOptions<ObjectStorageOptions> storageOptions,
|
||||
IOptions<AliyunOssOptions> aliyunOptions)
|
||||
: IObjectStorageService, IDisposable
|
||||
{
|
||||
private static readonly HashSet<string> SupportedProviders =
|
||||
[
|
||||
ObjectStorageProviders.ExternalUrl,
|
||||
ObjectStorageProviders.SupabaseStorage,
|
||||
ObjectStorageProviders.AliyunOss,
|
||||
ObjectStorageProviders.TencentCos,
|
||||
ObjectStorageProviders.QiniuKodo,
|
||||
ObjectStorageProviders.LocalDev
|
||||
];
|
||||
|
||||
private static readonly HashSet<string> UploadableProviders =
|
||||
[
|
||||
ObjectStorageProviders.LocalDev,
|
||||
ObjectStorageProviders.AliyunOss
|
||||
];
|
||||
|
||||
private readonly object clientLock = new();
|
||||
private Oss.Client? client;
|
||||
private bool disposed;
|
||||
|
||||
public string ConfiguredDefaultProvider() =>
|
||||
NormalizeProvider(storageOptions.Value.DefaultProvider, ObjectStorageProviders.LocalDev);
|
||||
|
||||
public string ConfiguredDefaultBucket() =>
|
||||
string.IsNullOrWhiteSpace(storageOptions.Value.DefaultBucket)
|
||||
? "tenant-assets"
|
||||
: storageOptions.Value.DefaultBucket.Trim();
|
||||
|
||||
public string NormalizeProvider(string? value, string? fallback = null)
|
||||
{
|
||||
var provider = (string.IsNullOrWhiteSpace(value) ? fallback : value)?.Trim() ?? ConfiguredDefaultProvider();
|
||||
if (!SupportedProviders.Contains(provider))
|
||||
{
|
||||
throw new ObjectStorageException($"Unsupported storage provider: {provider}", "UNSUPPORTED_STORAGE_PROVIDER");
|
||||
}
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
public string ValidateObjectKey(Guid tenantId, string objectKey)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(objectKey);
|
||||
|
||||
var clean = CanonicalObjectKey(objectKey);
|
||||
if (string.IsNullOrWhiteSpace(clean) ||
|
||||
clean.Contains("..", StringComparison.Ordinal) ||
|
||||
clean.Contains('\\', StringComparison.Ordinal) ||
|
||||
clean.Contains("%2f", StringComparison.OrdinalIgnoreCase) ||
|
||||
!SafeObjectKeyRegex().IsMatch(clean))
|
||||
{
|
||||
throw new ObjectStorageException("Invalid objectKey.", "INVALID_OBJECT_KEY");
|
||||
}
|
||||
|
||||
if (storageOptions.Value.RequireTenantPrefix && !clean.StartsWith($"{tenantId:N}/", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ObjectStorageException(
|
||||
"objectKey must be scoped by tenantId prefix.",
|
||||
"OBJECT_KEY_TENANT_PREFIX_REQUIRED");
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
public string ValidateMimeType(string mimeType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(mimeType);
|
||||
|
||||
var normalized = mimeType.Trim().ToLowerInvariant();
|
||||
if (normalized.Length > 160 ||
|
||||
normalized.Contains('\r', StringComparison.Ordinal) ||
|
||||
normalized.Contains('\n', StringComparison.Ordinal))
|
||||
{
|
||||
throw new ObjectStorageException("Invalid mimeType.", "INVALID_MIME_TYPE");
|
||||
}
|
||||
|
||||
var exact = storageOptions.Value.AllowedMimeTypes
|
||||
.Select(item => item.Trim().ToLowerInvariant())
|
||||
.Where(item => item.Length > 0)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var prefixes = storageOptions.Value.AllowedMimePrefixes
|
||||
.Select(item => item.Trim().ToLowerInvariant())
|
||||
.Where(item => item.Length > 0)
|
||||
.ToArray();
|
||||
|
||||
if (!exact.Contains(normalized) &&
|
||||
!prefixes.Any(prefix => normalized.StartsWith(prefix, StringComparison.Ordinal)))
|
||||
{
|
||||
throw new ObjectStorageException($"mimeType is not allowed: {normalized}", "MIME_TYPE_NOT_ALLOWED");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public long? ValidateFileSize(long? fileSizeBytes)
|
||||
{
|
||||
if (fileSizeBytes is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fileSizeBytes < 0)
|
||||
{
|
||||
throw new ObjectStorageException("fileSizeBytes must be non-negative.", "INVALID_FILE_SIZE");
|
||||
}
|
||||
|
||||
if (fileSizeBytes > storageOptions.Value.MaxUploadBytes)
|
||||
{
|
||||
throw new ObjectStorageException("file exceeds configured storage max upload bytes.", "FILE_TOO_LARGE");
|
||||
}
|
||||
|
||||
return fileSizeBytes;
|
||||
}
|
||||
|
||||
public void AssertUploadProvider(string provider)
|
||||
{
|
||||
var normalized = NormalizeProvider(provider);
|
||||
if (!UploadableProviders.Contains(normalized))
|
||||
{
|
||||
throw new ObjectStorageException(
|
||||
$"{normalized} does not support managed upload signing.",
|
||||
"UPLOAD_PROVIDER_NOT_SUPPORTED");
|
||||
}
|
||||
}
|
||||
|
||||
public void AssertWritableLocation(StorageAssetLocation location)
|
||||
{
|
||||
var provider = NormalizeProvider(location.Provider);
|
||||
if (provider == ObjectStorageProviders.AliyunOss &&
|
||||
(string.IsNullOrWhiteSpace(location.Bucket) || string.IsNullOrWhiteSpace(location.ObjectKey)))
|
||||
{
|
||||
throw new ObjectStorageException(
|
||||
$"{provider} asset requires bucket and objectKey.",
|
||||
"ASSET_OBJECT_LOCATION_REQUIRED");
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ObjectStorageSignedUrl> SignUploadAsync(
|
||||
ObjectStorageUploadSignRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var provider = NormalizeProvider(request.Provider);
|
||||
AssertUploadProvider(provider);
|
||||
var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
|
||||
var mimeType = ValidateMimeType(request.MimeType);
|
||||
ValidateFileSize(request.FileSizeBytes);
|
||||
|
||||
return provider == ObjectStorageProviders.LocalDev
|
||||
? Task.FromResult(LocalSignedUrl(
|
||||
provider,
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
"PUT",
|
||||
request.ExpiresIn,
|
||||
"attachment",
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["content-type"] = mimeType
|
||||
}))
|
||||
: Task.FromResult(SignAliyunOss(
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
"PUT",
|
||||
request.ExpiresIn,
|
||||
mimeType: mimeType,
|
||||
fileName: null,
|
||||
disposition: null));
|
||||
}
|
||||
|
||||
public Task<ObjectStorageSignedUrl> SignDownloadAsync(
|
||||
ObjectStorageDownloadSignRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var provider = NormalizeProvider(request.Provider);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.CdnUrl))
|
||||
{
|
||||
return Task.FromResult(new ObjectStorageSignedUrl(
|
||||
provider,
|
||||
request.Bucket,
|
||||
request.ObjectKey,
|
||||
"GET",
|
||||
new Uri(request.CdnUrl, UriKind.Absolute),
|
||||
new Dictionary<string, string>(),
|
||||
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
|
||||
request.ExpiresIn,
|
||||
"public-or-provider-managed"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ObjectKey))
|
||||
{
|
||||
throw new ObjectStorageException("Asset requires objectKey or cdnUrl.", "ASSET_LOCATION_REQUIRED");
|
||||
}
|
||||
|
||||
var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
|
||||
if (provider is ObjectStorageProviders.LocalDev or ObjectStorageProviders.QiniuKodo)
|
||||
{
|
||||
return Task.FromResult(LocalSignedUrl(
|
||||
provider,
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
"GET",
|
||||
request.ExpiresIn,
|
||||
request.Disposition));
|
||||
}
|
||||
|
||||
if (provider != ObjectStorageProviders.AliyunOss)
|
||||
{
|
||||
throw new ObjectStorageException($"{provider} asset requires cdnUrl.", "ASSET_LOCATION_REQUIRED");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Bucket))
|
||||
{
|
||||
throw new ObjectStorageException("Aliyun OSS asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED");
|
||||
}
|
||||
|
||||
return Task.FromResult(SignAliyunOss(
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
"GET",
|
||||
request.ExpiresIn,
|
||||
mimeType: null,
|
||||
request.FileName,
|
||||
request.Disposition));
|
||||
}
|
||||
|
||||
public async Task<ObjectStorageMetadata> HeadObjectAsync(
|
||||
ObjectStorageHeadRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var provider = NormalizeProvider(request.Provider);
|
||||
if (string.IsNullOrWhiteSpace(request.ObjectKey))
|
||||
{
|
||||
throw new ObjectStorageException("Asset requires objectKey.", "ASSET_LOCATION_REQUIRED");
|
||||
}
|
||||
|
||||
var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
|
||||
var fileSizeBytes = ValidateFileSize(request.DeclaredFileSizeBytes);
|
||||
var mimeType = request.DeclaredMimeType is null ? null : ValidateMimeType(request.DeclaredMimeType);
|
||||
var checksumSha256 = request.DeclaredChecksumSha256?.Trim().ToLowerInvariant();
|
||||
|
||||
if (provider == ObjectStorageProviders.LocalDev)
|
||||
{
|
||||
return new ObjectStorageMetadata(
|
||||
provider,
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
Exists: true,
|
||||
fileSizeBytes,
|
||||
mimeType,
|
||||
checksumSha256,
|
||||
checksumSha256,
|
||||
DateTimeOffset.UtcNow.ToString("O"),
|
||||
new Dictionary<string, string>(),
|
||||
"local-dev-declared-metadata");
|
||||
}
|
||||
|
||||
if (provider != ObjectStorageProviders.AliyunOss)
|
||||
{
|
||||
throw new ObjectStorageException(
|
||||
$"{provider} does not support upload confirmation.",
|
||||
"UPLOAD_CONFIRM_PROVIDER_NOT_SUPPORTED");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Bucket))
|
||||
{
|
||||
throw new ObjectStorageException("Aliyun OSS asset requires bucket and objectKey.", "ASSET_OBJECT_LOCATION_REQUIRED");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await GetClient().HeadObjectAsync(new HeadObjectRequest
|
||||
{
|
||||
Bucket = request.Bucket,
|
||||
Key = objectKey
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
return MetadataFromHeadResult(request.Bucket, objectKey, result);
|
||||
}
|
||||
catch (Exception exception) when (TryGetStatusCode(exception) == 404)
|
||||
{
|
||||
throw new ObjectStorageException(
|
||||
"Uploaded object was not found in Aliyun OSS.",
|
||||
"STORAGE_OBJECT_NOT_FOUND");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (clientLock)
|
||||
{
|
||||
client?.Dispose();
|
||||
client = null;
|
||||
disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectStorageSignedUrl SignAliyunOss(
|
||||
string bucket,
|
||||
string objectKey,
|
||||
string method,
|
||||
TimeSpan expiresIn,
|
||||
string? mimeType,
|
||||
string? fileName,
|
||||
string? disposition)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bucket);
|
||||
var expiresAt = ResolveExpiration(expiresIn);
|
||||
|
||||
PresignResult result;
|
||||
if (method == "PUT")
|
||||
{
|
||||
result = GetClient().Presign(new PutObjectRequest
|
||||
{
|
||||
Bucket = bucket,
|
||||
Key = objectKey,
|
||||
ContentType = mimeType
|
||||
}, expiresAt.UtcDateTime);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = GetClient().Presign(new GetObjectRequest
|
||||
{
|
||||
Bucket = bucket,
|
||||
Key = objectKey,
|
||||
ResponseContentDisposition = BuildContentDisposition(fileName, disposition)
|
||||
}, expiresAt.UtcDateTime);
|
||||
}
|
||||
|
||||
return ToSignedUrl(result, ObjectStorageProviders.AliyunOss, bucket, objectKey, method, expiresIn, "aliyun-oss-signature-url-v4");
|
||||
}
|
||||
|
||||
private ObjectStorageSignedUrl LocalSignedUrl(
|
||||
string provider,
|
||||
string? bucket,
|
||||
string? objectKey,
|
||||
string method,
|
||||
TimeSpan expiresIn,
|
||||
string? disposition,
|
||||
IReadOnlyDictionary<string, string>? headers = null)
|
||||
{
|
||||
var expiresAt = ResolveExpiration(expiresIn);
|
||||
var baseUrl = storageOptions.Value.PublicBaseUrl?.Trim().TrimEnd('/');
|
||||
var path = $"{Uri.EscapeDataString(bucket ?? "default")}/{Uri.EscapeDataString(objectKey ?? "missing")}";
|
||||
var url = string.IsNullOrWhiteSpace(baseUrl)
|
||||
? $"https://local-storage.invalid/{provider}/{path}?expiresAt={Uri.EscapeDataString(expiresAt.ToString("O"))}&disposition={disposition ?? "attachment"}"
|
||||
: $"{baseUrl}/{path}?expiresAt={Uri.EscapeDataString(expiresAt.ToString("O"))}&disposition={disposition ?? "attachment"}";
|
||||
|
||||
return new ObjectStorageSignedUrl(
|
||||
provider,
|
||||
bucket,
|
||||
objectKey,
|
||||
method,
|
||||
new Uri(url),
|
||||
headers ?? new Dictionary<string, string>(),
|
||||
expiresAt,
|
||||
expiresIn,
|
||||
"local-placeholder");
|
||||
}
|
||||
|
||||
private Oss.Client GetClient()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
if (client is not null)
|
||||
{
|
||||
return client;
|
||||
}
|
||||
|
||||
lock (clientLock)
|
||||
{
|
||||
client ??= CreateClient(aliyunOptions.Value);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
private static Oss.Client CreateClient(AliyunOssOptions currentOptions)
|
||||
{
|
||||
if (!currentOptions.IsConfigured)
|
||||
{
|
||||
throw new ObjectStorageNotConfiguredException(
|
||||
"aliyun_oss is not configured: ALIYUN_OSS_ACCESS_KEY_ID, ALIYUN_OSS_ACCESS_KEY_SECRET and ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT are required.");
|
||||
}
|
||||
|
||||
var configuration = Oss.Configuration.LoadDefault();
|
||||
configuration.Region = currentOptions.Region?.Trim();
|
||||
configuration.Endpoint = NullIfWhiteSpace(currentOptions.Endpoint);
|
||||
configuration.UseInternalEndpoint = currentOptions.UseInternalEndpoint;
|
||||
configuration.UsePathStyle = currentOptions.UsePathStyle;
|
||||
configuration.UseCName = currentOptions.UseCName;
|
||||
configuration.DisableSsl = false;
|
||||
configuration.CredentialsProvider = string.IsNullOrWhiteSpace(currentOptions.SecurityToken)
|
||||
? new StaticCredentialsProvider(
|
||||
currentOptions.AccessKeyId!.Trim(),
|
||||
currentOptions.AccessKeySecret!.Trim())
|
||||
: new StaticCredentialsProvider(
|
||||
currentOptions.AccessKeyId!.Trim(),
|
||||
currentOptions.AccessKeySecret!.Trim(),
|
||||
currentOptions.SecurityToken!.Trim());
|
||||
|
||||
return new Oss.Client(configuration);
|
||||
}
|
||||
|
||||
private static ObjectStorageSignedUrl ToSignedUrl(
|
||||
PresignResult result,
|
||||
string provider,
|
||||
string bucket,
|
||||
string objectKey,
|
||||
string method,
|
||||
TimeSpan expiresIn,
|
||||
string signatureMode)
|
||||
{
|
||||
if (!Uri.TryCreate(result.Url, UriKind.Absolute, out var url))
|
||||
{
|
||||
throw new InvalidOperationException("Aliyun OSS SDK returned an invalid presigned URL.");
|
||||
}
|
||||
|
||||
return new ObjectStorageSignedUrl(
|
||||
provider,
|
||||
bucket,
|
||||
objectKey,
|
||||
string.IsNullOrWhiteSpace(result.Method) ? method : result.Method,
|
||||
url,
|
||||
result.SignedHeaders is null
|
||||
? new Dictionary<string, string>()
|
||||
: new Dictionary<string, string>(result.SignedHeaders, StringComparer.OrdinalIgnoreCase),
|
||||
result.Expiration.HasValue
|
||||
? new DateTimeOffset(DateTime.SpecifyKind(result.Expiration.Value, DateTimeKind.Utc))
|
||||
: DateTimeOffset.UtcNow.Add(expiresIn),
|
||||
expiresIn,
|
||||
signatureMode);
|
||||
}
|
||||
|
||||
private static ObjectStorageMetadata MetadataFromHeadResult(
|
||||
string bucket,
|
||||
string objectKey,
|
||||
HeadObjectResult result)
|
||||
{
|
||||
var headers = NormalizeHeaders(result.Headers);
|
||||
foreach (var metadata in result.Metadata ?? new Dictionary<string, string>())
|
||||
{
|
||||
headers[$"x-oss-meta-{metadata.Key}".ToLowerInvariant()] = metadata.Value;
|
||||
}
|
||||
|
||||
return new ObjectStorageMetadata(
|
||||
ObjectStorageProviders.AliyunOss,
|
||||
bucket,
|
||||
objectKey,
|
||||
Exists: true,
|
||||
result.ContentLength,
|
||||
NullIfWhiteSpace(result.ContentType),
|
||||
FirstHeader(headers, "x-oss-meta-sha256", "x-oss-meta-checksum-sha256")?.Trim().ToLowerInvariant(),
|
||||
CleanEtag(result.ETag ?? FirstHeader(headers, "etag")),
|
||||
NullIfWhiteSpace(result.LastModified) ?? FirstHeader(headers, "last-modified"),
|
||||
headers,
|
||||
"aliyun-oss-head-object");
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> NormalizeHeaders(IDictionary<string, string>? headers)
|
||||
{
|
||||
var normalized = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (headers is null)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in headers)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key) && value is not null)
|
||||
{
|
||||
normalized[key.ToLowerInvariant()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static int? TryGetStatusCode(Exception exception)
|
||||
{
|
||||
var type = exception.GetType();
|
||||
var property = type.GetProperty("StatusCode", BindingFlags.Public | BindingFlags.Instance) ??
|
||||
type.GetProperty("Status", BindingFlags.Public | BindingFlags.Instance);
|
||||
return property?.GetValue(exception) switch
|
||||
{
|
||||
int statusCode => statusCode,
|
||||
long statusCode => checked((int)statusCode),
|
||||
string value when int.TryParse(value, out var statusCode) => statusCode,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset ResolveExpiration(TimeSpan expiresIn)
|
||||
{
|
||||
if (expiresIn <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(expiresIn), expiresIn, "Signed URL expiration must be positive.");
|
||||
}
|
||||
|
||||
return DateTimeOffset.UtcNow.Add(expiresIn);
|
||||
}
|
||||
|
||||
private static string CanonicalObjectKey(string objectKey) =>
|
||||
ConsecutiveSlashRegex().Replace(objectKey.Trim().TrimStart('/'), "/");
|
||||
|
||||
private static string? BuildContentDisposition(string? fileName, string? disposition)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedDisposition = string.Equals(disposition, "inline", StringComparison.OrdinalIgnoreCase)
|
||||
? "inline"
|
||||
: "attachment";
|
||||
return $"{normalizedDisposition}; filename=\"{Uri.EscapeDataString(fileName.Trim())}\"";
|
||||
}
|
||||
|
||||
private static string? FirstHeader(IReadOnlyDictionary<string, string> headers, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (headers.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? CleanEtag(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim().Trim('"');
|
||||
|
||||
private static string? NullIfWhiteSpace(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
[GeneratedRegex("""^[A-Za-z0-9][A-Za-z0-9._~!$&'()+,;=@/-]{0,1023}$""")]
|
||||
private static partial Regex SafeObjectKeyRegex();
|
||||
|
||||
[GeneratedRegex("/{2,}")]
|
||||
private static partial Regex ConsecutiveSlashRegex();
|
||||
}
|
||||
26
Tiku.Infrastructure/Storage/AliyunOssOptions.cs
Normal file
26
Tiku.Infrastructure/Storage/AliyunOssOptions.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace Tiku.Infrastructure.Storage;
|
||||
|
||||
public sealed class AliyunOssOptions
|
||||
{
|
||||
public const string SectionName = "Storage:AliyunOss";
|
||||
|
||||
public string? Region { get; set; }
|
||||
public string? Endpoint { get; set; }
|
||||
public string? AccessKeyId { get; set; }
|
||||
public string? AccessKeySecret { get; set; }
|
||||
public string? SecurityToken { get; set; }
|
||||
public bool UseInternalEndpoint { get; set; }
|
||||
public bool UsePathStyle { get; set; }
|
||||
public bool UseCName { get; set; }
|
||||
public int PresignDefaultMinutes { get; set; } = 15;
|
||||
|
||||
public bool IsConfigured =>
|
||||
(!string.IsNullOrWhiteSpace(Region) || !string.IsNullOrWhiteSpace(Endpoint)) &&
|
||||
!string.IsNullOrWhiteSpace(AccessKeyId) &&
|
||||
!string.IsNullOrWhiteSpace(AccessKeySecret);
|
||||
|
||||
public static bool BeValid(AliyunOssOptions options)
|
||||
{
|
||||
return options.PresignDefaultMinutes > 0;
|
||||
}
|
||||
}
|
||||
28
Tiku.Infrastructure/Storage/ObjectStorageOptions.cs
Normal file
28
Tiku.Infrastructure/Storage/ObjectStorageOptions.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace Tiku.Infrastructure.Storage;
|
||||
|
||||
public sealed class ObjectStorageOptions
|
||||
{
|
||||
public const string SectionName = "Storage";
|
||||
|
||||
public string DefaultProvider { get; set; } = "local_dev";
|
||||
public string DefaultBucket { get; set; } = "tenant-assets";
|
||||
public string? PublicBaseUrl { get; set; }
|
||||
public long MaxUploadBytes { get; set; } = 1024L * 1024 * 500;
|
||||
public string[] AllowedMimePrefixes { get; set; } = ["image/", "video/", "audio/"];
|
||||
public string[] AllowedMimeTypes { get; set; } =
|
||||
[
|
||||
"application/pdf",
|
||||
"application/json",
|
||||
"text/csv",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
];
|
||||
|
||||
public bool RequireTenantPrefix { get; set; } = true;
|
||||
|
||||
public static bool BeValid(ObjectStorageOptions options) =>
|
||||
options.MaxUploadBytes > 0 &&
|
||||
!string.IsNullOrWhiteSpace(options.DefaultProvider) &&
|
||||
!string.IsNullOrWhiteSpace(options.DefaultBucket);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AlibabaCloud.OSS.V2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
|
||||
Reference in New Issue
Block a user