diff --git a/SW.Bitween.NativeAdapters/S3Receiver/NativeS3Receiver.cs b/SW.Bitween.NativeAdapters/S3Receiver/NativeS3Receiver.cs new file mode 100644 index 00000000..05148909 --- /dev/null +++ b/SW.Bitween.NativeAdapters/S3Receiver/NativeS3Receiver.cs @@ -0,0 +1,99 @@ +using System.Text; +using Amazon.S3; +using SW.CloudFiles.S3; +using SW.PrimitiveTypes; + +namespace SW.Bitween.NativeAdapters.S3Receiver; + +public class NativeS3Receiver : INativeInfolinkReceiver, IDisposable +{ + private S3ReceiverInput _options = new(); + private CloudFilesService? _cloudFiles; + private AmazonS3Client? _s3Client; + + public Task Initialize() + { + var options = new CloudFilesOptions + { + AccessKeyId = _options.AccessKeyId, + SecretAccessKey = _options.SecretAccessKey, + ServiceUrl = _options.ServiceUrl, + BucketName = _options.BucketName, + }; + + _cloudFiles = new CloudFilesService(options); + _s3Client = options.CreateClient(); + + return Task.CompletedTask; + } + + public Task Finalize() + { + _cloudFiles?.Dispose(); + _s3Client?.Dispose(); + return Task.CompletedTask; + } + + // Safety net: the DI container disposes scoped instances when the job's scope ends, + // even if Initialize/ListFiles/GetFile/DeleteFile threw and Finalize was never reached. + public void Dispose() + { + _cloudFiles?.Dispose(); + _s3Client?.Dispose(); + } + + public async Task> ListFiles() + { + var files = await _cloudFiles.ListAsync(_options.FolderName ?? string.Empty); + + return files + .Where(f => !f.Key.EndsWith("/")) + .Select(f => f.Key) + .Take(_options.BatchSize) + .ToList(); + } + + public async Task GetFile(string fileId) + { + await using var stream = await _cloudFiles.OpenReadAsync(fileId); + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream); + var bytes = memoryStream.ToArray(); + + return (_options.ResponseEncoding ?? "utf8").ToLower() switch + { + "base64" => new XchangeFile(Convert.ToBase64String(bytes), fileId), + "utf8" => new XchangeFile(Encoding.UTF8.GetString(bytes), fileId), + _ => throw new ArgumentException( + $"Unknown {nameof(S3ReceiverInput.ResponseEncoding)} '{_options.ResponseEncoding}'") + }; + } + + public async Task DeleteFile(string fileId) + { + if (!string.IsNullOrWhiteSpace(_options.DeleteMovesFileTo)) + { + // Preserve the path relative to FolderName so files with the same name in + // different subdirectories don't collide at the destination. + var relativePath = !string.IsNullOrEmpty(_options.FolderName) && fileId.StartsWith(_options.FolderName + "/") + ? fileId[(_options.FolderName.Length + 1)..] + : fileId; + + // Server-side copy: S3 moves the object internally, so no bytes are + // downloaded or re-uploaded through this process. + var targetKey = $"{_options.DeleteMovesFileTo}/{relativePath}"; + await _s3Client!.CopyObjectAsync(_options.BucketName, fileId, _options.BucketName, targetKey); + } + + await _cloudFiles.DeleteAsync(fileId); + } + + public string Name => "NativeS3Receiver"; + + public void InitializeStartupValues(IDictionary settings) + { + _options = settings.ConvertTo(); + } + + public Type StartupValuesType => typeof(S3ReceiverInput); +} diff --git a/SW.Bitween.NativeAdapters/S3Receiver/S3ReceiverInput.cs b/SW.Bitween.NativeAdapters/S3Receiver/S3ReceiverInput.cs new file mode 100644 index 00000000..a97bfe3d --- /dev/null +++ b/SW.Bitween.NativeAdapters/S3Receiver/S3ReceiverInput.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace SW.Bitween.NativeAdapters.S3Receiver; + +public class S3ReceiverInput +{ + [Required] + public string AccessKeyId { get; set; } = string.Empty; + + [Required] + [Secure] + public string SecretAccessKey { get; set; } = string.Empty; + + [Required] + public string ServiceUrl { get; set; } = string.Empty; + + [Required] + public string BucketName { get; set; } = string.Empty; + + public string? FolderName { get; set; } + + [DefaultValue(50)] + public int BatchSize { get; set; } = 50; + + [DefaultValue("utf8")] + public string ResponseEncoding { get; set; } = "utf8"; + + public string? DeleteMovesFileTo { get; set; } +} diff --git a/SW.Bitween.NativeAdapters/S3UploadHandler/NativeS3UploadHandler.cs b/SW.Bitween.NativeAdapters/S3UploadHandler/NativeS3UploadHandler.cs new file mode 100644 index 00000000..674d88bd --- /dev/null +++ b/SW.Bitween.NativeAdapters/S3UploadHandler/NativeS3UploadHandler.cs @@ -0,0 +1,48 @@ +using SW.CloudFiles.S3; +using SW.PrimitiveTypes; + +namespace SW.Bitween.NativeAdapters.S3UploadHandler; + +public class NativeS3UploadHandler : INativeInfolinkHandler +{ + private S3UploadHandlerInput _options = new(); + + public async Task Handle(XchangeFile xchangeFile) + { + using var cloudFiles = new CloudFilesService(new CloudFilesOptions + { + AccessKeyId = _options.AccessKeyId, + SecretAccessKey = _options.SecretAccessKey, + ServiceUrl = _options.ServiceUrl, + BucketName = _options.BucketName, + }); + + var key = _options.FileName; + if (string.IsNullOrWhiteSpace(key)) + { + var extension = _options.FileExtension?.TrimStart('.'); + var name = $"{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}"; + key = string.IsNullOrEmpty(extension) ? name : $"{name}.{extension}"; + + if (!string.IsNullOrWhiteSpace(_options.FolderName)) + key = $"{_options.FolderName}/{key}"; + } + + await cloudFiles.WriteTextAsync(xchangeFile.Data, new WriteFileSettings + { + Key = key, + ContentType = _options.ContentType, + }); + + return new XchangeFile(key, xchangeFile.Filename); + } + + public string Name => "NativeS3UploadHandler"; + + public void InitializeStartupValues(IDictionary settings) + { + _options = settings.ConvertTo(); + } + + public Type StartupValuesType => typeof(S3UploadHandlerInput); +} diff --git a/SW.Bitween.NativeAdapters/S3UploadHandler/S3UploadHandlerInput.cs b/SW.Bitween.NativeAdapters/S3UploadHandler/S3UploadHandlerInput.cs new file mode 100644 index 00000000..eb9bbce2 --- /dev/null +++ b/SW.Bitween.NativeAdapters/S3UploadHandler/S3UploadHandlerInput.cs @@ -0,0 +1,29 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace SW.Bitween.NativeAdapters.S3UploadHandler; + +public class S3UploadHandlerInput +{ + [Required] + public string AccessKeyId { get; set; } = string.Empty; + + [Required] + [Secure] + public string SecretAccessKey { get; set; } = string.Empty; + + [Required] + public string ServiceUrl { get; set; } = string.Empty; + + [Required] + public string BucketName { get; set; } = string.Empty; + + public string? FolderName { get; set; } + + public string? FileName { get; set; } + + public string? FileExtension { get; set; } + + [DefaultValue("text/plain")] + public string ContentType { get; set; } = "text/plain"; +} diff --git a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj index 561103de..db3cb05e 100644 --- a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj +++ b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj @@ -22,6 +22,7 @@ + diff --git a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs index aaae520c..a4189224 100644 --- a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs +++ b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs @@ -4,6 +4,8 @@ using SW.Bitween.NativeAdapters.RebexFtpReceiver; using SW.Bitween.NativeAdapters.RebexFtpUploadHandler; using SW.Bitween.NativeAdapters.RebexPop3Receiver; +using SW.Bitween.NativeAdapters.S3Receiver; +using SW.Bitween.NativeAdapters.S3UploadHandler; namespace SW.Bitween.NativeAdapters; @@ -38,6 +40,12 @@ public static void AddNativeAdapters(this IServiceCollection serviceCollection, serviceCollection.AddScoped(); serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + if (!string.IsNullOrEmpty(rebexLicenseKey)) { serviceCollection.AddScoped(_ => new NativeRebexPop3Receiver(rebexLicenseKey));