From c4451c4f40f843dcbba6b66e7c7a0b8ff45c9bae Mon Sep 17 00:00:00 2001 From: lukhipolito-nexxbiz Date: Mon, 30 Jun 2025 16:56:51 +0200 Subject: [PATCH] Feat/6732 zip archive activities (#6751) * Io module and content strategies * Restructuring the folders and projects * Create zip activity + shared content resolver * Fixing build on github.com * Update src/modules/Elsa.IO/Services/ContentResolver.cs explicit nullable field Co-authored-by: Sipke Schoorstra * QoL improvements * More QoL improvements + removing some logic duplications * Update src/modules/Elsa.IO.Compression/Activities/CreateZipArchive.cs avoiding duplicate extensions Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Assigning file path correctly to the variable * More QoL changes, new Elsa.IO.Http module, more modular content strategy * Addresing QoL review comments + Correct extension handling --------- Co-authored-by: lucas.hipolito Co-authored-by: Sipke Schoorstra Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Elsa.sln | 53 +++++-- .../Elsa.ServerAndStudio.Web.csproj | 2 + src/apps/Elsa.ServerAndStudio.Web/Program.cs | 3 + ...Feature.cs => StringCompressionFeature.cs} | 2 +- .../Elsa.Http/Extensions/HeadersExtensions.cs | 2 + .../Activities/CreateZipArchive.cs | 140 ++++++++++++++++++ .../Elsa.IO.Compression/Common/Constants.cs | 6 + .../Elsa.IO.Compression.csproj | 23 +++ .../Extensions/ModuleExtensions.cs | 20 +++ .../Features/CompressionFeature.cs | 33 +++++ .../Elsa.IO.Compression/FodyWeavers.xml | 3 + .../Elsa.IO.Compression/Models/ZipEntry.cs | 11 ++ .../Strategies/ZipEntryContentStrategy.cs | 44 ++++++ src/modules/Elsa.IO.Http/Common/Constants.cs | 14 ++ src/modules/Elsa.IO.Http/Elsa.IO.Http.csproj | 21 +++ .../Elsa.IO.Http/Features/IOHttpFeature.cs | 29 ++++ src/modules/Elsa.IO.Http/FodyWeavers.xml | 3 + .../Services/Strategies/UrlContentStrategy.cs | 78 ++++++++++ src/modules/Elsa.IO/Common/Constants.cs | 38 +++++ .../Elsa.IO/Contracts/IContentResolver.cs | 17 +++ src/modules/Elsa.IO/Elsa.IO.csproj | 21 +++ .../Extensions/ContentTypeExtensions.cs | 110 ++++++++++++++ .../Elsa.IO/Extensions/FilePathExtensions.cs | 18 +++ .../Elsa.IO/Extensions/ModuleExtensions.cs | 19 +++ src/modules/Elsa.IO/Features/IOFeature.cs | 26 ++++ src/modules/Elsa.IO/FodyWeavers.xml | 1 + src/modules/Elsa.IO/Models/BinaryContent.cs | 33 +++++ .../Elsa.IO/Services/ContentResolver.cs | 34 +++++ .../Strategies/Base64ContentStrategy.cs | 67 +++++++++ .../Strategies/ByteArrayContentStrategy.cs | 31 ++++ .../Strategies/FilePathContentStrategy.cs | 89 +++++++++++ .../Strategies/IContentResolverStrategy.cs | 29 ++++ .../Strategies/StreamContentStrategy.cs | 37 +++++ .../Strategies/TextContentStrategy.cs | 31 ++++ .../ConfigureEngineWithVariableTypes.cs | 24 +++ .../Features/WorkflowManagementFeature.cs | 2 +- 36 files changed, 1098 insertions(+), 16 deletions(-) rename src/modules/Elsa.Common/Features/{CompressionFeature.cs => StringCompressionFeature.cs} (87%) create mode 100644 src/modules/Elsa.IO.Compression/Activities/CreateZipArchive.cs create mode 100644 src/modules/Elsa.IO.Compression/Common/Constants.cs create mode 100644 src/modules/Elsa.IO.Compression/Elsa.IO.Compression.csproj create mode 100644 src/modules/Elsa.IO.Compression/Extensions/ModuleExtensions.cs create mode 100644 src/modules/Elsa.IO.Compression/Features/CompressionFeature.cs create mode 100644 src/modules/Elsa.IO.Compression/FodyWeavers.xml create mode 100644 src/modules/Elsa.IO.Compression/Models/ZipEntry.cs create mode 100644 src/modules/Elsa.IO.Compression/Services/Strategies/ZipEntryContentStrategy.cs create mode 100644 src/modules/Elsa.IO.Http/Common/Constants.cs create mode 100644 src/modules/Elsa.IO.Http/Elsa.IO.Http.csproj create mode 100644 src/modules/Elsa.IO.Http/Features/IOHttpFeature.cs create mode 100644 src/modules/Elsa.IO.Http/FodyWeavers.xml create mode 100644 src/modules/Elsa.IO.Http/Services/Strategies/UrlContentStrategy.cs create mode 100644 src/modules/Elsa.IO/Common/Constants.cs create mode 100644 src/modules/Elsa.IO/Contracts/IContentResolver.cs create mode 100644 src/modules/Elsa.IO/Elsa.IO.csproj create mode 100644 src/modules/Elsa.IO/Extensions/ContentTypeExtensions.cs create mode 100644 src/modules/Elsa.IO/Extensions/FilePathExtensions.cs create mode 100644 src/modules/Elsa.IO/Extensions/ModuleExtensions.cs create mode 100644 src/modules/Elsa.IO/Features/IOFeature.cs create mode 100644 src/modules/Elsa.IO/FodyWeavers.xml create mode 100644 src/modules/Elsa.IO/Models/BinaryContent.cs create mode 100644 src/modules/Elsa.IO/Services/ContentResolver.cs create mode 100644 src/modules/Elsa.IO/Services/Strategies/Base64ContentStrategy.cs create mode 100644 src/modules/Elsa.IO/Services/Strategies/ByteArrayContentStrategy.cs create mode 100644 src/modules/Elsa.IO/Services/Strategies/FilePathContentStrategy.cs create mode 100644 src/modules/Elsa.IO/Services/Strategies/IContentResolverStrategy.cs create mode 100644 src/modules/Elsa.IO/Services/Strategies/StreamContentStrategy.cs create mode 100644 src/modules/Elsa.IO/Services/Strategies/TextContentStrategy.cs create mode 100644 src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariableTypes.cs diff --git a/Elsa.sln b/Elsa.sln index 373517434..d171f43ce 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.7.34003.232 @@ -244,8 +245,8 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Resilience.IntegrationTests", "test\integration\Elsa.Resilience.IntegrationTests\Elsa.Resilience.IntegrationTests.csproj", "{832675FA-C597-4554-AE6B-18F189198A1F}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "apps", "apps", "{D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1}" - ProjectSection(SolutionItems) = preProject - src\apps\Directory.Build.props = src\apps\Directory.Build.props + ProjectSection(SolutionItems) = preProject + src\apps\Directory.Build.props = src\apps\Directory.Build.props EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Server.Web", "src\apps\Elsa.Server.Web\Elsa.Server.Web.csproj", "{5ADDDFB1-E59B-4097-97B7-8C24E2D60463}" @@ -405,6 +406,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Resilience.Core", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Resilience", "src\modules\Elsa.Resilience\Elsa.Resilience.csproj", "{E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "io", "io", "{7FD1FD1E-5778-4065-AAA5-1F878129EF77}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.IO", "src\modules\Elsa.IO\Elsa.IO.csproj", "{EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.IO.Compression", "src\modules\Elsa.IO.Compression\Elsa.IO.Compression.csproj", "{9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.IO.Http", "src\modules\Elsa.IO.Http\Elsa.IO.Http.csproj", "{C583AF05-D517-4B7F-8955-6B61500ED3D8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -698,14 +707,14 @@ Global {99B171E6-0248-4402-836D-98947CD63772}.Release|Any CPU.ActiveCfg = Release|Any CPU {99B171E6-0248-4402-836D-98947CD63772}.Release|Any CPU.Build.0 = Release|Any CPU {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Release|Any CPU.Build.0 = Release|Any CPU - {832675FA-C597-4554-AE6B-18F189198A1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {832675FA-C597-4554-AE6B-18F189198A1F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {832675FA-C597-4554-AE6B-18F189198A1F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {832675FA-C597-4554-AE6B-18F189198A1F}.Release|Any CPU.Build.0 = Release|Any CPU - {5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Release|Any CPU.Build.0 = Release|Any CPU + {832675FA-C597-4554-AE6B-18F189198A1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {832675FA-C597-4554-AE6B-18F189198A1F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {832675FA-C597-4554-AE6B-18F189198A1F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {832675FA-C597-4554-AE6B-18F189198A1F}.Release|Any CPU.Build.0 = Release|Any CPU + {5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Debug|Any CPU.Build.0 = Debug|Any CPU {5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Release|Any CPU.ActiveCfg = Release|Any CPU {5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Release|Any CPU.Build.0 = Release|Any CPU @@ -841,6 +850,18 @@ Global {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Debug|Any CPU.Build.0 = Debug|Any CPU {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Release|Any CPU.ActiveCfg = Release|Any CPU {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Release|Any CPU.Build.0 = Release|Any CPU + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Release|Any CPU.Build.0 = Release|Any CPU + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Release|Any CPU.Build.0 = Release|Any CPU + {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -935,10 +956,10 @@ Global {94A61AD7-2A2B-40DB-81F3-C59D596958A8} = {C6658DE0-2B2F-47F0-BB61-2CA66D435C09} {4B598AF7-BD7D-4544-A274-2CDDD98F4167} = {C6658DE0-2B2F-47F0-BB61-2CA66D435C09} {99B171E6-0248-4402-836D-98947CD63772} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} - {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} - {832675FA-C597-4554-AE6B-18F189198A1F} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} - {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} = {61017E64-6D00-49CB-9E81-5002DC8F7D5F} - {5ADDDFB1-E59B-4097-97B7-8C24E2D60463} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} + {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} + {832675FA-C597-4554-AE6B-18F189198A1F} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} + {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} = {61017E64-6D00-49CB-9E81-5002DC8F7D5F} + {5ADDDFB1-E59B-4097-97B7-8C24E2D60463} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} {97C7E531-9D5F-43FD-AA19-BF24DA13B612} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {65F2AD97-3ECF-4BA6-8AAA-1E5882FDCE68} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} {690B0274-291F-4D9E-BA76-54EFF7D3E4BC} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} @@ -990,6 +1011,10 @@ Global {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {70593549-8B26-4D63-9857-6BA8BB3E31DB} = {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6} = {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} + {7FD1FD1E-5778-4065-AAA5-1F878129EF77} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77} + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77} + {C583AF05-D517-4B7F-8955-6B61500ED3D8} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj b/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj index 3ea5601f3..707e9f6a5 100644 --- a/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj +++ b/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj @@ -14,6 +14,7 @@ + @@ -28,6 +29,7 @@ + diff --git a/src/apps/Elsa.ServerAndStudio.Web/Program.cs b/src/apps/Elsa.ServerAndStudio.Web/Program.cs index 01b787cad..2987cf105 100644 --- a/src/apps/Elsa.ServerAndStudio.Web/Program.cs +++ b/src/apps/Elsa.ServerAndStudio.Web/Program.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using WebhooksCore.Options; using Elsa.Connections.Middleware; +using Elsa.IO.Http.Features; using Proto.Persistence.Sqlite; const bool useMassTransit = true; @@ -140,6 +141,8 @@ services .UseEmail(email => email.ConfigureOptions = options => configuration.GetSection("Smtp").Bind(options)) .UseWebhooks(webhooks => webhooks.ConfigureSinks = options => builder.Configuration.GetSection("Webhooks:Sinks").Bind(options)) .UseWorkflowsApi() + .UseCompression() + .Use() .AddActivitiesFrom() .AddWorkflowsFrom(); diff --git a/src/modules/Elsa.Common/Features/CompressionFeature.cs b/src/modules/Elsa.Common/Features/StringCompressionFeature.cs similarity index 87% rename from src/modules/Elsa.Common/Features/CompressionFeature.cs rename to src/modules/Elsa.Common/Features/StringCompressionFeature.cs index f6b5955c1..5b2c2b0c6 100644 --- a/src/modules/Elsa.Common/Features/CompressionFeature.cs +++ b/src/modules/Elsa.Common/Features/StringCompressionFeature.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.DependencyInjection; namespace Elsa.Common.Features; [UsedImplicitly] -public class CompressionFeature(IModule module) : FeatureBase(module) +public class StringCompressionFeature(IModule module) : FeatureBase(module) { public override void Apply() { diff --git a/src/modules/Elsa.Http/Extensions/HeadersExtensions.cs b/src/modules/Elsa.Http/Extensions/HeadersExtensions.cs index 5c144c002..10f0b53c7 100644 --- a/src/modules/Elsa.Http/Extensions/HeadersExtensions.cs +++ b/src/modules/Elsa.Http/Extensions/HeadersExtensions.cs @@ -1,4 +1,6 @@ // ReSharper disable once CheckNamespace +using Elsa.Http; + namespace Elsa.Extensions; /// diff --git a/src/modules/Elsa.IO.Compression/Activities/CreateZipArchive.cs b/src/modules/Elsa.IO.Compression/Activities/CreateZipArchive.cs new file mode 100644 index 000000000..c7eee4d13 --- /dev/null +++ b/src/modules/Elsa.IO.Compression/Activities/CreateZipArchive.cs @@ -0,0 +1,140 @@ +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.Create, 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); + + 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(); + } + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO.Compression/Common/Constants.cs b/src/modules/Elsa.IO.Compression/Common/Constants.cs new file mode 100644 index 000000000..f9e7a627e --- /dev/null +++ b/src/modules/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/Elsa.IO.Compression/Elsa.IO.Compression.csproj b/src/modules/Elsa.IO.Compression/Elsa.IO.Compression.csproj new file mode 100644 index 000000000..f1b1d9145 --- /dev/null +++ b/src/modules/Elsa.IO.Compression/Elsa.IO.Compression.csproj @@ -0,0 +1,23 @@ + + + + + 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/Elsa.IO.Compression/Extensions/ModuleExtensions.cs b/src/modules/Elsa.IO.Compression/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..e471e4c49 --- /dev/null +++ b/src/modules/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/Elsa.IO.Compression/Features/CompressionFeature.cs b/src/modules/Elsa.IO.Compression/Features/CompressionFeature.cs new file mode 100644 index 000000000..04bfb6ce4 --- /dev/null +++ b/src/modules/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/Elsa.IO.Compression/FodyWeavers.xml b/src/modules/Elsa.IO.Compression/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.IO.Compression/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.IO.Compression/Models/ZipEntry.cs b/src/modules/Elsa.IO.Compression/Models/ZipEntry.cs new file mode 100644 index 000000000..03986e6ce --- /dev/null +++ b/src/modules/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/Elsa.IO.Compression/Services/Strategies/ZipEntryContentStrategy.cs b/src/modules/Elsa.IO.Compression/Services/Strategies/ZipEntryContentStrategy.cs new file mode 100644 index 000000000..14757faa1 --- /dev/null +++ b/src/modules/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) + ? zipEntry.EntryName + innerContentExtension + : innerContent.Name; + + return innerContent; + } +} diff --git a/src/modules/Elsa.IO.Http/Common/Constants.cs b/src/modules/Elsa.IO.Http/Common/Constants.cs new file mode 100644 index 000000000..c785d5ee7 --- /dev/null +++ b/src/modules/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/Elsa.IO.Http/Elsa.IO.Http.csproj b/src/modules/Elsa.IO.Http/Elsa.IO.Http.csproj new file mode 100644 index 000000000..eca9c5e79 --- /dev/null +++ b/src/modules/Elsa.IO.Http/Elsa.IO.Http.csproj @@ -0,0 +1,21 @@ + + + + + Provides http capabilities to IO modules in Elsa Workflows. + + elsa module io http + + + + + + + + + + + + + + diff --git a/src/modules/Elsa.IO.Http/Features/IOHttpFeature.cs b/src/modules/Elsa.IO.Http/Features/IOHttpFeature.cs new file mode 100644 index 000000000..90916619f --- /dev/null +++ b/src/modules/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/Elsa.IO.Http/FodyWeavers.xml b/src/modules/Elsa.IO.Http/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.IO.Http/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.IO.Http/Services/Strategies/UrlContentStrategy.cs b/src/modules/Elsa.IO.Http/Services/Strategies/UrlContentStrategy.cs new file mode 100644 index 000000000..5aece7553 --- /dev/null +++ b/src/modules/Elsa.IO.Http/Services/Strategies/UrlContentStrategy.cs @@ -0,0 +1,78 @@ +using Elsa.Extensions; +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(filename)) + { + return filename; + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to extract filename from URL: {Url}", url); + } + + return "download"; + } +} diff --git a/src/modules/Elsa.IO/Common/Constants.cs b/src/modules/Elsa.IO/Common/Constants.cs new file mode 100644 index 000000000..6444fc89c --- /dev/null +++ b/src/modules/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/Elsa.IO/Contracts/IContentResolver.cs b/src/modules/Elsa.IO/Contracts/IContentResolver.cs new file mode 100644 index 000000000..1ae60ca14 --- /dev/null +++ b/src/modules/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/Elsa.IO/Elsa.IO.csproj b/src/modules/Elsa.IO/Elsa.IO.csproj new file mode 100644 index 000000000..f7dc3b034 --- /dev/null +++ b/src/modules/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/Elsa.IO/Extensions/ContentTypeExtensions.cs b/src/modules/Elsa.IO/Extensions/ContentTypeExtensions.cs new file mode 100644 index 000000000..0bb796073 --- /dev/null +++ b/src/modules/Elsa.IO/Extensions/ContentTypeExtensions.cs @@ -0,0 +1,110 @@ +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(); + } + + 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/Elsa.IO/Extensions/FilePathExtensions.cs b/src/modules/Elsa.IO/Extensions/FilePathExtensions.cs new file mode 100644 index 000000000..cb8456f57 --- /dev/null +++ b/src/modules/Elsa.IO/Extensions/FilePathExtensions.cs @@ -0,0 +1,18 @@ +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; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO/Extensions/ModuleExtensions.cs b/src/modules/Elsa.IO/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..dcd9b4bb0 --- /dev/null +++ b/src/modules/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/Elsa.IO/Features/IOFeature.cs b/src/modules/Elsa.IO/Features/IOFeature.cs new file mode 100644 index 000000000..52cb72312 --- /dev/null +++ b/src/modules/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/Elsa.IO/FodyWeavers.xml b/src/modules/Elsa.IO/FodyWeavers.xml new file mode 100644 index 000000000..06ee72161 --- /dev/null +++ b/src/modules/Elsa.IO/FodyWeavers.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/Elsa.IO/Models/BinaryContent.cs b/src/modules/Elsa.IO/Models/BinaryContent.cs new file mode 100644 index 000000000..ba5c7d2b1 --- /dev/null +++ b/src/modules/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/Elsa.IO/Services/ContentResolver.cs b/src/modules/Elsa.IO/Services/ContentResolver.cs new file mode 100644 index 000000000..607bce68b --- /dev/null +++ b/src/modules/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/Elsa.IO/Services/Strategies/Base64ContentStrategy.cs b/src/modules/Elsa.IO/Services/Strategies/Base64ContentStrategy.cs new file mode 100644 index 000000000..a1d621c9b --- /dev/null +++ b/src/modules/Elsa.IO/Services/Strategies/Base64ContentStrategy.cs @@ -0,0 +1,67 @@ +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) + { + if (IsUriDataBase64String(base64)) + { + return true; + } + + var buffer = new Span(new byte[base64.Length]); + return Convert.TryFromBase64String(base64, buffer , out _); + } + + private static bool IsUriDataBase64String(string base64) + { + return base64.StartsWith("data:") && base64.Contains("base64"); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO/Services/Strategies/ByteArrayContentStrategy.cs b/src/modules/Elsa.IO/Services/Strategies/ByteArrayContentStrategy.cs new file mode 100644 index 000000000..71b5f1658 --- /dev/null +++ b/src/modules/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/Elsa.IO/Services/Strategies/FilePathContentStrategy.cs b/src/modules/Elsa.IO/Services/Strategies/FilePathContentStrategy.cs new file mode 100644 index 000000000..5f974cccf --- /dev/null +++ b/src/modules/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/Elsa.IO/Services/Strategies/IContentResolverStrategy.cs b/src/modules/Elsa.IO/Services/Strategies/IContentResolverStrategy.cs new file mode 100644 index 000000000..680ade5fb --- /dev/null +++ b/src/modules/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/Elsa.IO/Services/Strategies/StreamContentStrategy.cs b/src/modules/Elsa.IO/Services/Strategies/StreamContentStrategy.cs new file mode 100644 index 000000000..538f7bcd7 --- /dev/null +++ b/src/modules/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/Elsa.IO/Services/Strategies/TextContentStrategy.cs b/src/modules/Elsa.IO/Services/Strategies/TextContentStrategy.cs new file mode 100644 index 000000000..23e5992b8 --- /dev/null +++ b/src/modules/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 diff --git a/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariableTypes.cs b/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariableTypes.cs new file mode 100644 index 000000000..146bdb735 --- /dev/null +++ b/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariableTypes.cs @@ -0,0 +1,24 @@ +using Elsa.Extensions; +using Elsa.JavaScript.Notifications; +using Elsa.Mediator.Contracts; +using Elsa.Workflows.Management.Options; +using JetBrains.Annotations; +using Microsoft.Extensions.Options; + +namespace Elsa.JavaScript.Handlers; + +[UsedImplicitly] +public class ConfigureEngineWithWorkflowVariableTypes(IOptions options) + : INotificationHandler +{ + /// + public Task HandleAsync(EvaluatingJavaScript notification, CancellationToken cancellationToken) + { + var engine = notification.Engine; + foreach (var variableDescriptor in + options.Value.VariableDescriptors.Where(x => x.Type is { ContainsGenericParameters: false })) + engine.RegisterType(variableDescriptor.Type); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs index 76e53ea19..ff369a7c4 100644 --- a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs @@ -34,7 +34,7 @@ namespace Elsa.Workflows.Management.Features; /// /// Installs and configures the workflow management feature. /// -[DependsOn(typeof(CompressionFeature))] +[DependsOn(typeof(StringCompressionFeature))] [DependsOn(typeof(MediatorFeature))] [DependsOn(typeof(MemoryCacheFeature))] [DependsOn(typeof(SystemClockFeature))]