diff --git a/Directory.Packages.props b/Directory.Packages.props index 0f1e76edd..9fbc7b0bf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,101 +9,169 @@ 9.6.0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Elsa.sln b/Elsa.sln index 82e4dc8cf..0f2c195a9 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -1,4 +1,5 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 + +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.7.34003.232 MinimumVisualStudioVersion = 10.0.40219.1 @@ -132,9 +133,9 @@ 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 - EndProjectSection + 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}" EndProject @@ -236,6 +237,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Caching", "src\modules EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "http", "http", "{C55015F0-E9DF-4BF9-8131-D7539E938220}" 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 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Http", "src\modules\Elsa.Http\Elsa.Http.csproj", "{E0B22AB7-7CB5-6D17-562C-4A989DC61F8A}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.CSharp", "src\modules\Elsa.Expressions.CSharp\Elsa.Expressions.CSharp.csproj", "{16C3FBDE-A832-E3CD-5FBF-F51744D1F79B}" @@ -402,6 +411,74 @@ Global {D5720DBC-8C2B-42D5-9D9F-2FF6EAD4001C}.Debug|Any CPU.Build.0 = Debug|Any CPU {D5720DBC-8C2B-42D5-9D9F-2FF6EAD4001C}.Release|Any CPU.ActiveCfg = Release|Any CPU {D5720DBC-8C2B-42D5-9D9F-2FF6EAD4001C}.Release|Any CPU.Build.0 = Release|Any CPU + {BF934627-F531-44FB-BEC2-ECA801FF31E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF934627-F531-44FB-BEC2-ECA801FF31E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF934627-F531-44FB-BEC2-ECA801FF31E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF934627-F531-44FB-BEC2-ECA801FF31E7}.Release|Any CPU.Build.0 = Release|Any CPU + {FD3CD5A8-E9B3-467F-90EB-2B7D5B83F348}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD3CD5A8-E9B3-467F-90EB-2B7D5B83F348}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD3CD5A8-E9B3-467F-90EB-2B7D5B83F348}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD3CD5A8-E9B3-467F-90EB-2B7D5B83F348}.Release|Any CPU.Build.0 = Release|Any CPU + {3BED411B-79B5-4CCC-BD46-9549A427B908}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BED411B-79B5-4CCC-BD46-9549A427B908}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BED411B-79B5-4CCC-BD46-9549A427B908}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BED411B-79B5-4CCC-BD46-9549A427B908}.Release|Any CPU.Build.0 = Release|Any CPU + {6CC5FBC7-D3D7-4FE3-AD08-C67939BDB24D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6CC5FBC7-D3D7-4FE3-AD08-C67939BDB24D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6CC5FBC7-D3D7-4FE3-AD08-C67939BDB24D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6CC5FBC7-D3D7-4FE3-AD08-C67939BDB24D}.Release|Any CPU.Build.0 = Release|Any CPU + {FA5E857F-B173-4B5D-8049-B817A210DEF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA5E857F-B173-4B5D-8049-B817A210DEF5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA5E857F-B173-4B5D-8049-B817A210DEF5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA5E857F-B173-4B5D-8049-B817A210DEF5}.Release|Any CPU.Build.0 = Release|Any CPU + {A51F9683-DA9F-45E7-82DE-1E261ACD6D68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A51F9683-DA9F-45E7-82DE-1E261ACD6D68}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A51F9683-DA9F-45E7-82DE-1E261ACD6D68}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A51F9683-DA9F-45E7-82DE-1E261ACD6D68}.Release|Any CPU.Build.0 = Release|Any CPU + {75E08B0E-A41A-425C-9DCA-155505CE41CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {75E08B0E-A41A-425C-9DCA-155505CE41CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {75E08B0E-A41A-425C-9DCA-155505CE41CD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {75E08B0E-A41A-425C-9DCA-155505CE41CD}.Release|Any CPU.Build.0 = Release|Any CPU + {BD120D97-4862-469E-B2CF-4109F7F21C20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BD120D97-4862-469E-B2CF-4109F7F21C20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BD120D97-4862-469E-B2CF-4109F7F21C20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BD120D97-4862-469E-B2CF-4109F7F21C20}.Release|Any CPU.Build.0 = Release|Any CPU + {8A3443F3-D40E-44B9-9805-B4CB7A3F022C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A3443F3-D40E-44B9-9805-B4CB7A3F022C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A3443F3-D40E-44B9-9805-B4CB7A3F022C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A3443F3-D40E-44B9-9805-B4CB7A3F022C}.Release|Any CPU.Build.0 = Release|Any CPU + {FA2B2B44-7E3E-444B-A157-7526B376E9CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA2B2B44-7E3E-444B-A157-7526B376E9CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA2B2B44-7E3E-444B-A157-7526B376E9CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA2B2B44-7E3E-444B-A157-7526B376E9CA}.Release|Any CPU.Build.0 = Release|Any CPU + {DF0CA7E2-F5DF-40ED-91B6-748767084FC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF0CA7E2-F5DF-40ED-91B6-748767084FC7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF0CA7E2-F5DF-40ED-91B6-748767084FC7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF0CA7E2-F5DF-40ED-91B6-748767084FC7}.Release|Any CPU.Build.0 = Release|Any CPU + {DB20B168-CD90-41F2-BE08-3D8C6F142459}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB20B168-CD90-41F2-BE08-3D8C6F142459}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB20B168-CD90-41F2-BE08-3D8C6F142459}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB20B168-CD90-41F2-BE08-3D8C6F142459}.Release|Any CPU.Build.0 = Release|Any CPU + {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Release|Any CPU.Build.0 = Release|Any CPU + {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 {6F14B066-DF7B-2409-59D8-CCCA90A4974C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6F14B066-DF7B-2409-59D8-CCCA90A4974C}.Debug|Any CPU.Build.0 = Debug|Any CPU {6F14B066-DF7B-2409-59D8-CCCA90A4974C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -519,6 +596,13 @@ Global {66E2E2CF-967F-4564-89E8-F46FA973C99B} = {986E5482-0482-448C-B9E4-EC67A9474B85} {0A04B1FD-06C0-4271-A910-A08C263DBC44} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6} {9B80A705-2E31-4012-964A-83963DCDB384} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6} + {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} {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {6F14B066-DF7B-2409-59D8-CCCA90A4974C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {C55015F0-E9DF-4BF9-8131-D7539E938220} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} diff --git a/src/apps/Directory.Build.props b/src/apps/Directory.Build.props index d5f14e067..50f33a49d 100644 --- a/src/apps/Directory.Build.props +++ b/src/apps/Directory.Build.props @@ -8,10 +8,6 @@ $(NoWarn);CS0162;CS1591 - - 3.5.0-preview.1040 - - diff --git a/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj b/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj index 5541ab064..1b29a1ad7 100644 --- a/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj +++ b/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/apps/Elsa.ServerAndStudio.Web/Program.cs b/src/apps/Elsa.ServerAndStudio.Web/Program.cs index 2ea41f335..63ea982e3 100644 --- a/src/apps/Elsa.ServerAndStudio.Web/Program.cs +++ b/src/apps/Elsa.ServerAndStudio.Web/Program.cs @@ -65,6 +65,8 @@ services }; }) .UseWorkflowsApi() + .UseCompression() + .Use() .AddActivitiesFrom() .AddWorkflowsFrom(); }); diff --git a/src/common/Elsa.Features/Implementations/Module.cs b/src/common/Elsa.Features/Implementations/Module.cs index c64056133..4821e91d7 100644 --- a/src/common/Elsa.Features/Implementations/Module.cs +++ b/src/common/Elsa.Features/Implementations/Module.cs @@ -47,11 +47,13 @@ public class Module : IModule } /// - public T Configure(Action? configure = default) where T : class, IFeature - => Configure(module => (T)Activator.CreateInstance(typeof(T), module)!, configure); + public T Configure(Action? configure = null) where T : class, IFeature + { + return Configure(module => (T)Activator.CreateInstance(typeof(T), module)!, configure); + } /// - public T Configure(Func factory, Action? configure = default) where T : class, IFeature + public T Configure(Func factory, Action? configure = null) where T : class, IFeature { if (!_features.TryGetValue(typeof(T), out var feature)) { @@ -81,7 +83,7 @@ public class Module : IModule /// public IModule ConfigureHostedService(Type hostedServiceType, int priority = 0) { - _hostedServiceDescriptors.Add(new HostedServiceDescriptor(priority, hostedServiceType)); + _hostedServiceDescriptors.Add(new(priority, hostedServiceType)); return this; } @@ -121,7 +123,7 @@ public class Module : IModule var ns = "Elsa"; var displayName = type.GetCustomAttribute()?.DisplayName ?? name; var description = type.GetCustomAttribute()?.Description; - registry.Add(new FeatureDescriptor(name, ns, displayName, description)); + registry.Add(new(name, ns, displayName, description)); } Services.AddSingleton(registry); diff --git a/src/common/Elsa.Features/Services/IModule.cs b/src/common/Elsa.Features/Services/IModule.cs index 48d45795a..da070d3f6 100644 --- a/src/common/Elsa.Features/Services/IModule.cs +++ b/src/common/Elsa.Features/Services/IModule.cs @@ -31,12 +31,12 @@ public interface IModule /// /// Creates and configures a feature of the specified type. /// - T Configure(Action? configure = default) where T : class, IFeature; + T Configure(Action? configure = null) where T : class, IFeature; /// /// Creates and configures a feature of the specified type. /// - T Configure(Func factory, Action? configure = default) where T : class, IFeature; + T Configure(Func factory, Action? configure = null) where T : class, IFeature; /// /// Configures a using an optional priority to control in which order it will be registered with the service container. 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.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs b/src/modules/Elsa.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs new file mode 100644 index 000000000..927bc81b0 --- /dev/null +++ b/src/modules/Elsa.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Elsa.MongoDb.Serializers; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Models; +using Elsa.Workflows.Memory; +using JetBrains.Annotations; +using Microsoft.Extensions.Hosting; +using MongoDB.Bson.Serialization.Serializers; +using static MongoDB.Bson.Serialization.BsonSerializer; + +namespace Elsa.MongoDb.HostedServices; + +/// +/// A hosted service that configures and registers custom MongoDB serializers for various types. +/// +/// +/// This class implements and is responsible for registering serializers to handle +/// specific types such as , , , , +/// , , and . +/// It uses helper methods to register these serializers during the application's startup process. +/// +[UsedImplicitly] +public class ConfigureMongoDbSerializers(IPayloadSerializer payloadSerializer) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + TryRegisterSerializer(typeof(object), new PolymorphicSerializer()); + TryRegisterSerializer(typeof(Type), new TypeSerializer()); + TryRegisterSerializer(typeof(Variable), new VariableSerializer()); + TryRegisterSerializer(typeof(Version), new VersionSerializer()); + TryRegisterSerializer(typeof(JsonElement), new JsonElementSerializer()); + TryRegisterSerializer(typeof(JsonNode), new JsonNodeBsonConverter()); + TryRegisterSerializer(typeof(FlowScope), new FlowScopeSerializer(payloadSerializer)); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.MongoDb/Serializers/FlowScopeSerializer.cs b/src/modules/Elsa.MongoDb/Serializers/FlowScopeSerializer.cs new file mode 100644 index 000000000..f6d78e931 --- /dev/null +++ b/src/modules/Elsa.MongoDb/Serializers/FlowScopeSerializer.cs @@ -0,0 +1,52 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Models; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; + +namespace Elsa.MongoDb.Serializers; + +/// +/// Serializes a . +/// +public class FlowScopeSerializer(IPayloadSerializer payloadSerializer) : IBsonSerializer +{ + /// + public Type ValueType => typeof(FlowScope); + + void IBsonSerializer.Serialize(BsonSerializationContext context, BsonSerializationArgs args, object value) => Serialize(context, args, (FlowScope)value); + object IBsonSerializer.Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) => Deserialize(context, args); + + /// + public void Serialize(BsonSerializationContext context, BsonSerializationArgs args, FlowScope? value) + { + if (value is null) + context.Writer.WriteNull(); + else + { + var json = payloadSerializer.Serialize(value); + context.Writer.WriteString(json); + } + } + + /// + public FlowScope Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var reader = context.Reader; + var bsonType = reader.GetCurrentBsonType(); + + if (bsonType == BsonType.Null) + { + reader.ReadNull(); + return new(); + } + + if(bsonType == BsonType.String) + { + var json = context.Reader.ReadString(); + + return string.IsNullOrEmpty(json) ? new() : payloadSerializer.Deserialize(json); + } + + return new(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs index 57c01a6e6..f025ef74d 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs @@ -50,8 +50,8 @@ public static class WorkflowExecutionContextExtensions public static ActivityWorkItem ScheduleActivityExecutionContext(this WorkflowExecutionContext workflowExecutionContext, ActivityExecutionContext activityExecutionContext, IDictionary? input = null, IEnumerable? variables = null) { var workItem = new ActivityWorkItem( - activityExecutionContext.Activity, - input: input, + activityExecutionContext.Activity, + input: input, variables: variables, existingActivityExecutionContext: activityExecutionContext); workflowExecutionContext.Scheduler.Schedule(workItem); @@ -112,7 +112,7 @@ public static class WorkflowExecutionContextExtensions // Validate that the specified activity is part of the workflow. if (!workflowExecutionContext.NodeActivityLookup.ContainsKey(activityNode.Activity)) throw new InvalidOperationException("The specified activity is not part of the workflow."); - + var scheduler = workflowExecutionContext.Scheduler; if (options?.PreventDuplicateScheduling == true) @@ -145,4 +145,21 @@ public static class WorkflowExecutionContextExtensions var outputRegister = workflowExecutionContext.GetActivityOutputRegister(); return outputRegister.FindOutputByActivityId(activityId, outputName); } + + public static IEnumerable FindActivityExecutionContexts(this WorkflowExecutionContext workflowExecutionContext, ActivityHandle activityHandle) + { + if (activityHandle.ActivityInstanceId != null) + return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.Id == activityHandle.ActivityInstanceId); + if (activityHandle.ActivityNodeId != null) + return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.NodeId == activityHandle.ActivityNodeId); + if (activityHandle.ActivityId != null) + return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.Activity.Id == activityHandle.ActivityId); + if (activityHandle.ActivityHash != null) + { + var activity = workflowExecutionContext.FindActivityByHash(activityHandle.ActivityHash); + return activity != null ? workflowExecutionContext.ActivityExecutionContexts.Where(x => x.Activity.NodeId == activity.NodeId) : []; + } + + return []; + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs index 60f4e3fec..b7028e2b8 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs @@ -1,8 +1,6 @@ using Elsa.Common; using Elsa.Extensions; -using Elsa.Workflows.Models; using Elsa.Workflows.Pipelines.ActivityExecution; -using Elsa.Workflows.State; using Microsoft.Extensions.Logging; namespace Elsa.Workflows.Middleware.Activities; @@ -34,34 +32,14 @@ public class ExceptionHandlingMiddleware(ActivityMiddlewareDelegate next, IIncid catch (Exception e) { logger.LogWarning(e, "An exception was caught from a downstream middleware component"); - LogExceptionAndTransition(context, e); - FaultAncestors(context); + context.Fault(e); await HandleIncidentAsync(context); } } - private void LogExceptionAndTransition(ActivityExecutionContext context, Exception e) - { - context.Exception = e; - context.TransitionTo(ActivityStatus.Faulted); - var activity = context.Activity; - var exceptionState = ExceptionState.FromException(e); - var now = systemClock.UtcNow; - var incident = new ActivityIncident(activity.Id, activity.NodeId ,activity.Type, e.Message, exceptionState, now); - context.WorkflowExecutionContext.Incidents.Add(incident); - } - private async Task HandleIncidentAsync(ActivityExecutionContext context) { var strategy = await incidentStrategyResolver.ResolveStrategyAsync(context); strategy.HandleIncident(context); } - - private static void FaultAncestors(ActivityExecutionContext context) - { - var ancestors = context.GetAncestors(); - - foreach (var ancestor in ancestors) - ancestor.TransitionTo(ActivityStatus.Faulted); - } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Models/ActivityHandle.cs b/src/modules/Elsa.Workflows.Core/Models/ActivityHandle.cs index ba0cb92c8..7ffefa1d2 100644 --- a/src/modules/Elsa.Workflows.Core/Models/ActivityHandle.cs +++ b/src/modules/Elsa.Workflows.Core/Models/ActivityHandle.cs @@ -5,12 +5,33 @@ namespace Elsa.Workflows.Models; /// public class ActivityHandle { - public static ActivityHandle FromActivityId(string activityId) => new() { ActivityId = activityId }; - public static ActivityHandle FromActivityNodeId(string activityNodeId) => new() { ActivityNodeId = activityNodeId }; - public static ActivityHandle FromActivityInstanceId(string activityInstanceId) => new() { ActivityInstanceId = activityInstanceId }; - public static ActivityHandle FromActivityHash(string activityHash) => new() { ActivityHash = activityHash }; + public static ActivityHandle FromActivityId(string activityId) => new() + { + ActivityId = activityId + }; + + public static ActivityHandle FromActivityNodeId(string activityNodeId) => new() + { + ActivityNodeId = activityNodeId + }; + + public static ActivityHandle FromActivityInstanceId(string activityInstanceId) => new() + { + ActivityInstanceId = activityInstanceId + }; + + public static ActivityHandle FromActivityHash(string activityHash) => new() + { + ActivityHash = activityHash + }; + public string? ActivityId { get; init; } - public string? ActivityNodeId { get; init;} - public string? ActivityInstanceId { get; init;} - public string? ActivityHash { get; init;} + public string? ActivityNodeId { get; init; } + public string? ActivityInstanceId { get; init; } + public string? ActivityHash { get; init; } + + public override string ToString() + { + return ActivityId ?? (ActivityNodeId ?? (ActivityInstanceId ?? (ActivityHash ?? ""))); + } } \ 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))] diff --git a/src/modules/Elsa.Workflows.Management/Mappers/WorkflowStateMapper.cs b/src/modules/Elsa.Workflows.Management/Mappers/WorkflowStateMapper.cs index cea8e3a2a..deccb8170 100644 --- a/src/modules/Elsa.Workflows.Management/Mappers/WorkflowStateMapper.cs +++ b/src/modules/Elsa.Workflows.Management/Mappers/WorkflowStateMapper.cs @@ -1,3 +1,4 @@ +using Elsa.Extensions; using Elsa.Workflows.Management.Entities; using Elsa.Workflows.State; @@ -8,6 +9,12 @@ namespace Elsa.Workflows.Management.Mappers; /// public class WorkflowStateMapper { + /// + /// [Obsolete] The property key name used to store the workflow instance name. + /// + [Obsolete("This constant is obsolete and retained only for backward compatibility. Avoid using it in new code.")] + private const string WorkflowInstanceNameKey = "WorkflowInstanceName"; + /// /// Maps a workflow state to a workflow instance. /// @@ -43,6 +50,10 @@ public class WorkflowStateMapper target.UpdatedAt = source.UpdatedAt; target.FinishedAt = source.FinishedAt; target.WorkflowState = source; + + // Keep for backward compatibility with workflow instances created before the introduction of the Name property. + if (source.Properties.TryGetValue(WorkflowInstanceNameKey, out var name)) + target.Name = name; } ///