forked from xiongyuxing/tiku-backend.net
feat: add asset access signing endpoints
This commit is contained in:
329
Tiku.Infrastructure/Assets/AssetAccessService.cs
Normal file
329
Tiku.Infrastructure/Assets/AssetAccessService.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
public sealed class AssetAccessService(
|
||||
TikuDbContext dbContext,
|
||||
IObjectStorageService objectStorageService) : IAssetAccessService
|
||||
{
|
||||
private static readonly TimeSpan DefaultDownloadTtl = TimeSpan.FromMinutes(15);
|
||||
private static readonly TimeSpan DefaultPreviewTtl = TimeSpan.FromMinutes(10);
|
||||
|
||||
public Task<AssetAccessResultModel> DownloadAsync(
|
||||
AssetAccessRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return AccessAsync(request, AssetAccessType.Download, AssetAccessDisposition.Attachment, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<AssetAccessResultModel> PreviewAsync(
|
||||
AssetAccessRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return AccessAsync(request, AssetAccessType.Preview, AssetAccessDisposition.Inline, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AssetAccessResultModel> AccessAsync(
|
||||
AssetAccessRequest request,
|
||||
AssetAccessType accessType,
|
||||
AssetAccessDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var asset = await dbContext.ContentAssets
|
||||
.SingleOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == request.TenantId &&
|
||||
item.Id == request.AssetId &&
|
||||
item.Status == ContentStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetAccessException("Asset was not found.", "ASSET_NOT_FOUND");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var access = await ResolveAccessAsync(request, asset, cancellationToken);
|
||||
AssertPublishedAsset(asset);
|
||||
if (accessType == AssetAccessType.Preview)
|
||||
{
|
||||
AssertPreviewable(asset);
|
||||
}
|
||||
|
||||
var ttl = ResolveTtl(request, accessType, asset.Visibility);
|
||||
var signedUrl = await objectStorageService.SignDownloadAsync(
|
||||
new ObjectStorageDownloadSignRequest(
|
||||
request.TenantId,
|
||||
ToStorageProvider(asset.StorageProvider),
|
||||
asset.Bucket,
|
||||
accessType == AssetAccessType.Preview
|
||||
? asset.PreviewObjectKey ?? asset.ObjectKey
|
||||
: asset.ObjectKey,
|
||||
ttl,
|
||||
accessType == AssetAccessType.Preview
|
||||
? asset.PreviewUrl ?? asset.CdnUrl
|
||||
: asset.CdnUrl,
|
||||
asset.FileName ?? asset.Title,
|
||||
disposition == AssetAccessDisposition.Inline ? "inline" : "attachment"),
|
||||
cancellationToken);
|
||||
|
||||
if (accessType == AssetAccessType.Download)
|
||||
{
|
||||
asset.DownloadCount++;
|
||||
}
|
||||
|
||||
dbContext.ContentAssetAccessEvents.Add(CreateAccessEvent(
|
||||
request,
|
||||
asset,
|
||||
accessType,
|
||||
disposition,
|
||||
AssetAccessResult.Granted,
|
||||
signedUrl,
|
||||
null,
|
||||
access));
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new AssetAccessResultModel(
|
||||
ToSummary(asset),
|
||||
access,
|
||||
signedUrl,
|
||||
CreateWatermark(request, asset, accessType, signedUrl));
|
||||
}
|
||||
catch (Exception exception) when (ShouldAuditDenied(exception))
|
||||
{
|
||||
dbContext.ContentAssetAccessEvents.Add(CreateAccessEvent(
|
||||
request,
|
||||
asset,
|
||||
accessType,
|
||||
disposition,
|
||||
AssetAccessResult.Denied,
|
||||
null,
|
||||
DenyCode(exception),
|
||||
null));
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AssetAccessPrincipal> ResolveAccessAsync(
|
||||
AssetAccessRequest request,
|
||||
ContentAsset asset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (asset.Visibility == ContentVisibility.Public || asset.IsPublic)
|
||||
{
|
||||
return new AssetAccessPrincipal(request.UserId, IsMember: false, HasSvip: false);
|
||||
}
|
||||
|
||||
if (asset.Visibility == ContentVisibility.Hidden)
|
||||
{
|
||||
throw new AssetAccessException("Asset is hidden.", "ASSET_HIDDEN");
|
||||
}
|
||||
|
||||
if (!request.UserId.HasValue)
|
||||
{
|
||||
throw new AssetAccessException("Authentication is required.", "AUTH_REQUIRED");
|
||||
}
|
||||
|
||||
var isMember = await dbContext.TenantMemberships
|
||||
.AnyAsync(
|
||||
membership =>
|
||||
membership.TenantId == request.TenantId &&
|
||||
membership.UserId == request.UserId.Value &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
if (!isMember)
|
||||
{
|
||||
throw new AssetAccessException("Tenant membership is required for this asset.", "ASSET_MEMBERSHIP_REQUIRED");
|
||||
}
|
||||
|
||||
if (asset.Visibility == ContentVisibility.Members)
|
||||
{
|
||||
return new AssetAccessPrincipal(request.UserId, IsMember: true, HasSvip: false);
|
||||
}
|
||||
|
||||
var hasSvip = await HasSvipAccessAsync(request, asset, cancellationToken);
|
||||
if (!hasSvip)
|
||||
{
|
||||
throw new AssetAccessException("SVIP entitlement is required for this asset.", "ASSET_SVIP_REQUIRED");
|
||||
}
|
||||
|
||||
return new AssetAccessPrincipal(request.UserId, IsMember: true, HasSvip: true);
|
||||
}
|
||||
|
||||
private Task<bool> HasSvipAccessAsync(
|
||||
AssetAccessRequest request,
|
||||
ContentAsset asset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return dbContext.Entitlements.AnyAsync(
|
||||
entitlement =>
|
||||
entitlement.TenantId == request.TenantId &&
|
||||
entitlement.UserId == request.UserId!.Value &&
|
||||
entitlement.EntitlementType == "svip" &&
|
||||
entitlement.Status == EntitlementStatus.Active &&
|
||||
entitlement.StartsAt <= now &&
|
||||
(entitlement.ExpiresAt == null || entitlement.ExpiresAt > now) &&
|
||||
(entitlement.ScopeType == EntitlementScopeType.Tenant ||
|
||||
(asset.RegionId != null &&
|
||||
entitlement.ScopeType == EntitlementScopeType.Region &&
|
||||
entitlement.ScopeId == asset.RegionId) ||
|
||||
(asset.SubjectId != null &&
|
||||
entitlement.ScopeType == EntitlementScopeType.Subject &&
|
||||
entitlement.ScopeId == asset.SubjectId)),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static void AssertPublishedAsset(ContentAsset asset)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(asset.ObjectKey) && asset.UploadStatus != AssetUploadStatus.Verified)
|
||||
{
|
||||
throw new AssetAccessException("Asset upload has not been verified.", "ASSET_UPLOAD_NOT_VERIFIED");
|
||||
}
|
||||
|
||||
if (asset.SecurityScanStatus is AssetSecurityScanStatus.Failed or AssetSecurityScanStatus.Scanning)
|
||||
{
|
||||
throw new AssetAccessException("Asset security scan has not passed.", "ASSET_SECURITY_SCAN_NOT_PASSED");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertPreviewable(ContentAsset asset)
|
||||
{
|
||||
var mimeType = asset.MimeType?.Split(';', StringSplitOptions.TrimEntries)[0].ToLowerInvariant() ?? string.Empty;
|
||||
var previewable = asset.AssetType is ContentAssetType.Pdf or ContentAssetType.Image ||
|
||||
mimeType == "application/pdf" ||
|
||||
mimeType.StartsWith("image/", StringComparison.Ordinal);
|
||||
if (!previewable)
|
||||
{
|
||||
throw new AssetAccessException("Asset type does not support inline preview.", "ASSET_PREVIEW_NOT_SUPPORTED");
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeSpan ResolveTtl(
|
||||
AssetAccessRequest request,
|
||||
AssetAccessType accessType,
|
||||
ContentVisibility visibility)
|
||||
{
|
||||
var fallback = accessType == AssetAccessType.Preview ? DefaultPreviewTtl : DefaultDownloadTtl;
|
||||
var requested = request.RequestedExpiresIn ?? fallback;
|
||||
var max = visibility == ContentVisibility.Public ? TimeSpan.FromHours(2) : TimeSpan.FromMinutes(15);
|
||||
if (requested <= TimeSpan.Zero)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return requested <= max ? requested : max;
|
||||
}
|
||||
|
||||
private static ContentAssetAccessEvent CreateAccessEvent(
|
||||
AssetAccessRequest request,
|
||||
ContentAsset asset,
|
||||
AssetAccessType accessType,
|
||||
AssetAccessDisposition disposition,
|
||||
AssetAccessResult result,
|
||||
ObjectStorageSignedUrl? signedUrl,
|
||||
string? denyCode,
|
||||
AssetAccessPrincipal? access)
|
||||
{
|
||||
return new ContentAssetAccessEvent
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
AssetId = asset.Id,
|
||||
UserId = request.UserId,
|
||||
ActorRole = ResolveActorRole(access),
|
||||
AccessType = accessType,
|
||||
Visibility = asset.Visibility.ToString(),
|
||||
AssetType = asset.AssetType.ToString(),
|
||||
StorageProvider = asset.StorageProvider.ToString(),
|
||||
Disposition = disposition,
|
||||
ExpiresInSeconds = signedUrl is null ? null : (int)signedUrl.ExpiresIn.TotalSeconds,
|
||||
SignatureMode = signedUrl?.SignatureMode,
|
||||
Result = result,
|
||||
DenyCode = denyCode,
|
||||
IpAddress = request.IpAddress,
|
||||
UserAgent = request.UserAgent,
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
asset.Title,
|
||||
asset.FileName,
|
||||
signedUrl?.Method
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static AssetAccessActorRole ResolveActorRole(AssetAccessPrincipal? access)
|
||||
{
|
||||
return access?.UserId.HasValue == true ? AssetAccessActorRole.Student : AssetAccessActorRole.Anonymous;
|
||||
}
|
||||
|
||||
private static ContentAssetAccessSummary ToSummary(ContentAsset asset)
|
||||
{
|
||||
return new ContentAssetAccessSummary(
|
||||
asset.Id,
|
||||
asset.AssetType,
|
||||
asset.Title,
|
||||
asset.FileName,
|
||||
asset.PreviewUrl,
|
||||
asset.UploadStatus,
|
||||
asset.SecurityScanStatus,
|
||||
asset.PreviewStatus,
|
||||
asset.Visibility);
|
||||
}
|
||||
|
||||
private static JsonElement CreateWatermark(
|
||||
AssetAccessRequest request,
|
||||
ContentAsset asset,
|
||||
AssetAccessType accessType,
|
||||
ObjectStorageSignedUrl signedUrl)
|
||||
{
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
request.TenantId,
|
||||
assetId = asset.Id,
|
||||
request.UserId,
|
||||
accessType = accessType.ToString(),
|
||||
signedUrl.ExpiresAt,
|
||||
asset.Title,
|
||||
asset.FileName
|
||||
});
|
||||
}
|
||||
|
||||
private static bool ShouldAuditDenied(Exception exception)
|
||||
{
|
||||
return exception is AssetAccessException or ObjectStorageException or ObjectStorageNotConfiguredException;
|
||||
}
|
||||
|
||||
private static string DenyCode(Exception exception)
|
||||
{
|
||||
return exception switch
|
||||
{
|
||||
AssetAccessException accessException => accessException.Code,
|
||||
ObjectStorageException storageException => storageException.Code,
|
||||
_ => "ASSET_ACCESS_DENIED"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToStorageProvider(AssetStorageProvider provider)
|
||||
{
|
||||
return provider switch
|
||||
{
|
||||
AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl,
|
||||
AssetStorageProvider.SupabaseStorage => ObjectStorageProviders.SupabaseStorage,
|
||||
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
|
||||
AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos,
|
||||
AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo,
|
||||
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
|
||||
_ => ObjectStorageProviders.ExternalUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
||||
services.AddScoped<IAssetAccessService, AssetAccessService>();
|
||||
services.AddOptions<AliyunOssOptions>()
|
||||
.Validate(
|
||||
AliyunOssOptions.BeValid,
|
||||
|
||||
Reference in New Issue
Block a user