diff --git a/Elsa.Extensions.sln b/Elsa.Extensions.sln index fdf72b48..3ca34442 100644 --- a/Elsa.Extensions.sln +++ b/Elsa.Extensions.sln @@ -277,6 +277,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "diagnostics", "diagnostics" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.OpenTelemetry", "src\modules\diagnostics\Elsa.OpenTelemetry\Elsa.OpenTelemetry.csproj", "{28D04FA3-4DCC-4137-8ED4-9F6F1A815909}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "io", "io", "{9B363CC3-1F38-409B-8E0D-E2C13019902A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.IO", "src\modules\io\Elsa.IO\Elsa.IO.csproj", "{D620FC48-5B8E-4010-86DE-20628364AA6E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.IO.Compression", "src\modules\io\Elsa.IO.Compression\Elsa.IO.Compression.csproj", "{53F9BF60-127F-4F86-9B33-34445BFA512D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.IO.Http", "src\modules\io\Elsa.IO.Http\Elsa.IO.Http.csproj", "{89D15E59-B0BF-4525-99FA-B61BA8451241}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -631,6 +639,18 @@ Global {28D04FA3-4DCC-4137-8ED4-9F6F1A815909}.Debug|Any CPU.Build.0 = Debug|Any CPU {28D04FA3-4DCC-4137-8ED4-9F6F1A815909}.Release|Any CPU.ActiveCfg = Release|Any CPU {28D04FA3-4DCC-4137-8ED4-9F6F1A815909}.Release|Any CPU.Build.0 = Release|Any CPU + {D620FC48-5B8E-4010-86DE-20628364AA6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D620FC48-5B8E-4010-86DE-20628364AA6E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D620FC48-5B8E-4010-86DE-20628364AA6E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D620FC48-5B8E-4010-86DE-20628364AA6E}.Release|Any CPU.Build.0 = Release|Any CPU + {53F9BF60-127F-4F86-9B33-34445BFA512D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {53F9BF60-127F-4F86-9B33-34445BFA512D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {53F9BF60-127F-4F86-9B33-34445BFA512D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {53F9BF60-127F-4F86-9B33-34445BFA512D}.Release|Any CPU.Build.0 = Release|Any CPU + {89D15E59-B0BF-4525-99FA-B61BA8451241}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {89D15E59-B0BF-4525-99FA-B61BA8451241}.Debug|Any CPU.Build.0 = Debug|Any CPU + {89D15E59-B0BF-4525-99FA-B61BA8451241}.Release|Any CPU.ActiveCfg = Release|Any CPU + {89D15E59-B0BF-4525-99FA-B61BA8451241}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -755,6 +775,10 @@ Global {8D0EC628-350F-47FC-8B36-DD98B4B7CC93} = {30CF0330-4B09-4784-B499-46BED303810B} {28D04FA3-4DCC-4137-8ED4-9F6F1A815909} = {8D0EC628-350F-47FC-8B36-DD98B4B7CC93} {04265302-AF6B-4627-807C-DE9E1699D7C9} = {30CF0330-4B09-4784-B499-46BED303810B} + {9B363CC3-1F38-409B-8E0D-E2C13019902A} = {30CF0330-4B09-4784-B499-46BED303810B} + {D620FC48-5B8E-4010-86DE-20628364AA6E} = {9B363CC3-1F38-409B-8E0D-E2C13019902A} + {53F9BF60-127F-4F86-9B33-34445BFA512D} = {9B363CC3-1F38-409B-8E0D-E2C13019902A} + {89D15E59-B0BF-4525-99FA-B61BA8451241} = {9B363CC3-1F38-409B-8E0D-E2C13019902A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {11A771DA-B728-445E-8A88-AE1C84C3B3A6} diff --git a/src/modules/io/Elsa.IO.Compression/Activities/CreateZipArchive.cs b/src/modules/io/Elsa.IO.Compression/Activities/CreateZipArchive.cs new file mode 100644 index 00000000..fcc612c9 --- /dev/null +++ b/src/modules/io/Elsa.IO.Compression/Activities/CreateZipArchive.cs @@ -0,0 +1,208 @@ +using System.IO.Compression; +using System.Text.Json.Serialization; +using Elsa.Extensions; +using Elsa.IO.Contracts; +using Elsa.IO.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; +using Microsoft.Extensions.Logging; + +namespace Elsa.IO.Compression.Activities; + +/// +/// Creates a ZIP archive from a collection of entries. +/// +[Activity("Elsa", "Compression", "Creates a ZIP archive from a collection of entries.", DisplayName = "Create Zip Archive")] +public class CreateZipArchive : CodeActivity +{ + private const string DefaultArchiveName = "archive.zip"; + private const string ZipExtension = ".zip"; + private const string DefaultEntryNameFormat = "entry_{0}"; + + /// + [JsonConstructor] + public CreateZipArchive(string? source = null, int? line = null) : base(source, line) + { + } + + /// + /// The entries to include in the ZIP archive. Can be byte[], Stream, file path, file URL, base64 string, ZipEntry objects, or arrays of these types. + /// + [Input( + Description = "The entries to include in the ZIP archive. Can be byte[], Stream, file path, file URL, base64 string, ZipEntry objects, or arrays of these types", + UIHint = InputUIHints.MultiLine + )] + public Input Entries { get; set; } = null!; + + /// + /// The compression level for the Zip Entries. Default is Optimal + /// + [Input( + Description = "The compression level for the Zip Entries. Default is Optimal", + UIHint = InputUIHints.DropDown + )] + public Input CompressionLevel { get; set; } = new(System.IO.Compression.CompressionLevel.Optimal); + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var entriesInput = Entries.Get(context); + var resolver = context.GetRequiredService(); + var logger = context.GetRequiredService>(); + + var entries = ParseEntries(entriesInput); + + var zipStream = await CreateZipStreamFromEntries(entries, resolver, context, logger); + + Result.Set(context, zipStream); + } + + private static IEnumerable ParseEntries(object? entriesInput) + { + return entriesInput switch + { + null => [], + IEnumerable enumerable => enumerable, + Array array => array.Cast(), + _ => [entriesInput] + }; + } + + private async Task CreateZipStreamFromEntries( + IEnumerable entries, + IContentResolver resolver, + ActivityExecutionContext context, + ILogger logger) + { + var zipStream = new MemoryStream(); + + try + { + using var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Update, leaveOpen: true); + var entryIndex = 0; + + var compressionLevel = CompressionLevel.Get(context); + foreach (var entryContent in entries) + { + try + { + await ProcessZipEntry(entryContent, zipArchive, resolver, context, entryIndex, compressionLevel); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to add entry {EntryIndex} to ZIP archive. Reason: {ExceptionMessage}", + entryIndex, ex.Message); + } + entryIndex++; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create ZIP archive"); + await zipStream.DisposeAsync(); + throw; + } + + // Reset stream position for reading + zipStream.Position = 0; + return zipStream; + } + + /// + /// Processes a single zip entry and adds it to the archive. + /// + private static async Task ProcessZipEntry( + object entryContent, + ZipArchive zipArchive, + IContentResolver resolver, + ActivityExecutionContext context, + int entryIndex, + CompressionLevel compressionLevel) + { + var binaryContent = await resolver.ResolveAsync(entryContent, context.CancellationToken); + + var entryName = binaryContent.Name?.GetNameAndExtension() + ?? string.Format(DefaultEntryNameFormat, entryIndex + 1); + + // Get a unique name following Windows convention + entryName = GetUniqueEntryName(zipArchive, entryName); + + var archiveEntry = zipArchive.CreateEntry(entryName, compressionLevel); + + await using var entryStream = archiveEntry.Open(); + await binaryContent.Stream.CopyToAsync(entryStream, context.CancellationToken); + await entryStream.FlushAsync(context.CancellationToken); + + if (entryContent is not Stream) + { + await binaryContent.Stream.DisposeAsync(); + } + } + + private static string GetUniqueEntryName(ZipArchive zipArchive, string originalName) + { + var filenameWithoutExtension = Path.GetFileNameWithoutExtension(originalName); + var extension = Path.GetExtension(originalName); + + var originalExists = false; + var highestIndex = 0; + + foreach (var entry in zipArchive.Entries) + { + if (!entry.Name.Equals(originalName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + originalExists = true; + + var entryNameWithoutExtension = Path.GetFileNameWithoutExtension(entry.Name); + var entryExtension = Path.GetExtension(entry.Name); + + // Only process entries with the same extension + if (!entryExtension.Equals(extension, StringComparison.OrdinalIgnoreCase)) + continue; + + // Check if this entry follows our naming pattern + highestIndex = HighestEntryNameIndex(entryNameWithoutExtension, filenameWithoutExtension, highestIndex); + } + + if (!originalExists) + { + return originalName; + } + + return $"{filenameWithoutExtension}({highestIndex + 1}){extension}"; + } + + private static int HighestEntryNameIndex(string entryNameWithoutExtension, string filenameWithoutExtension, + int highestIndex) + { + if (!entryNameWithoutExtension.StartsWith(filenameWithoutExtension, StringComparison.OrdinalIgnoreCase) || + entryNameWithoutExtension.Length <= filenameWithoutExtension.Length || + entryNameWithoutExtension[filenameWithoutExtension.Length] != '(') + { + return highestIndex; + } + + // Extract the number between parentheses + var closingParenIndex = entryNameWithoutExtension.LastIndexOf(')'); + if (closingParenIndex <= filenameWithoutExtension.Length + 1) + { + return highestIndex; + } + + var indexStr = entryNameWithoutExtension.Substring( + filenameWithoutExtension.Length + 1, + closingParenIndex - filenameWithoutExtension.Length - 1); + + if (int.TryParse(indexStr, out var index)) + { + highestIndex = Math.Max(highestIndex, index); + } + + return highestIndex; + } +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO.Compression/Common/Constants.cs b/src/modules/io/Elsa.IO.Compression/Common/Constants.cs new file mode 100644 index 00000000..70e8ee5b --- /dev/null +++ b/src/modules/io/Elsa.IO.Compression/Common/Constants.cs @@ -0,0 +1,6 @@ +namespace Elsa.IO.Compression.Common; + +public static class Constants +{ + public const float ZipEntryStrategyPriority = 0.5f; +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO.Compression/Elsa.IO.Compression.csproj b/src/modules/io/Elsa.IO.Compression/Elsa.IO.Compression.csproj new file mode 100644 index 00000000..8d50533b --- /dev/null +++ b/src/modules/io/Elsa.IO.Compression/Elsa.IO.Compression.csproj @@ -0,0 +1,24 @@ + + + + + Provides compression and archiving activities for Elsa Workflows. + + elsa module compression zip archive workflows + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/io/Elsa.IO.Compression/Extensions/ModuleExtensions.cs b/src/modules/io/Elsa.IO.Compression/Extensions/ModuleExtensions.cs new file mode 100644 index 00000000..95038252 --- /dev/null +++ b/src/modules/io/Elsa.IO.Compression/Extensions/ModuleExtensions.cs @@ -0,0 +1,20 @@ +using Elsa.IO.Compression.Features; +using Elsa.Features.Services; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +/// +/// Provides extensions to install the feature. +/// +public static class ModuleExtensions +{ + /// + /// Install the feature. + /// + public static IModule UseCompression(this IModule module, Action? configure = default) + { + module.Configure(configure); + return module; + } +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO.Compression/Features/CompressionFeature.cs b/src/modules/io/Elsa.IO.Compression/Features/CompressionFeature.cs new file mode 100644 index 00000000..17e5d6e2 --- /dev/null +++ b/src/modules/io/Elsa.IO.Compression/Features/CompressionFeature.cs @@ -0,0 +1,33 @@ +using Elsa.Extensions; +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Elsa.IO.Compression.Models; +using Elsa.IO.Compression.Services.Strategies; +using Elsa.IO.Features; +using Elsa.IO.Services.Strategies; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.IO.Compression.Features; + +/// +/// Configures compression activities and services. +/// +[UsedImplicitly] +[DependsOn(typeof(IOFeature))] +public class CompressionFeature(IModule module) : FeatureBase(module) +{ + /// + public override void Configure() + { + Module.AddActivitiesFrom(); + Module.AddVariableTypeAndAlias("ZipEntry", "Compression"); + } + + /// + public override void Apply() + { + Services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO.Compression/FodyWeavers.xml b/src/modules/io/Elsa.IO.Compression/FodyWeavers.xml new file mode 100644 index 00000000..00e1d9a1 --- /dev/null +++ b/src/modules/io/Elsa.IO.Compression/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/io/Elsa.IO.Compression/Models/ZipEntry.cs b/src/modules/io/Elsa.IO.Compression/Models/ZipEntry.cs new file mode 100644 index 00000000..87f6de9a --- /dev/null +++ b/src/modules/io/Elsa.IO.Compression/Models/ZipEntry.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; + +namespace Elsa.IO.Compression.Models; + +/// +/// Represents a zip entry with content and metadata. +/// +/// The content of the zip entry. Can be byte[], Stream, file path, file URL, or base64 string. +/// The name of the entry in the zip archive. +[UsedImplicitly] +public record ZipEntry(object Content, string? EntryName = null); \ No newline at end of file diff --git a/src/modules/io/Elsa.IO.Compression/Services/Strategies/ZipEntryContentStrategy.cs b/src/modules/io/Elsa.IO.Compression/Services/Strategies/ZipEntryContentStrategy.cs new file mode 100644 index 00000000..7395f641 --- /dev/null +++ b/src/modules/io/Elsa.IO.Compression/Services/Strategies/ZipEntryContentStrategy.cs @@ -0,0 +1,44 @@ +using Elsa.IO.Compression.Common; +using Elsa.IO.Compression.Models; +using Elsa.IO.Contracts; +using Elsa.IO.Extensions; +using Elsa.IO.Models; +using Elsa.IO.Services.Strategies; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.IO.Compression.Services.Strategies; + +/// +/// Strategy for resolving ZipEntry content with proper entry names. +/// +public class ZipEntryContentStrategy(IServiceProvider serviceProvider) : IContentResolverStrategy +{ + /// + public float Priority => Constants.ZipEntryStrategyPriority; + + /// + public bool CanResolve(object content) => content is ZipEntry; + + /// + public async Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + var zipEntry = (ZipEntry)content; + + var resolver = serviceProvider.GetRequiredService(); + + var innerContent = await resolver.ResolveAsync(zipEntry.Content, cancellationToken); + + if (string.IsNullOrEmpty(zipEntry.EntryName)) + { + return innerContent; + } + + var innerContentName = innerContent.Name?.GetNameAndExtension(); + var innerContentExtension = Path.GetExtension(innerContentName); + innerContent.Name = !string.IsNullOrWhiteSpace(innerContentExtension) + ? Path.HasExtension(zipEntry.EntryName) ? zipEntry.EntryName : zipEntry.EntryName + innerContentExtension + : innerContent.Name; + + return innerContent; + } +} diff --git a/src/modules/io/Elsa.IO.Http/Common/Constants.cs b/src/modules/io/Elsa.IO.Http/Common/Constants.cs new file mode 100644 index 00000000..4b1bcf60 --- /dev/null +++ b/src/modules/io/Elsa.IO.Http/Common/Constants.cs @@ -0,0 +1,14 @@ +namespace Elsa.IO.Http.Common; + +public static class Constants +{ + /// + /// The name of the HTTP client used for IO operations. + /// + public const string IOFeatureHttpClient = "IOFeatureHttpClient"; + + public static class StrategyPriorities + { + public const float Uri = 2.5f; + } +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO.Http/Elsa.IO.Http.csproj b/src/modules/io/Elsa.IO.Http/Elsa.IO.Http.csproj new file mode 100644 index 00000000..3db15018 --- /dev/null +++ b/src/modules/io/Elsa.IO.Http/Elsa.IO.Http.csproj @@ -0,0 +1,24 @@ + + + + + Provides http capabilities to IO modules in Elsa Workflows. + + elsa module io http + + + + + + + + + + + + + + + + + diff --git a/src/modules/io/Elsa.IO.Http/Features/IOHttpFeature.cs b/src/modules/io/Elsa.IO.Http/Features/IOHttpFeature.cs new file mode 100644 index 00000000..f33ae57a --- /dev/null +++ b/src/modules/io/Elsa.IO.Http/Features/IOHttpFeature.cs @@ -0,0 +1,29 @@ +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Elsa.IO.Compression.Features; +using Elsa.IO.Features; +using Elsa.IO.Http.Common; +using Elsa.IO.Http.Services.Strategies; +using Elsa.IO.Services.Strategies; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.IO.Http.Features; + +/// +/// Configures HTTP-based IO services. +/// +[UsedImplicitly] +[DependsOn(typeof(IOFeature))] +[DependencyOf(typeof(CompressionFeature))] +public class IOHttpFeature(IModule module) : FeatureBase(module) +{ + /// + public override void Apply() + { + Services.AddHttpClient(Constants.IOFeatureHttpClient); + + Services.AddScoped(); + } +} diff --git a/src/modules/io/Elsa.IO.Http/FodyWeavers.xml b/src/modules/io/Elsa.IO.Http/FodyWeavers.xml new file mode 100644 index 00000000..00e1d9a1 --- /dev/null +++ b/src/modules/io/Elsa.IO.Http/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/io/Elsa.IO.Http/Services/Strategies/UrlContentStrategy.cs b/src/modules/io/Elsa.IO.Http/Services/Strategies/UrlContentStrategy.cs new file mode 100644 index 00000000..2803403d --- /dev/null +++ b/src/modules/io/Elsa.IO.Http/Services/Strategies/UrlContentStrategy.cs @@ -0,0 +1,77 @@ +using Elsa.IO.Extensions; +using Elsa.IO.Http.Common; +using Elsa.IO.Models; +using Elsa.IO.Services.Strategies; +using Microsoft.Extensions.Logging; + +namespace Elsa.IO.Http.Services.Strategies; + +/// +/// Strategy for handling URL content by downloading from HTTP/HTTPS URLs. +/// +public class UrlContentStrategy(ILogger logger, IHttpClientFactory httpClientFactory) : IContentResolverStrategy +{ + /// + public float Priority => Constants.StrategyPriorities.Uri; + + /// + public bool CanResolve(object content) => content is string str && (str.StartsWith("http://") || str.StartsWith("https://")); + + /// + public async Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + var url = (string)content; + + try + { + var httpClient = httpClientFactory.CreateClient(); + var response = await httpClient.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var filename = ExtractFilenameFromResponse(response, url); + var contentType = response.Content.Headers.ContentType?.MediaType; + + return new BinaryContent + { + Name = filename.GetNameAndExtension(contentType.GetExtensionFromContentType()), + Stream = stream + }; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to download file from URL: {Url}", url); + throw; + } + } + + /// + /// Extracts a filename from the HTTP response, either from Content-Disposition header or URL. + /// + private string ExtractFilenameFromResponse(HttpResponseMessage response, string url) + { + var filename = response.GetFilename(); + if (!string.IsNullOrWhiteSpace(filename)) + { + return filename; + } + + try + { + var uri = new Uri(url); + var path = uri.AbsolutePath; + filename = Path.GetFileName(path); + + if (!string.IsNullOrEmpty(filename) && Path.HasExtension((string?)filename)) + { + return filename; + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to extract filename from URL: {Url}", url); + } + + return "download"; + } +} diff --git a/src/modules/io/Elsa.IO/Common/Constants.cs b/src/modules/io/Elsa.IO/Common/Constants.cs new file mode 100644 index 00000000..4b1e4847 --- /dev/null +++ b/src/modules/io/Elsa.IO/Common/Constants.cs @@ -0,0 +1,38 @@ +namespace Elsa.IO.Common; + +/// +/// IO module constants. +/// +public static class Constants +{ + /// + /// Priorities for content resolver strategies. + /// + public static class StrategyPriorities + { + /// + /// Stream content priority. + /// + public const float Stream = 0.0f; + + /// + /// Byte array content priority. + /// + public const float ByteArray = 1.0f; + + /// + /// Base64 content priority. + /// + public const float Base64 = 2.0f; + + /// + /// File path content priority. + /// + public const float FilePath = 3.0f; + + /// + /// Text content priority. + /// + public const float Text = 100.0f; + } +} diff --git a/src/modules/io/Elsa.IO/Contracts/IContentResolver.cs b/src/modules/io/Elsa.IO/Contracts/IContentResolver.cs new file mode 100644 index 00000000..de365bff --- /dev/null +++ b/src/modules/io/Elsa.IO/Contracts/IContentResolver.cs @@ -0,0 +1,17 @@ +namespace Elsa.IO.Contracts; + +using Elsa.IO.Models; + +/// +/// Provides methods to resolve various content types to BinaryContent. +/// +public interface IContentResolver +{ + /// + /// Resolves arbitrary content to a BinaryContent object that includes the content stream and metadata. + /// + /// The content to resolve. Can be byte[], Stream, file path, file URL, base64 string, or plain text. + /// A cancellation token. + /// A BinaryContent object containing the content stream and associated metadata. + Task ResolveAsync(object content, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO/Elsa.IO.csproj b/src/modules/io/Elsa.IO/Elsa.IO.csproj new file mode 100644 index 00000000..8bc1b098 --- /dev/null +++ b/src/modules/io/Elsa.IO/Elsa.IO.csproj @@ -0,0 +1,21 @@ + + + + + Provides IO services for resolving various content types to streams. + + elsa module io content streams + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/io/Elsa.IO/Extensions/ContentTypeExtensions.cs b/src/modules/io/Elsa.IO/Extensions/ContentTypeExtensions.cs new file mode 100644 index 00000000..bd7a98f4 --- /dev/null +++ b/src/modules/io/Elsa.IO/Extensions/ContentTypeExtensions.cs @@ -0,0 +1,166 @@ +namespace Elsa.IO.Extensions; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +public static class ContentTypeExtensions +{ + private static readonly Dictionary MimeMapping = new(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary> ContentTypeToExtensionsMap = new(StringComparer.OrdinalIgnoreCase); + + static ContentTypeExtensions() + { + // Define all mappings in a single place + AddMapping(".txt", "text/plain"); + AddMapping(".html", "text/html"); + AddMapping(".htm", "text/html"); + AddMapping(".css", "text/css"); + AddMapping(".js", "application/javascript"); + AddMapping(".json", "application/json"); + AddMapping(".xml", "application/xml"); + AddMapping(".jpg", "image/jpeg"); + AddMapping(".jpeg", "image/jpeg"); + AddMapping(".png", "image/png"); + AddMapping(".gif", "image/gif"); + AddMapping(".svg", "image/svg+xml"); + AddMapping(".pdf", "application/pdf"); + AddMapping(".doc", "application/msword"); + AddMapping(".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + AddMapping(".xls", "application/vnd.ms-excel"); + AddMapping(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + AddMapping(".ppt", "application/vnd.ms-powerpoint"); + AddMapping(".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"); + AddMapping(".zip", "application/zip"); + AddMapping(".csv", "text/csv"); + } + private static void AddMapping(string extension, string contentType) + { + // Map extension to content type + MimeMapping[extension] = contentType; + // Map content type to extension(s) + if (!ContentTypeToExtensionsMap.TryGetValue(contentType, out var extensions)) + { + extensions = new(StringComparer.OrdinalIgnoreCase); + ContentTypeToExtensionsMap[contentType] = extensions; + } + + extensions.Add(extension); + } + public static string GetExtensionFromContentType(this string? contentType) + { + if (string.IsNullOrEmpty(contentType)) + return ".bin"; + + if (contentType.EndsWith("/pdf") || contentType == "application/pdf") + return ".pdf"; + + if (ContentTypeToExtensionsMap.TryGetValue(contentType, out var extensions) && extensions.Any()) + { + // Return the first extension for this content type + return extensions.First(); + } + + return DetermineExtensionFromMimeType(contentType); + } + public static string GetContentTypeFromExtension(this string filePath) + { + var extension = filePath.GetFileExtension(); + + return MimeMapping.GetValueOrDefault(extension, "application/octet-stream"); + } + + public static string GetNameAndExtension(this string fileName, string? extension = ".bin") + { + var currentExtension = fileName.GetFileExtension(); + if (!string.IsNullOrWhiteSpace(currentExtension)) + { + return fileName; + } + + return fileName + extension; + } + + public static string GetFileExtension(this string filePath) + { + return Path.GetExtension(filePath).ToLowerInvariant(); + } + + public static bool IsBase64String(this string s) + { + if (string.IsNullOrWhiteSpace(s)) + return false; + + s = s.Trim(); + + // Length must be divisible by 4 + if (s.Length % 4 != 0) + return false; + + // Check padding position and count + var paddingIndex = s.IndexOf('='); + + switch (paddingIndex) + { + // Padding cannot be at index 0 + case 0: + // Padding must be at the end + case > 0 when paddingIndex < s.Length - 2: + // All characters after first '=' must also be '=' + case > 0 when s[paddingIndex..].Any(c => c != '='): + return false; + } + + // Check for valid Base64 characters + for (var i = 0; i < paddingIndex; i++) + { + var c = s[i]; + var isValid = + c is >= 'A' and <= 'Z' || + c is >= 'a' and <= 'z' || + c is >= '0' and <= '9' || + c == '+' || c == '/'; + + if (!isValid) + return false; + } + + // Additional check for short strings that are just lowercase+numbers + // This catches "whatever" and similar false positives + if (s.Length <= 10 && s.All(c => char.IsLower(c) || char.IsDigit(c))) + return false; + + // Try actual decoding + try + { + _ = Convert.FromBase64String(s); + return true; + } + catch + { + return false; + } + } + + private static string DetermineExtensionFromMimeType(string mimeType) + { + if (mimeType.Contains("/pdf")) + return ".pdf"; + if (mimeType.Contains("image/")) + return ".img"; + if (mimeType.Contains("text/")) + return ".txt"; + if (mimeType.Contains("audio/")) + return ".audio"; + if (mimeType.Contains("video/")) + return ".video"; + + if (mimeType.StartsWith("file/") || mimeType.StartsWith("@file/")) + { + var extension = mimeType[(mimeType.IndexOf('/') + 1)..]; + if (!string.IsNullOrWhiteSpace(extension)) + return "." + extension; + } + + return ".bin"; + } +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO/Extensions/FilePathExtensions.cs b/src/modules/io/Elsa.IO/Extensions/FilePathExtensions.cs new file mode 100644 index 00000000..45267112 --- /dev/null +++ b/src/modules/io/Elsa.IO/Extensions/FilePathExtensions.cs @@ -0,0 +1,40 @@ +namespace Elsa.IO.Extensions; + +public static class FilePathExtensions +{ + public static string CleanFilePath(this string filePath) + { + // Clean up the path - trim quotes and whitespace that might come from copy-paste + filePath = filePath.Trim().Trim('"', '\''); + + // Replace backslashes with forward slashes on Unix/Mac systems + if (Path.DirectorySeparatorChar == '/') + { + filePath = filePath.Replace('\\', '/'); + } + + return filePath; + } + + /// + /// Gets the filename from the Content-Disposition header. + /// + public static string? GetFilename(this HttpResponseMessage response) + { + var dictionary = response.Headers.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.OrdinalIgnoreCase); + return dictionary.GetFilename(); + } + + /// + /// Gets the filename from the Content-Disposition header. + /// + public static string? GetFilename(this IDictionary headers) + { + if (!headers.TryGetValue("Content-Disposition", out var values)) + return null; + + var contentDispositionString = string.Join("", values); + var contentDisposition = new System.Net.Mime.ContentDisposition(contentDispositionString); + return contentDisposition.FileName; + } +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO/Extensions/ModuleExtensions.cs b/src/modules/io/Elsa.IO/Extensions/ModuleExtensions.cs new file mode 100644 index 00000000..6109e8f1 --- /dev/null +++ b/src/modules/io/Elsa.IO/Extensions/ModuleExtensions.cs @@ -0,0 +1,19 @@ +using Elsa.Extensions; +using Elsa.Features.Services; +using Elsa.IO.Features; + +namespace Elsa.IO.Extensions; + +/// +/// Provides extension methods for configuring IO services. +/// +public static class ModuleExtensions +{ + /// + /// Installs the IO module. + /// + public static IModule UseIO(this IModule module, Action? configure = null) + { + return module.Use(configure); + } +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO/Features/IOFeature.cs b/src/modules/io/Elsa.IO/Features/IOFeature.cs new file mode 100644 index 00000000..5eb2c952 --- /dev/null +++ b/src/modules/io/Elsa.IO/Features/IOFeature.cs @@ -0,0 +1,26 @@ +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using Elsa.IO.Contracts; +using Elsa.IO.Services; +using Elsa.IO.Services.Strategies; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.IO.Features; + +/// +/// A feature that installs IO services for resolving various content types to streams. +/// +public class IOFeature(IModule module) : FeatureBase(module) +{ + /// + public override void Apply() + { + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + + Services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO/FodyWeavers.xml b/src/modules/io/Elsa.IO/FodyWeavers.xml new file mode 100644 index 00000000..06ee7216 --- /dev/null +++ b/src/modules/io/Elsa.IO/FodyWeavers.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/io/Elsa.IO/Models/BinaryContent.cs b/src/modules/io/Elsa.IO/Models/BinaryContent.cs new file mode 100644 index 00000000..e84f6cfe --- /dev/null +++ b/src/modules/io/Elsa.IO/Models/BinaryContent.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.IO; +using Elsa.IO.Extensions; + +namespace Elsa.IO.Models; + +/// +/// Represents normalized binary content with metadata. +/// +public class BinaryContent +{ + /// + /// Gets or sets the name of the content. + /// + public string? Name { get; set; } + + /// + /// Gets the content type (MIME type) based on file extension. + /// + public string? ContentType => !string.IsNullOrWhiteSpace(Name) + ? Path.GetExtension(Name).GetContentTypeFromExtension() + : null; + + /// + /// Gets or sets optional metadata headers. + /// + public IDictionary Headers { get; set; } = new Dictionary(); + + /// + /// Gets or sets the content stream. + /// + public Stream Stream { get; init; } = null!; +} diff --git a/src/modules/io/Elsa.IO/Services/ContentResolver.cs b/src/modules/io/Elsa.IO/Services/ContentResolver.cs new file mode 100644 index 00000000..619efa77 --- /dev/null +++ b/src/modules/io/Elsa.IO/Services/ContentResolver.cs @@ -0,0 +1,34 @@ +using Elsa.IO.Contracts; +using Elsa.IO.Models; +using Elsa.IO.Services.Strategies; + +namespace Elsa.IO.Services; + +/// +/// Resolves various content types to BinaryContent using a strategy pattern. +/// +public class ContentResolver : IContentResolver +{ + private readonly IEnumerable _strategies; + + /// + /// Initializes a new instance of the class. + /// + public ContentResolver(IEnumerable strategies) + { + _strategies = strategies.OrderBy(s => s.Priority).ToList(); + } + + /// + public async Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + var strategy = _strategies.FirstOrDefault(s => s.CanResolve(content)); + + if (strategy == null) + { + throw new ArgumentException($"Unsupported content type: {content.GetType().Name}"); + } + + return await strategy.ResolveAsync(content, cancellationToken); + } +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO/Services/Strategies/Base64ContentStrategy.cs b/src/modules/io/Elsa.IO/Services/Strategies/Base64ContentStrategy.cs new file mode 100644 index 00000000..0deb29c4 --- /dev/null +++ b/src/modules/io/Elsa.IO/Services/Strategies/Base64ContentStrategy.cs @@ -0,0 +1,62 @@ +using Elsa.IO.Common; +using Elsa.IO.Extensions; +using Elsa.IO.Models; + +namespace Elsa.IO.Services.Strategies; + +/// +/// Strategy for handling base64 encoded content. +/// +public class Base64ContentStrategy : IContentResolverStrategy +{ + /// + public float Priority => Constants.StrategyPriorities.Base64; + + /// + public bool CanResolve(object content) + { + return content is string str && IsBase64String(str); + } + + /// + public Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + var str = content.ToString()!; + var extension = ".bin"; + string? name = null; + + if (IsUriDataBase64String(str)) + { + var dataUrlParts = str.Split(';'); + if (dataUrlParts.Length > 0 && dataUrlParts[0].StartsWith("data:")) + { + var contentType = dataUrlParts[0][5..]; + extension = contentType.GetExtensionFromContentType(); + + name = "data" + extension; + } + + str = str[(str.IndexOf("base64,", StringComparison.Ordinal) + 7)..]; + } + + var base64Bytes = Convert.FromBase64String(str); + var stream = new MemoryStream(base64Bytes); + + return Task.FromResult(new BinaryContent + { + Name = name?.GetNameAndExtension(extension) ?? "data.bin", + Stream = stream, + }); + } + + private static bool IsBase64String(string base64) + { + return IsUriDataBase64String(base64) + || base64.IsBase64String(); + } + + private static bool IsUriDataBase64String(string base64) + { + return base64.StartsWith("data:") && base64.Contains("base64"); + } +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO/Services/Strategies/ByteArrayContentStrategy.cs b/src/modules/io/Elsa.IO/Services/Strategies/ByteArrayContentStrategy.cs new file mode 100644 index 00000000..8b3505e2 --- /dev/null +++ b/src/modules/io/Elsa.IO/Services/Strategies/ByteArrayContentStrategy.cs @@ -0,0 +1,31 @@ +using Elsa.IO.Common; +using Elsa.IO.Models; + +namespace Elsa.IO.Services.Strategies; + +/// +/// Strategy for handling byte array content. +/// +public class ByteArrayContentStrategy : IContentResolverStrategy +{ + /// + public float Priority => Constants.StrategyPriorities.ByteArray; + + /// + public bool CanResolve(object content) => content is byte[]; + + /// + public Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + var bytes = (byte[])content; + var stream = new MemoryStream(bytes); + + var result = new BinaryContent + { + Stream = stream, + Name = "data.bin", + }; + + return Task.FromResult(result); + } +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO/Services/Strategies/FilePathContentStrategy.cs b/src/modules/io/Elsa.IO/Services/Strategies/FilePathContentStrategy.cs new file mode 100644 index 00000000..5f974ccc --- /dev/null +++ b/src/modules/io/Elsa.IO/Services/Strategies/FilePathContentStrategy.cs @@ -0,0 +1,89 @@ +using Elsa.IO.Common; +using Elsa.IO.Extensions; +using Elsa.IO.Models; + +namespace Elsa.IO.Services.Strategies; + +/// +/// Strategy for handling file path content by reading from the filesystem. +/// +public class FilePathContentStrategy : IContentResolverStrategy +{ + /// + public float Priority => Constants.StrategyPriorities.FilePath; + + /// + public bool CanResolve(object content) + { + if (content is not string filePath) + { + return false; + } + + filePath = filePath.CleanFilePath(); + + try + { + if (Path.IsPathRooted(filePath) && File.Exists(filePath)) + { + return true; + } + + var normalized = Path.GetFullPath(filePath); + if (File.Exists(normalized)) + { + return true; + } + + var combined = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), filePath)); + return File.Exists(combined); + } + catch (Exception) + { + return false; + } + } + + /// + public Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + try + { + var filePath = (string)content; + filePath = ResolveActualPath(filePath); + + var fileName = Path.GetFileName(filePath); + var fileStream = File.OpenRead(filePath); + + var result = new BinaryContent + { + Name = fileName.GetNameAndExtension(), + Stream = fileStream + }; + + return Task.FromResult(result); + } + catch (Exception ex) when (ex is not FileNotFoundException) + { + throw new FileNotFoundException($"Error opening file: {content}", content.ToString(), ex); + } + } + + private string ResolveActualPath(string filePath) + { + filePath = filePath.CleanFilePath(); + + if (Path.IsPathRooted(filePath) && File.Exists(filePath)) + return filePath; + + var normalized = Path.GetFullPath(filePath); + if (File.Exists(normalized)) + return normalized; + + var combined = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), filePath)); + if (File.Exists(combined)) + return combined; + + throw new FileNotFoundException($"Could not find file at path: {filePath}", filePath); + } +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO/Services/Strategies/IContentResolverStrategy.cs b/src/modules/io/Elsa.IO/Services/Strategies/IContentResolverStrategy.cs new file mode 100644 index 00000000..960cd9f3 --- /dev/null +++ b/src/modules/io/Elsa.IO/Services/Strategies/IContentResolverStrategy.cs @@ -0,0 +1,29 @@ +namespace Elsa.IO.Services.Strategies; + +using Elsa.IO.Models; + +/// +/// Defines a strategy for resolving specific content types to BinaryContent. +/// +public interface IContentResolverStrategy +{ + /// + /// The priority of the strategy. + /// + float Priority { get; } + + /// + /// Determines if this strategy can handle the specified content. + /// + /// The content to check. + /// True if this strategy can handle the content, false otherwise. + bool CanResolve(object content); + + /// + /// Resolves the content to a BinaryContent object that includes the content stream and metadata. + /// + /// The content to resolve. + /// A cancellation token. + /// A BinaryContent object containing the content stream and associated metadata. + Task ResolveAsync(object content, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO/Services/Strategies/StreamContentStrategy.cs b/src/modules/io/Elsa.IO/Services/Strategies/StreamContentStrategy.cs new file mode 100644 index 00000000..1494bbc7 --- /dev/null +++ b/src/modules/io/Elsa.IO/Services/Strategies/StreamContentStrategy.cs @@ -0,0 +1,37 @@ +using Elsa.IO.Common; +using Elsa.IO.Extensions; +using Elsa.IO.Models; + +namespace Elsa.IO.Services.Strategies; + +/// +/// Strategy for handling Stream content. +/// +public class StreamContentStrategy : IContentResolverStrategy +{ + /// + public float Priority => Constants.StrategyPriorities.Stream; + + /// + public bool CanResolve(object content) => content is Stream; + + /// + public Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + var stream = (Stream)content; + + string? name = null; + if (stream is FileStream fileStream) + { + name = Path.GetFileName(fileStream.Name); + } + + var result = new BinaryContent + { + Stream = stream, + Name = name?.GetNameAndExtension() ?? "data.bin", + }; + + return Task.FromResult(result); + } +} \ No newline at end of file diff --git a/src/modules/io/Elsa.IO/Services/Strategies/TextContentStrategy.cs b/src/modules/io/Elsa.IO/Services/Strategies/TextContentStrategy.cs new file mode 100644 index 00000000..cb5b6a40 --- /dev/null +++ b/src/modules/io/Elsa.IO/Services/Strategies/TextContentStrategy.cs @@ -0,0 +1,31 @@ +using System.Text; +using Elsa.IO.Common; +using Elsa.IO.Models; + +namespace Elsa.IO.Services.Strategies; + +/// +/// Strategy for handling plain text content by encoding as UTF-8. +/// +public class TextContentStrategy : IContentResolverStrategy +{ + /// + public float Priority => Constants.StrategyPriorities.Text; + + /// + public bool CanResolve(object content) => content is string; + + /// + public Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + var textContent = (string)content; + var textBytes = Encoding.UTF8.GetBytes(textContent); + var stream = new MemoryStream(textBytes); + + return Task.FromResult(new BinaryContent + { + Name = "text.txt", + Stream = stream + }); + } +} \ No newline at end of file