From 77017386de002b3466fcfa1e3d7f8eb3f033a0db Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Wed, 8 Jul 2026 15:57:02 +0300 Subject: [PATCH 1/2] feat: add native AzureBlob upload handler and receiver Port the serverless AzureBlob handler and receiver into in-process native adapters, with collision-safe filenames, batch/folder limits, and a server-side copy for move-on-delete. --- .../AzureBlobReceiverInput.cs | 24 ++++++ .../NativeAzureBlobReceiver.cs | 85 +++++++++++++++++++ .../AzureBlobUploadHandlerInput.cs | 17 ++++ .../NativeAzureBlobUploadHandler.cs | 38 +++++++++ .../SW.Bitween.NativeAdapters.csproj | 1 + .../ServiceCollectionExtensions.cs | 8 ++ 6 files changed, 173 insertions(+) create mode 100644 SW.Bitween.NativeAdapters/AzureBlobReceiver/AzureBlobReceiverInput.cs create mode 100644 SW.Bitween.NativeAdapters/AzureBlobReceiver/NativeAzureBlobReceiver.cs create mode 100644 SW.Bitween.NativeAdapters/AzureBlobUploadHandler/AzureBlobUploadHandlerInput.cs create mode 100644 SW.Bitween.NativeAdapters/AzureBlobUploadHandler/NativeAzureBlobUploadHandler.cs 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..6335a3c6 --- /dev/null +++ b/SW.Bitween.NativeAdapters/AzureBlobReceiver/NativeAzureBlobReceiver.cs @@ -0,0 +1,85 @@ +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(); + + await foreach (var blob in _container.GetBlobsAsync(BlobTraits.None, BlobStates.None, _options.FolderName)) + { + 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) + { + 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 sourceBlob = _container.GetBlobClient(fileId); + var targetBlob = _container.GetBlobClient(targetName); + await targetBlob.SyncCopyFromUriAsync(sourceBlob.Uri); + } + + await _container.GetBlobClient(fileId).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)); From 2fbb8eb547ba2d407257bb474c81e0d66040ed36 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Wed, 8 Jul 2026 17:00:16 +0300 Subject: [PATCH 2/2] fix: harden native AzureBlob receiver per code review Trim whitespace from connection settings, scope folder listing to a true prefix boundary, and make move-on-delete safe to retry. --- .../AzureBlobReceiver/NativeAzureBlobReceiver.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/SW.Bitween.NativeAdapters/AzureBlobReceiver/NativeAzureBlobReceiver.cs b/SW.Bitween.NativeAdapters/AzureBlobReceiver/NativeAzureBlobReceiver.cs index 6335a3c6..503a7d54 100644 --- a/SW.Bitween.NativeAdapters/AzureBlobReceiver/NativeAzureBlobReceiver.cs +++ b/SW.Bitween.NativeAdapters/AzureBlobReceiver/NativeAzureBlobReceiver.cs @@ -25,7 +25,11 @@ public async Task> ListFiles() { var blobNames = new List(); - await foreach (var blob in _container.GetBlobsAsync(BlobTraits.None, BlobStates.None, _options.FolderName)) + // 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) @@ -55,6 +59,13 @@ public async Task GetFile(string fileId) 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 @@ -66,12 +77,11 @@ public async Task DeleteFile(string fileId) // Server-side copy: Azure moves the blob internally, so no bytes are // downloaded or re-uploaded through this process. - var sourceBlob = _container.GetBlobClient(fileId); var targetBlob = _container.GetBlobClient(targetName); await targetBlob.SyncCopyFromUriAsync(sourceBlob.Uri); } - await _container.GetBlobClient(fileId).DeleteAsync(); + await sourceBlob.DeleteAsync(); } public string Name => "NativeAzureBlobReceiver";