using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; using Tiku.Application.Jobs; using Tiku.Application.Storage; using Tiku.Domain.Common; using Tiku.Domain.Content; namespace Tiku.Infrastructure.Assets; internal sealed class AssetUploadManagementService(AssetManagementDependencies dependencies) : AssetManagementServiceBase(dependencies), IAssetUploadManagementService { public async Task SignUploadAsync( AssetManagementActor actor, AssetUploadSignCommand command, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.FileName); ArgumentException.ThrowIfNullOrWhiteSpace(command.MimeType); var storageConfig = await ResolveObjectStorageConfigAsync(actor.TenantId, cancellationToken); var provider = storageConfig.Provider; var bucket = storageConfig.Bucket; var mimeType = objectStorageService.ValidateMimeType(command.MimeType.Trim()); var fileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes); var asset = await ResolveUploadAssetAsync(actor, command, provider, bucket, mimeType, fileSizeBytes, cancellationToken); var objectKey = objectStorageService.ValidateObjectKey( actor.TenantId, string.IsNullOrWhiteSpace(command.ObjectKey) ? CreateObjectKey(actor.TenantId, asset.Id, command.FileName) : command.ObjectKey.Trim()); objectStorageService.AssertUploadProvider(provider); objectStorageService.AssertWritableLocation(new StorageAssetLocation(provider, bucket, objectKey)); asset.StorageProvider = ToAssetStorageProvider(provider); asset.Bucket = bucket; asset.ObjectKey = objectKey; asset.FileName = command.FileName.Trim(); asset.MimeType = mimeType; asset.FileSizeBytes = fileSizeBytes; asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256); asset.UploadStatus = AssetUploadStatus.Pending; asset.VerifiedAt = null; asset.VerifiedBy = null; asset.VerificationDetails = JsonDefaults.Object(); asset.SecurityScanStatus = AssetSecurityScanStatus.Pending; asset.PreviewStatus = ResolveInitialPreviewStatus(asset.AssetType, mimeType); asset.UpdatedBy = actor.UserId; var expiresIn = ResolveUploadTtl(command.ExpiresInSeconds); var upload = await objectStorageService.SignUploadAsync( new ObjectStorageUploadSignRequest( actor.TenantId, provider, bucket, objectKey, asset.FileName, mimeType, fileSizeBytes, expiresIn, true), cancellationToken); await contentAssetPersistence.SaveChangesAsync(cancellationToken); return new AssetUploadSignResult(ToItem(asset), upload); } public async Task ConfirmUploadAsync( AssetManagementActor actor, AssetUploadConfirmCommand command, CancellationToken cancellationToken = default) { var asset = await contentAssetPersistence.ContentAssets .SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.AssetId && item.Status == ContentStatus.Active, cancellationToken); if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found"); if (string.IsNullOrWhiteSpace(asset.ObjectKey) || string.IsNullOrWhiteSpace(asset.Bucket)) throw new AssetManagementException("Asset does not have a writable object location.", "asset_location_missing"); var accountedBytesBefore = AccountedStorageBytes(asset); var provider = ToObjectStorageProvider(asset.StorageProvider); var declaredMimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : command.MimeType.Trim(); var declaredSize = command.FileSizeBytes ?? asset.FileSizeBytes; var declaredChecksum = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256; var metadata = await objectStorageService.HeadObjectAsync( new ObjectStorageHeadRequest( actor.TenantId, provider, asset.Bucket, asset.ObjectKey, declaredMimeType, declaredSize, declaredChecksum), cancellationToken); asset.VerificationDetails = JsonSerializer.SerializeToElement(new { metadata.Exists, metadata.SizeBytes, metadata.MimeType, metadata.ChecksumSha256, metadata.ETag, metadata.LastModified, metadata.VerificationSource, declared = new { MimeType = declaredMimeType, FileSizeBytes = declaredSize, ChecksumSha256 = declaredChecksum } }); if (!metadata.Exists) { asset.UploadStatus = AssetUploadStatus.Failed; asset.UpdatedBy = actor.UserId; await contentAssetPersistence.SaveChangesAsync(cancellationToken); throw new AssetManagementException("Uploaded object was not found in object storage.", "asset_upload_missing"); } if (metadata.SizeBytes is not { } verifiedSizeBytes) { asset.UploadStatus = AssetUploadStatus.Failed; asset.UpdatedBy = actor.UserId; await contentAssetPersistence.SaveChangesAsync(cancellationToken); throw new AssetManagementException( "Object storage did not return a verified asset size.", "asset_upload_size_unverified"); } asset.UploadStatus = AssetUploadStatus.Verified; asset.VerifiedAt = DateTimeOffset.UtcNow; asset.VerifiedBy = actor.UserId; asset.VerifiedSizeBytes = verifiedSizeBytes; asset.VerifiedChecksumSha256 = NormalizeChecksum(metadata.ChecksumSha256) ?? declaredChecksum; asset.MimeType = metadata.MimeType ?? declaredMimeType; asset.FileSizeBytes = verifiedSizeBytes; asset.SecurityScanStatus = AssetSecurityScanStatus.Pending; asset.UpdatedBy = actor.UserId; var accountedBytesAfter = AccountedStorageBytes(asset); await SaveWithStorageQuotaAdjustmentAsync( actor.TenantId, accountedBytesAfter - accountedBytesBefore, cancellationToken); await backgroundJobService.EnqueueAsync( new CreateBackgroundJobCommand( actor.TenantId, "asset_security_scan", JsonSerializer.SerializeToElement(new { assetId = asset.Id }), MaxRetries: 5, IdempotencyKey: $"asset:{asset.Id:N}:{asset.VerifiedChecksumSha256 ?? asset.VerifiedAt?.UtcTicks.ToString()}", IsSystemJob: true), cancellationToken); return new AssetUploadConfirmResult(ToItem(asset), metadata); } }