From 1931899d534ce04ebd143e87179e8f014506cd18 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 20 Aug 2026 16:46:17 +0200 Subject: [PATCH] Added S3 support and services --- .../Abstractions/IS3Service.cs | 7 ++ .../Abstractions/S3ServiceBase.cs | 83 +++++++++++++++++++ .../Configuration/S3/S3Settings.cs | 16 ++++ .../Extensions/Constants.cs | 12 +++ .../Services/ContractS3Service.cs | 11 +++ .../Services/EvidenceS3Service.cs | 11 +++ .../Services/GeneralS3Service.cs | 11 +++ 7 files changed, 151 insertions(+) create mode 100644 PostFundManagement.Domain/Abstractions/IS3Service.cs create mode 100644 PostFundManagement.Domain/Abstractions/S3ServiceBase.cs create mode 100644 PostFundManagement.Domain/Configuration/S3/S3Settings.cs create mode 100644 PostFundManagement.Domain/Services/ContractS3Service.cs create mode 100644 PostFundManagement.Domain/Services/EvidenceS3Service.cs create mode 100644 PostFundManagement.Domain/Services/GeneralS3Service.cs diff --git a/PostFundManagement.Domain/Abstractions/IS3Service.cs b/PostFundManagement.Domain/Abstractions/IS3Service.cs new file mode 100644 index 0000000..c47083b --- /dev/null +++ b/PostFundManagement.Domain/Abstractions/IS3Service.cs @@ -0,0 +1,7 @@ +namespace PostFundManagement.Domain.Abstractions; + +public interface IS3Service +{ + Task> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default); + Task DeleteFileAsync(string fileKey, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Abstractions/S3ServiceBase.cs b/PostFundManagement.Domain/Abstractions/S3ServiceBase.cs new file mode 100644 index 0000000..e78cba1 --- /dev/null +++ b/PostFundManagement.Domain/Abstractions/S3ServiceBase.cs @@ -0,0 +1,83 @@ +using PostFundManagement.Domain.Services; + +namespace PostFundManagement.Domain.Abstractions; + +public abstract class S3ServiceBase(IAmazonS3 amazonS3) +{ + protected readonly IAmazonS3 Client = amazonS3; + + protected abstract string BucketName { get; } + protected abstract string CdnBaseUrl { get; } + + public virtual async Task> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default) + { + try + { + if (string.IsNullOrWhiteSpace(BucketName)) + return Result.Fail("Bucket name is not configured."); + + if (string.IsNullOrWhiteSpace(CdnBaseUrl)) + return Result.Fail("CDN base URL is not configured."); + + 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("Failed to compute file hash."); + + var fileKey = $"{fileHash.ToLower(CultureInfo.InvariantCulture)}{Path.GetExtension(fileName)}"; + + 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 != System.Net.HttpStatusCode.OK + ? Result.Fail($"Failed to upload {fileName} to S3.") + : Result.Ok($"{CdnBaseUrl}/{fileKey}"); + } + catch (Exception ex) + { + 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)); + } + } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Configuration/S3/S3Settings.cs b/PostFundManagement.Domain/Configuration/S3/S3Settings.cs new file mode 100644 index 0000000..9e36e5a --- /dev/null +++ b/PostFundManagement.Domain/Configuration/S3/S3Settings.cs @@ -0,0 +1,16 @@ +namespace PostFundManagement.Domain.Configuration.S3; + +public sealed class S3Settings +{ + public string? ServiceUrl { get; set; } + + public string? AccessKey { get; set; } + + public string? SecretKey { get; set; } + + public string? BucketName { get; set; } + + public string? Region { get; set; } + + public string? CdnBaseUrl { get; set; } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Extensions/Constants.cs b/PostFundManagement.Domain/Extensions/Constants.cs index 392a3fd..dd57bd2 100644 --- a/PostFundManagement.Domain/Extensions/Constants.cs +++ b/PostFundManagement.Domain/Extensions/Constants.cs @@ -2,6 +2,18 @@ namespace PostFundManagement.Domain.Extensions; public static class Constants { + public const string GeneralS3SettingsSection = "PfmS3Settings"; + + public const string EvidenceS3SettingsSection = "EvidenceS3Settings"; + + public const string ContractS3SettingsSection = "ContractS3Settings"; + + public const string GeneralQuotesBucketName = "pfm.general"; + + public const string EvidenceQuotesBucketName = "pfm.evidence"; + + public const string ContractQuotesBucketName = "pfm.contract"; + public const string DatabaseConfigName = "PfmDatabase"; public const int LabelLength = 256; diff --git a/PostFundManagement.Domain/Services/ContractS3Service.cs b/PostFundManagement.Domain/Services/ContractS3Service.cs new file mode 100644 index 0000000..b7f632b --- /dev/null +++ b/PostFundManagement.Domain/Services/ContractS3Service.cs @@ -0,0 +1,11 @@ +using PostFundManagement.Domain.Abstractions; +using static PostFundManagement.Domain.Extensions.Constants; + +namespace PostFundManagement.Domain.Services; + +public sealed class ContractS3Service(IConfiguration configuration, [FromKeyedServices(ContractQuotesBucketName)] IAmazonS3 amazonS3) : + S3ServiceBase(amazonS3), IS3Service +{ + protected override string BucketName => configuration.GetSection($"{ContractS3SettingsSection}:BucketName").Value ?? ""; + protected override string CdnBaseUrl => configuration.GetSection($"{ContractS3SettingsSection}:CdnBaseUrl").Value ?? ""; +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Services/EvidenceS3Service.cs b/PostFundManagement.Domain/Services/EvidenceS3Service.cs new file mode 100644 index 0000000..097715b --- /dev/null +++ b/PostFundManagement.Domain/Services/EvidenceS3Service.cs @@ -0,0 +1,11 @@ +using PostFundManagement.Domain.Abstractions; +using static PostFundManagement.Domain.Extensions.Constants; + +namespace PostFundManagement.Domain.Services; + +public sealed class EvidenceS3Service(IConfiguration configuration, [FromKeyedServices(EvidenceQuotesBucketName)] IAmazonS3 amazonS3) : + S3ServiceBase(amazonS3), IS3Service +{ + protected override string BucketName => configuration.GetSection($"{EvidenceS3SettingsSection}:BucketName").Value ?? ""; + protected override string CdnBaseUrl => configuration.GetSection($"{EvidenceS3SettingsSection}:CdnBaseUrl").Value ?? ""; +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Services/GeneralS3Service.cs b/PostFundManagement.Domain/Services/GeneralS3Service.cs new file mode 100644 index 0000000..5b425df --- /dev/null +++ b/PostFundManagement.Domain/Services/GeneralS3Service.cs @@ -0,0 +1,11 @@ +using PostFundManagement.Domain.Abstractions; +using static PostFundManagement.Domain.Extensions.Constants; + +namespace PostFundManagement.Domain.Services; + +public sealed class GeneralS3Service(IConfiguration configuration, [FromKeyedServices(GeneralQuotesBucketName)] IAmazonS3 amazonS3) : + S3ServiceBase(amazonS3), IS3Service +{ + protected override string BucketName => configuration.GetSection($"{GeneralS3SettingsSection}:BucketName").Value ?? ""; + protected override string CdnBaseUrl => configuration.GetSection($"{GeneralS3SettingsSection}:CdnBaseUrl").Value ?? ""; +} \ No newline at end of file