diff --git a/SW.Bitween.NativeAdapters/AzureBlobReceiver/AzureBlobReceiverInput.cs b/SW.Bitween.NativeAdapters/AzureBlobReceiver/AzureBlobReceiverInput.cs new file mode 100644 index 00000000..da455994 --- /dev/null +++ b/SW.Bitween.NativeAdapters/AzureBlobReceiver/AzureBlobReceiverInput.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace SW.Bitween.NativeAdapters.AzureBlobReceiver; + +public class AzureBlobReceiverInput +{ + [Required] + [Secure] + public string ConnectionString { get; set; } = string.Empty; + + [Required] + public string ContainerName { 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/AzureBlobReceiver/NativeAzureBlobReceiver.cs b/SW.Bitween.NativeAdapters/AzureBlobReceiver/NativeAzureBlobReceiver.cs new file mode 100644 index 00000000..503a7d54 --- /dev/null +++ b/SW.Bitween.NativeAdapters/AzureBlobReceiver/NativeAzureBlobReceiver.cs @@ -0,0 +1,95 @@ +using System.Text; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using SW.PrimitiveTypes; + +namespace SW.Bitween.NativeAdapters.AzureBlobReceiver; + +public class NativeAzureBlobReceiver : INativeInfolinkReceiver +{ + private AzureBlobReceiverInput _options = new(); + private BlobContainerClient _container = null!; + + public Task Initialize() + { + _container = new BlobContainerClient(_options.ConnectionString.Trim(), _options.ContainerName.Trim()); + return Task.CompletedTask; + } + + public Task Finalize() + { + return Task.CompletedTask; + } + + public async Task> ListFiles() + { + var blobNames = new List(); + + // Trailing slash keeps the prefix scoped to the folder itself, so a sibling + // like "incoming-archive/x.txt" doesn't match a FolderName of "incoming". + var prefix = string.IsNullOrEmpty(_options.FolderName) ? _options.FolderName : _options.FolderName + "/"; + + await foreach (var blob in _container.GetBlobsAsync(BlobTraits.None, BlobStates.None, prefix)) + { + blobNames.Add(blob.Name); + if (blobNames.Count >= _options.BatchSize) + break; + } + + return blobNames; + } + + public async Task GetFile(string fileId) + { + var blobClient = _container.GetBlobClient(fileId); + var download = await blobClient.DownloadAsync(); + + using var memoryStream = new MemoryStream(); + await download.Value.Content.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(AzureBlobReceiverInput.ResponseEncoding)} '{_options.ResponseEncoding}'") + }; + } + + public async Task DeleteFile(string fileId) + { + var sourceBlob = _container.GetBlobClient(fileId); + + // Retry-safe: if a prior attempt already moved/deleted this file, there's + // nothing left to do — treat it as already completed, not an error. + if (!await sourceBlob.ExistsAsync()) + return; + + 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; + var targetName = $"{_options.DeleteMovesFileTo}/{relativePath}"; + + // Server-side copy: Azure moves the blob internally, so no bytes are + // downloaded or re-uploaded through this process. + var targetBlob = _container.GetBlobClient(targetName); + await targetBlob.SyncCopyFromUriAsync(sourceBlob.Uri); + } + + await sourceBlob.DeleteAsync(); + } + + public string Name => "NativeAzureBlobReceiver"; + + public void InitializeStartupValues(IDictionary settings) + { + _options = settings.ConvertTo(); + } + + public Type StartupValuesType => typeof(AzureBlobReceiverInput); +} diff --git a/SW.Bitween.NativeAdapters/AzureBlobUploadHandler/AzureBlobUploadHandlerInput.cs b/SW.Bitween.NativeAdapters/AzureBlobUploadHandler/AzureBlobUploadHandlerInput.cs new file mode 100644 index 00000000..5174f4b0 --- /dev/null +++ b/SW.Bitween.NativeAdapters/AzureBlobUploadHandler/AzureBlobUploadHandlerInput.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace SW.Bitween.NativeAdapters.AzureBlobUploadHandler; + +public class AzureBlobUploadHandlerInput +{ + [Required] + [Secure] + public string ConnectionString { get; set; } = string.Empty; + + [Required] + public string ContainerName { get; set; } = string.Empty; + + public string? FileName { get; set; } + + public string? FileExtension { get; set; } +} diff --git a/SW.Bitween.NativeAdapters/AzureBlobUploadHandler/NativeAzureBlobUploadHandler.cs b/SW.Bitween.NativeAdapters/AzureBlobUploadHandler/NativeAzureBlobUploadHandler.cs new file mode 100644 index 00000000..3473d9bf --- /dev/null +++ b/SW.Bitween.NativeAdapters/AzureBlobUploadHandler/NativeAzureBlobUploadHandler.cs @@ -0,0 +1,38 @@ +using System.Text; +using Azure.Storage.Blobs; +using SW.PrimitiveTypes; + +namespace SW.Bitween.NativeAdapters.AzureBlobUploadHandler; + +public class NativeAzureBlobUploadHandler : INativeInfolinkHandler +{ + private AzureBlobUploadHandlerInput _options = new(); + + public async Task Handle(XchangeFile xchangeFile) + { + var container = new BlobContainerClient(_options.ConnectionString.Trim(), _options.ContainerName.Trim()); + + var blobName = _options.FileName; + if (string.IsNullOrWhiteSpace(blobName)) + { + var extension = _options.FileExtension?.TrimStart('.'); + var name = $"{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}"; + blobName = string.IsNullOrEmpty(extension) ? name : $"{name}.{extension}"; + } + + var blobClient = container.GetBlobClient(blobName); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(xchangeFile.Data)); + await blobClient.UploadAsync(stream, overwrite: true); + + return new XchangeFile(blobName, xchangeFile.Filename); + } + + public string Name => "NativeAzureBlobUploadHandler"; + + public void InitializeStartupValues(IDictionary settings) + { + _options = settings.ConvertTo(); + } + + public Type StartupValuesType => typeof(AzureBlobUploadHandlerInput); +} diff --git a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj index db3cb05e..2e232ebe 100644 --- a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj +++ b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj @@ -14,6 +14,7 @@ + diff --git a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs index a4189224..5f0c882a 100644 --- a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs +++ b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.NativeAdapters.AzureBlobReceiver; +using SW.Bitween.NativeAdapters.AzureBlobUploadHandler; using SW.Bitween.NativeAdapters.HttpReceiver; using SW.Bitween.NativeAdapters.Pop3Receiver; using SW.Bitween.NativeAdapters.RebexFtpReceiver; @@ -46,6 +48,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));