using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; using Tiku.Application.Jobs; using Tiku.Application.Storage; using Tiku.Domain.Content; using Tiku.Infrastructure.Jobs; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Assets.Security; internal sealed class AssetSecurityScanJobHandler( IContentAssetPersistence dbContext, IAssetSecurityScanner scanner, IObjectStorageService storage) : IBackgroundJobHandler { public string JobType => "asset_security_scan"; public async Task HandleAsync( BackgroundJobExecutionContext context, CancellationToken cancellationToken = default) { var assetId = BackgroundJobPayload.GetGuid(context.Payload, "assetId") ?? throw new InvalidOperationException("asset_security_scan job requires assetId."); var asset = await dbContext.ContentAssets.SingleOrDefaultAsync( item => item.TenantId == context.TenantId && item.Id == assetId, cancellationToken) ?? throw new InvalidOperationException("Asset security scan target was not found."); if (asset.UploadStatus != AssetUploadStatus.Verified || string.IsNullOrWhiteSpace(asset.Bucket) || string.IsNullOrWhiteSpace(asset.ObjectKey)) throw new InvalidOperationException("Asset must have a verified object location before security scanning."); asset.SecurityScanStatus = AssetSecurityScanStatus.Scanning; await dbContext.SaveChangesAsync(cancellationToken); try { await using var content = await storage.OpenReadAsync( new ObjectStorageReadRequest( context.TenantId, asset.StorageProvider switch { AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss, AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev, _ => throw new InvalidOperationException( "Asset storage provider does not support security scanning.") }, asset.Bucket, asset.ObjectKey), cancellationToken); var result = await scanner.ScanAsync(content, asset.VerifiedSizeBytes ?? asset.FileSizeBytes, cancellationToken); var infected = result.Verdict == AssetSecurityScanVerdict.Infected; asset.SecurityScanStatus = infected ? AssetSecurityScanStatus.Failed : AssetSecurityScanStatus.Passed; asset.SecurityScannedAt = DateTimeOffset.UtcNow; asset.SecurityScanProvider = result.Provider; asset.SecurityScanSummary = JsonSerializer.SerializeToElement(new { verdict = result.Verdict.ToString(), result.Signature, result.BytesScanned }); dbContext.ContentAssetSecurityScanEvents.Add(new ContentAssetSecurityScanEvent { TenantId = context.TenantId, AssetId = asset.Id, Provider = result.Provider, ScanStatus = asset.SecurityScanStatus, RiskLevel = infected ? AssetSecurityRiskLevel.Critical : AssetSecurityRiskLevel.None, IssueCodes = infected ? [result.Signature ?? "malware_detected"] : [], Details = asset.SecurityScanSummary }); await dbContext.SaveChangesAsync(cancellationToken); return new BackgroundJobHandlerResult(JsonSerializer.SerializeToElement(new { assetId = asset.Id, status = asset.SecurityScanStatus.ToString(), result.Signature, result.BytesScanned })); } catch (AssetSecurityScannerException exception) { throw new BackgroundJobHandlerException(exception.Code, exception.Message, exception); } } public async Task HandleFailureAsync( BackgroundJobExecutionContext context, Exception exception, CancellationToken cancellationToken = default) { if (exception is not BackgroundJobHandlerException handlerException || handlerException.InnerException is not AssetSecurityScannerException) return; var assetId = BackgroundJobPayload.GetGuid(context.Payload, "assetId"); if (assetId is null) return; var asset = await dbContext.ContentAssets.SingleOrDefaultAsync( item => item.TenantId == context.TenantId && item.Id == assetId, cancellationToken); if (asset is null) return; asset.SecurityScanStatus = AssetSecurityScanStatus.Pending; asset.SecurityScanProvider = "clamav"; asset.SecurityScanSummary = JsonSerializer.SerializeToElement(new { errorCode = handlerException.Code }); dbContext.ContentAssetSecurityScanEvents.Add(new ContentAssetSecurityScanEvent { TenantId = context.TenantId, AssetId = asset.Id, Provider = "clamav", ScanStatus = AssetSecurityScanStatus.Pending, RiskLevel = AssetSecurityRiskLevel.None, IssueCodes = [handlerException.Code], Details = asset.SecurityScanSummary }); await dbContext.SaveChangesAsync(cancellationToken); } }