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( IContentAssetPersistence contentAssetPersistence, ICommercePersistence commercePersistence, IIdentityPersistence identityPersistence, IObjectStorageService objectStorageService) : IAssetAccessService { private static readonly TimeSpan DefaultDownloadTtl = TimeSpan.FromMinutes(15); private static readonly TimeSpan DefaultPreviewTtl = TimeSpan.FromMinutes(10); public Task DownloadAsync( AssetAccessRequest request, CancellationToken cancellationToken = default) { return AccessAsync(request, AssetAccessType.Download, AssetAccessDisposition.Attachment, cancellationToken); } public Task PreviewAsync( AssetAccessRequest request, CancellationToken cancellationToken = default) { return AccessAsync(request, AssetAccessType.Preview, AssetAccessDisposition.Inline, cancellationToken); } private async Task AccessAsync( AssetAccessRequest request, AssetAccessType accessType, AssetAccessDisposition disposition, CancellationToken cancellationToken) { var asset = await contentAssetPersistence.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++; contentAssetPersistence.ContentAssetAccessEvents.Add(CreateAccessEvent( request, asset, accessType, disposition, AssetAccessResult.Granted, signedUrl, null, access)); await contentAssetPersistence.SaveChangesAsync(cancellationToken); return new AssetAccessResultModel( ToSummary(asset), access, signedUrl, CreateWatermark(request, asset, accessType, signedUrl)); } catch (Exception exception) when (ShouldAuditDenied(exception)) { contentAssetPersistence.ContentAssetAccessEvents.Add(CreateAccessEvent( request, asset, accessType, disposition, AssetAccessResult.Denied, null, DenyCode(exception), null)); await contentAssetPersistence.SaveChangesAsync(cancellationToken); throw; } } private async Task ResolveAccessAsync( AssetAccessRequest request, ContentAsset asset, CancellationToken cancellationToken) { if (asset.Visibility == ContentVisibility.Public || asset.IsPublic) return new AssetAccessPrincipal(request.UserId, false, 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 identityPersistence.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, true, 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, true, true); } private Task HasSvipAccessAsync( AssetAccessRequest request, ContentAsset asset, CancellationToken cancellationToken) { var now = DateTimeOffset.UtcNow; return commercePersistence.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 not (AssetSecurityScanStatus.Passed or AssetSecurityScanStatus.NotRequired)) 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.AliyunOss => ObjectStorageProviders.AliyunOss, AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos, AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo, AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev, _ => ObjectStorageProviders.ExternalUrl }; } }