using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain.Errors; namespace PostFundManagement.Application.Abstractions.Services; public abstract class S3ServiceBase(IAmazonS3 amazonS3) { protected readonly IAmazonS3 Client = amazonS3; protected abstract string BucketName { get; } protected abstract string CdnBaseUrl { get; } public string GenerateCdnUrl(string fileKey) => $"{CdnBaseUrl}/{fileKey}"; public virtual async Task> GeneratePresignedUrlAsync(string fileKey, TimeSpan duration, string? contentType = null, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { if (string.IsNullOrWhiteSpace(BucketName)) return Result.Fail(ValidationError.Create("Bucket name is not configured.")); if (string.IsNullOrWhiteSpace(fileKey)) return Result.Fail(ValidationError.Create("File key is required.")); var request = new GetPreSignedUrlRequest { BucketName = BucketName, Key = fileKey, Expires = DateTime.UtcNow.Add(duration), Verb = HttpVerb.GET }; if (!string.IsNullOrWhiteSpace(contentType)) { request.ResponseHeaderOverrides.ContentType = contentType; request.ResponseHeaderOverrides.ContentDisposition = "inline"; } var presignedUrl = await Client.GetPreSignedURLAsync(request); return Result.FailIf(string.IsNullOrWhiteSpace(presignedUrl), CreateError.Create("Failed to create presigned URL")) .ToResult(presignedUrl); })); private static string CreateFileKey(string path, string fileName, string fileHash) { var cleanSubPath = path.Replace('\\', '/').Trim('/'); return string.IsNullOrWhiteSpace(cleanSubPath) ? $"{fileHash.ToLower(CultureInfo.InvariantCulture)}_{fileName}" : $"{cleanSubPath}/{fileHash.ToLower(CultureInfo.InvariantCulture)}_{fileName}"; } public virtual async Task> UploadFileAsync(string fileName, string subPath, Stream fileStream, string contentType, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { if (string.IsNullOrWhiteSpace(BucketName)) return Result.Fail(ValidationError.Create("Bucket name is not configured.")); if (string.IsNullOrWhiteSpace(CdnBaseUrl)) return Result.Fail(ValidationError.Create("CDN base URL is not configured.")); await using var stream = new MemoryStream(); await fileStream.CopyToAsync(stream, cancellationToken); await fileStream.DisposeAsync(); stream.Seek(0, SeekOrigin.Begin); var fileHash = HashService.StreamToSha256Hash(stream); if (string.IsNullOrWhiteSpace(fileHash)) return Result.Fail(CreateError.Create("Failed to compute file hash.")); var fileKey = CreateFileKey(subPath, fileName, fileHash); var putRequest = new PutObjectRequest { BucketName = BucketName, Key = fileKey, InputStream = stream, ContentType = contentType, UseChunkEncoding = false, }; stream.Seek(0, SeekOrigin.Begin); var response = await Client.PutObjectAsync(putRequest, cancellationToken); return response.HttpStatusCode != HttpStatusCode.OK ? Result.Fail(CreateError.Create($"Failed to upload {fileName} to S3.")) : Result.Ok(fileKey); })); public virtual async Task DeleteFileAsync(string fileKey, CancellationToken cancellationToken = default) => await Result.Try((Func>)(async () => { if (string.IsNullOrWhiteSpace(BucketName)) return Result.Fail(NotFoundError.Create("Bucket name is not configured.")); var response = await Client.DeleteObjectAsync(new DeleteObjectRequest { BucketName = BucketName, Key = fileKey }, cancellationToken); return Result.FailIf(response.HttpStatusCode == HttpStatusCode.NoContent, UpdateError.Create($"Failed to delete {fileKey} from S3.")); })); }