From ccf30ac36b7d17a0b71fdab9aee72ae7b1c68b42 Mon Sep 17 00:00:00 2001 From: Khwezi Mngoma Date: Wed, 20 May 2026 15:32:54 +0200 Subject: [PATCH] Optimised UploadFileAsync() Implemented and tested DeleteFileAsync() --- .../S3ServiceFeatureTests.cs | 28 ++++++++++- LiteCharms.Features/Extensions/Hash.cs | 8 +++- .../S3/Abstractions/IS3Service.cs | 1 + .../S3/Abstractions/S3ServiceBase.cs | 47 +++++++++++++++++-- LiteCharms.Features/Shop/Leads/LeadService.cs | 2 +- 5 files changed, 80 insertions(+), 6 deletions(-) diff --git a/LiteCharms.Features.Tests/S3ServiceFeatureTests.cs b/LiteCharms.Features.Tests/S3ServiceFeatureTests.cs index b7f7ac4..3697a7a 100644 --- a/LiteCharms.Features.Tests/S3ServiceFeatureTests.cs +++ b/LiteCharms.Features.Tests/S3ServiceFeatureTests.cs @@ -7,7 +7,7 @@ public class S3ServiceFeatureTests(CommonFixture fixture, ITestOutputHelper outp [Fact] public async Task BookshopS3Service_MustReturnUrl() { - var service = fixture.Services.GetKeyedService(S3.Constants.BookshopQuotesBucketName); + var service = fixture.Services.GetKeyedService(S3.Constants.BookshopBucketName); var fileName = "appsettings.json"; @@ -25,4 +25,30 @@ public class S3ServiceFeatureTests(CommonFixture fixture, ITestOutputHelper outp output.WriteLine(result.Value); } + + [Fact] + public async Task BookshopS3Service_MustDeleteFile() + { + var service = fixture.Services.GetKeyedService(S3.Constants.BookshopBucketName); + + var fileName = "appsettings.json"; + + string path = Path.Combine(Directory.GetCurrentDirectory(), fileName); + + Assert.True(File.Exists(path)); + + var stream = File.OpenRead(path); + + var uploadResult = await service!.UploadFileAsync(fileName, stream, MimeKit.MimeTypes.GetMimeType(fileName)); + + Assert.True(uploadResult.IsSuccess); + Assert.NotNull(uploadResult.Value); + Assert.NotEmpty(uploadResult.Value); + + var fileKey = uploadResult.Value.Split('/').Last(); + + var deleteResult = await service!.DeleteFileAsync(fileKey); + + Assert.True(deleteResult.IsSuccess); + } } diff --git a/LiteCharms.Features/Extensions/Hash.cs b/LiteCharms.Features/Extensions/Hash.cs index ab76ecc..d903299 100644 --- a/LiteCharms.Features/Extensions/Hash.cs +++ b/LiteCharms.Features/Extensions/Hash.cs @@ -2,6 +2,12 @@ public static class Hash { - public static Func GenerateSha256HashString = (input) => + public static Func StringToSha256Hash = (input) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input!))); + + public static Func StreamToSha256Hash = (stream) => + Convert.ToHexString(SHA256.HashData(stream)); + + public static Func BytesToSha256Hash = (bytes) => + Convert.ToHexString(SHA256.HashData(bytes)); } diff --git a/LiteCharms.Features/S3/Abstractions/IS3Service.cs b/LiteCharms.Features/S3/Abstractions/IS3Service.cs index 8684b2d..4c0cdb5 100644 --- a/LiteCharms.Features/S3/Abstractions/IS3Service.cs +++ b/LiteCharms.Features/S3/Abstractions/IS3Service.cs @@ -3,4 +3,5 @@ public interface IS3Service { Task> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default); + Task DeleteFileAsync(string fileKey, CancellationToken cancellationToken = default); } diff --git a/LiteCharms.Features/S3/Abstractions/S3ServiceBase.cs b/LiteCharms.Features/S3/Abstractions/S3ServiceBase.cs index 1b232fc..6679d5d 100644 --- a/LiteCharms.Features/S3/Abstractions/S3ServiceBase.cs +++ b/LiteCharms.Features/S3/Abstractions/S3ServiceBase.cs @@ -1,4 +1,6 @@ -namespace LiteCharms.Features.S3.Abstractions; +using static LiteCharms.Features.Extensions.Hash; + +namespace LiteCharms.Features.S3.Abstractions; public abstract class S3ServiceBase(IAmazonS3 amazonS3) { @@ -17,17 +19,31 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3) if (string.IsNullOrWhiteSpace(CdnBaseUrl)) return Result.Fail("CDN base URL is not configured."); - var fileKey = $"{Guid.NewGuid():N}{Path.GetExtension(fileName)}"; + using var stream = new MemoryStream(); + + await fileStream.CopyToAsync(stream, cancellationToken); + await fileStream.DisposeAsync(); + + stream.Seek(0, SeekOrigin.Begin); + + var fileHash = StreamToSha256Hash(stream); + + if(string.IsNullOrWhiteSpace(fileHash)) + return Result.Fail("Failed to compute file hash."); + + var fileKey = $"{fileHash.ToLower()}{Path.GetExtension(fileName)}"; var putRequest = new PutObjectRequest { BucketName = BucketName, Key = fileKey, - InputStream = fileStream, + InputStream = stream, ContentType = contentType, UseChunkEncoding = false }; + stream.Seek(0, SeekOrigin.Begin); + var response = await Client.PutObjectAsync(putRequest, cancellationToken); return response.HttpStatusCode != System.Net.HttpStatusCode.OK @@ -39,4 +55,29 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3) return Result.Fail(new Error($"Error uploading {fileName} to S3: {ex.Message}").CausedBy(ex)); } } + + public virtual async Task DeleteFileAsync(string fileKey, CancellationToken cancellationToken = default) + { + try + { + if (string.IsNullOrWhiteSpace(BucketName)) + return Result.Fail("Bucket name is not configured."); + + var deleteRequest = new DeleteObjectRequest + { + BucketName = BucketName, + Key = fileKey + }; + + var response = await Client.DeleteObjectAsync(deleteRequest, cancellationToken); + + return response.HttpStatusCode != System.Net.HttpStatusCode.NoContent + ? Result.Fail($"Failed to delete {fileKey} from S3.") + : Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(new Error($"Error deleting {fileKey} from S3: {ex.Message}").CausedBy(ex)); + } + } } diff --git a/LiteCharms.Features/Shop/Leads/LeadService.cs b/LiteCharms.Features/Shop/Leads/LeadService.cs index f099235..18c5063 100644 --- a/LiteCharms.Features/Shop/Leads/LeadService.cs +++ b/LiteCharms.Features/Shop/Leads/LeadService.cs @@ -28,7 +28,7 @@ public class LeadService(IDbContextFactory contextFactory) FeedItemId = request.FeedItemId, Status = LeadStatus.New, TargetId = request.TargetId, - AttributionHash = GenerateSha256HashString.Invoke($"{request.ClickId}{request.AppClickId}{request.WebClickId}") + AttributionHash = StringToSha256Hash.Invoke($"{request.ClickId}{request.AppClickId}{request.WebClickId}") }); return await context.SaveChangesAsync(cancellationToken) > 0