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 <sipkeschoorstra@outlook.com>

* 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 <lukhipolito@yahoo.com.br>
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
lukhipolito-nexxbiz 2025-06-30 16:56:51 +02:00 committed by GitHub
parent 2dbd050e64
commit c4451c4f40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1098 additions and 16 deletions

View file

@ -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}

View file

@ -14,6 +14,7 @@
<ProjectReference Include="..\..\modules\Elsa.Connections.Persistence.EntityFrameworkCore\Elsa.Connections.Persistence.EntityFrameworkCore.csproj" />
<ProjectReference Include="..\..\modules\Elsa.EntityFrameworkCore.PostgreSql\Elsa.EntityFrameworkCore.PostgreSql.csproj" />
<ProjectReference Include="..\..\modules\Elsa.EntityFrameworkCore.SqlServer\Elsa.EntityFrameworkCore.SqlServer.csproj" />
<ProjectReference Include="..\..\modules\Elsa.IO.Http\Elsa.IO.Http.csproj" />
<ProjectReference Include="..\..\modules\Elsa.JavaScript.Libraries\Elsa.JavaScript.Libraries.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Quartz.EntityFrameworkCore.PostgreSql\Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Quartz.EntityFrameworkCore.SqlServer\Elsa.Quartz.EntityFrameworkCore.SqlServer.csproj" />
@ -28,6 +29,7 @@
<ProjectReference Include="..\..\modules\Elsa.Hangfire\Elsa.Hangfire.csproj"/>
<ProjectReference Include="..\..\modules\Elsa.Http\Elsa.Http.csproj"/>
<ProjectReference Include="..\..\modules\Elsa.Identity\Elsa.Identity.csproj"/>
<ProjectReference Include="..\..\modules\Elsa.IO.Compression\Elsa.IO.Compression.csproj"/>
<ProjectReference Include="..\..\modules\Elsa.Liquid\Elsa.Liquid.csproj"/>
<ProjectReference Include="..\..\modules\Elsa.EntityFrameworkCore\Elsa.EntityFrameworkCore.csproj"/>
<ProjectReference Include="..\..\modules\Elsa.MassTransit.AzureServiceBus\Elsa.MassTransit.AzureServiceBus.csproj"/>

View file

@ -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<IOHttpFeature>()
.AddActivitiesFrom<Program>()
.AddWorkflowsFrom<Program>();

View file

@ -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()
{

View file

@ -1,4 +1,6 @@
// ReSharper disable once CheckNamespace
using Elsa.Http;
namespace Elsa.Extensions;
/// <summary>

View file

@ -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;
/// <summary>
/// Creates a ZIP archive from a collection of entries.
/// </summary>
[Activity("Elsa", "Compression", "Creates a ZIP archive from a collection of entries.", DisplayName = "Create Zip Archive")]
public class CreateZipArchive : CodeActivity<Stream>
{
private const string DefaultArchiveName = "archive.zip";
private const string ZipExtension = ".zip";
private const string DefaultEntryNameFormat = "entry_{0}";
/// <inheritdoc />
[JsonConstructor]
public CreateZipArchive(string? source = null, int? line = null) : base(source, line)
{
}
/// <summary>
/// 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.
/// </summary>
[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<object?> Entries { get; set; } = null!;
/// <summary>
/// The compression level for the Zip Entries. Default is Optimal
/// </summary>
[Input(
Description = "The compression level for the Zip Entries. Default is Optimal",
UIHint = InputUIHints.DropDown
)]
public Input<CompressionLevel> CompressionLevel { get; set; } = new(System.IO.Compression.CompressionLevel.Optimal);
/// <inheritdoc />
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
var entriesInput = Entries.Get(context);
var resolver = context.GetRequiredService<IContentResolver>();
var logger = context.GetRequiredService<ILogger<CreateZipArchive>>();
var entries = ParseEntries(entriesInput);
var zipStream = await CreateZipStreamFromEntries(entries, resolver, context, logger);
Result.Set(context, zipStream);
}
private static IEnumerable<object> ParseEntries(object? entriesInput)
{
return entriesInput switch
{
null => [],
IEnumerable<object> enumerable => enumerable,
Array array => array.Cast<object>(),
_ => [entriesInput]
};
}
private async Task<Stream> CreateZipStreamFromEntries(
IEnumerable<object> 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;
}
/// <summary>
/// Processes a single zip entry and adds it to the archive.
/// </summary>
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();
}
}
}

View file

@ -0,0 +1,6 @@
namespace Elsa.IO.Compression.Common;
public static class Constants
{
public const float ZipEntryStrategyPriority = 0.5f;
}

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Provides compression and archiving activities for Elsa Workflows.
</Description>
<PackageTags>elsa module compression zip archive workflows</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
<ProjectReference Include="..\Elsa.Workflows.Runtime\Elsa.Workflows.Runtime.csproj" />
<ProjectReference Include="..\Elsa.IO\Elsa.IO.csproj" />
<ProjectReference Include="..\..\common\Elsa.Features\Elsa.Features.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,20 @@
using Elsa.IO.Compression.Features;
using Elsa.Features.Services;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
/// Provides extensions to install the <see cref="CompressionFeature"/> feature.
/// </summary>
public static class ModuleExtensions
{
/// <summary>
/// Install the <see cref="CompressionFeature"/> feature.
/// </summary>
public static IModule UseCompression(this IModule module, Action<CompressionFeature>? configure = default)
{
module.Configure(configure);
return module;
}
}

View file

@ -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;
/// <summary>
/// Configures compression activities and services.
/// </summary>
[UsedImplicitly]
[DependsOn(typeof(IOFeature))]
public class CompressionFeature(IModule module) : FeatureBase(module)
{
/// <inheritdoc />
public override void Configure()
{
Module.AddActivitiesFrom<CompressionFeature>();
Module.AddVariableTypeAndAlias<ZipEntry>("ZipEntry", "Compression");
}
/// <inheritdoc />
public override void Apply()
{
Services.AddScoped<IContentResolverStrategy, ZipEntryContentStrategy>();
}
}

View file

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>

View file

@ -0,0 +1,11 @@
using JetBrains.Annotations;
namespace Elsa.IO.Compression.Models;
/// <summary>
/// Represents a zip entry with content and metadata.
/// </summary>
/// <param name="Content">The content of the zip entry. Can be byte[], Stream, file path, file URL, or base64 string.</param>
/// <param name="EntryName">The name of the entry in the zip archive.</param>
[UsedImplicitly]
public record ZipEntry(object Content, string? EntryName = null);

View file

@ -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;
/// <summary>
/// Strategy for resolving ZipEntry content with proper entry names.
/// </summary>
public class ZipEntryContentStrategy(IServiceProvider serviceProvider) : IContentResolverStrategy
{
/// <inheritdoc />
public float Priority => Constants.ZipEntryStrategyPriority;
/// <inheritdoc />
public bool CanResolve(object content) => content is ZipEntry;
/// <inheritdoc />
public async Task<BinaryContent> ResolveAsync(object content, CancellationToken cancellationToken = default)
{
var zipEntry = (ZipEntry)content;
var resolver = serviceProvider.GetRequiredService<IContentResolver>();
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;
}
}

View file

@ -0,0 +1,14 @@
namespace Elsa.IO.Http.Common;
public static class Constants
{
/// <summary>
/// The name of the HTTP client used for IO operations.
/// </summary>
public const string IOFeatureHttpClient = "IOFeatureHttpClient";
public static class StrategyPriorities
{
public const float Uri = 2.5f;
}
}

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Provides http capabilities to IO modules in Elsa Workflows.
</Description>
<PackageTags>elsa module io http</PackageTags>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Elsa.Http\Elsa.Http.csproj" />
<ProjectReference Include="..\Elsa.IO.Compression\Elsa.IO.Compression.csproj" />
<ProjectReference Include="..\Elsa.IO\Elsa.IO.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>

View file

@ -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;
/// <summary>
/// Configures HTTP-based IO services.
/// </summary>
[UsedImplicitly]
[DependsOn(typeof(IOFeature))]
[DependencyOf(typeof(CompressionFeature))]
public class IOHttpFeature(IModule module) : FeatureBase(module)
{
/// <inheritdoc />
public override void Apply()
{
Services.AddHttpClient(Constants.IOFeatureHttpClient);
Services.AddScoped<IContentResolverStrategy, UrlContentStrategy>();
}
}

View file

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>

View file

@ -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;
/// <summary>
/// Strategy for handling URL content by downloading from HTTP/HTTPS URLs.
/// </summary>
public class UrlContentStrategy(ILogger<UrlContentStrategy> logger, IHttpClientFactory httpClientFactory) : IContentResolverStrategy
{
/// <inheritdoc />
public float Priority => Constants.StrategyPriorities.Uri;
/// <inheritdoc />
public bool CanResolve(object content) => content is string str && (str.StartsWith("http://") || str.StartsWith("https://"));
/// <inheritdoc />
public async Task<BinaryContent> 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;
}
}
/// <summary>
/// Extracts a filename from the HTTP response, either from Content-Disposition header or URL.
/// </summary>
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";
}
}

View file

@ -0,0 +1,38 @@
namespace Elsa.IO.Common;
/// <summary>
/// IO module constants.
/// </summary>
public static class Constants
{
/// <summary>
/// Priorities for content resolver strategies.
/// </summary>
public static class StrategyPriorities
{
/// <summary>
/// Stream content priority.
/// </summary>
public const float Stream = 0.0f;
/// <summary>
/// Byte array content priority.
/// </summary>
public const float ByteArray = 1.0f;
/// <summary>
/// Base64 content priority.
/// </summary>
public const float Base64 = 2.0f;
/// <summary>
/// File path content priority.
/// </summary>
public const float FilePath = 3.0f;
/// <summary>
/// Text content priority.
/// </summary>
public const float Text = 100.0f;
}
}

View file

@ -0,0 +1,17 @@
namespace Elsa.IO.Contracts;
using Elsa.IO.Models;
/// <summary>
/// Provides methods to resolve various content types to BinaryContent.
/// </summary>
public interface IContentResolver
{
/// <summary>
/// Resolves arbitrary content to a BinaryContent object that includes the content stream and metadata.
/// </summary>
/// <param name="content">The content to resolve. Can be byte[], Stream, file path, file URL, base64 string, or plain text.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A BinaryContent object containing the content stream and associated metadata.</returns>
Task<BinaryContent> ResolveAsync(object content, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Provides IO services for resolving various content types to streams.
</Description>
<PackageTags>elsa module io content streams</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\common\Elsa.Features\Elsa.Features.csproj" />
</ItemGroup>
</Project>

View file

@ -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<string, string> MimeMapping = new(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, HashSet<string>> 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";
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,19 @@
using Elsa.Extensions;
using Elsa.Features.Services;
using Elsa.IO.Features;
namespace Elsa.IO.Extensions;
/// <summary>
/// Provides extension methods for configuring IO services.
/// </summary>
public static class ModuleExtensions
{
/// <summary>
/// Installs the IO module.
/// </summary>
public static IModule UseIO(this IModule module, Action<IOFeature>? configure = null)
{
return module.Use(configure);
}
}

View file

@ -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;
/// <summary>
/// A feature that installs IO services for resolving various content types to streams.
/// </summary>
public class IOFeature(IModule module) : FeatureBase(module)
{
/// <inheritdoc />
public override void Apply()
{
Services.AddScoped<IContentResolverStrategy, StreamContentStrategy>();
Services.AddScoped<IContentResolverStrategy, ByteArrayContentStrategy>();
Services.AddScoped<IContentResolverStrategy, Base64ContentStrategy>();
Services.AddScoped<IContentResolverStrategy, FilePathContentStrategy>();
Services.AddScoped<IContentResolverStrategy, TextContentStrategy>();
Services.AddScoped<IContentResolver, ContentResolver>();
}
}

View file

@ -0,0 +1 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd" />

View file

@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.IO;
using Elsa.IO.Extensions;
namespace Elsa.IO.Models;
/// <summary>
/// Represents normalized binary content with metadata.
/// </summary>
public class BinaryContent
{
/// <summary>
/// Gets or sets the name of the content.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets the content type (MIME type) based on file extension.
/// </summary>
public string? ContentType => !string.IsNullOrWhiteSpace(Name)
? Path.GetExtension(Name).GetContentTypeFromExtension()
: null;
/// <summary>
/// Gets or sets optional metadata headers.
/// </summary>
public IDictionary<string, object> Headers { get; set; } = new Dictionary<string, object>();
/// <summary>
/// Gets or sets the content stream.
/// </summary>
public Stream Stream { get; init; } = null!;
}

View file

@ -0,0 +1,34 @@
using Elsa.IO.Contracts;
using Elsa.IO.Models;
using Elsa.IO.Services.Strategies;
namespace Elsa.IO.Services;
/// <summary>
/// Resolves various content types to BinaryContent using a strategy pattern.
/// </summary>
public class ContentResolver : IContentResolver
{
private readonly IEnumerable<IContentResolverStrategy> _strategies;
/// <summary>
/// Initializes a new instance of the <see cref="ContentResolver"/> class.
/// </summary>
public ContentResolver(IEnumerable<IContentResolverStrategy> strategies)
{
_strategies = strategies.OrderBy(s => s.Priority).ToList();
}
/// <inheritdoc />
public async Task<BinaryContent> 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);
}
}

View file

@ -0,0 +1,67 @@
using Elsa.IO.Common;
using Elsa.IO.Extensions;
using Elsa.IO.Models;
namespace Elsa.IO.Services.Strategies;
/// <summary>
/// Strategy for handling base64 encoded content.
/// </summary>
public class Base64ContentStrategy : IContentResolverStrategy
{
/// <inheritdoc />
public float Priority => Constants.StrategyPriorities.Base64;
/// <inheritdoc />
public bool CanResolve(object content)
{
return content is string str && IsBase64String(str);
}
/// <inheritdoc />
public Task<BinaryContent> 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<byte>(new byte[base64.Length]);
return Convert.TryFromBase64String(base64, buffer , out _);
}
private static bool IsUriDataBase64String(string base64)
{
return base64.StartsWith("data:") && base64.Contains("base64");
}
}

View file

@ -0,0 +1,31 @@
using Elsa.IO.Common;
using Elsa.IO.Models;
namespace Elsa.IO.Services.Strategies;
/// <summary>
/// Strategy for handling byte array content.
/// </summary>
public class ByteArrayContentStrategy : IContentResolverStrategy
{
/// <inheritdoc />
public float Priority => Constants.StrategyPriorities.ByteArray;
/// <inheritdoc />
public bool CanResolve(object content) => content is byte[];
/// <inheritdoc />
public Task<BinaryContent> 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);
}
}

View file

@ -0,0 +1,89 @@
using Elsa.IO.Common;
using Elsa.IO.Extensions;
using Elsa.IO.Models;
namespace Elsa.IO.Services.Strategies;
/// <summary>
/// Strategy for handling file path content by reading from the filesystem.
/// </summary>
public class FilePathContentStrategy : IContentResolverStrategy
{
/// <inheritdoc />
public float Priority => Constants.StrategyPriorities.FilePath;
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
public Task<BinaryContent> 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);
}
}

View file

@ -0,0 +1,29 @@
namespace Elsa.IO.Services.Strategies;
using Elsa.IO.Models;
/// <summary>
/// Defines a strategy for resolving specific content types to BinaryContent.
/// </summary>
public interface IContentResolverStrategy
{
/// <summary>
/// The priority of the strategy.
/// </summary>
float Priority { get; }
/// <summary>
/// Determines if this strategy can handle the specified content.
/// </summary>
/// <param name="content">The content to check.</param>
/// <returns>True if this strategy can handle the content, false otherwise.</returns>
bool CanResolve(object content);
/// <summary>
/// Resolves the content to a BinaryContent object that includes the content stream and metadata.
/// </summary>
/// <param name="content">The content to resolve.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A BinaryContent object containing the content stream and associated metadata.</returns>
Task<BinaryContent> ResolveAsync(object content, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,37 @@
using Elsa.IO.Common;
using Elsa.IO.Extensions;
using Elsa.IO.Models;
namespace Elsa.IO.Services.Strategies;
/// <summary>
/// Strategy for handling Stream content.
/// </summary>
public class StreamContentStrategy : IContentResolverStrategy
{
/// <inheritdoc />
public float Priority => Constants.StrategyPriorities.Stream;
/// <inheritdoc />
public bool CanResolve(object content) => content is Stream;
/// <inheritdoc />
public Task<BinaryContent> 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);
}
}

View file

@ -0,0 +1,31 @@
using System.Text;
using Elsa.IO.Common;
using Elsa.IO.Models;
namespace Elsa.IO.Services.Strategies;
/// <summary>
/// Strategy for handling plain text content by encoding as UTF-8.
/// </summary>
public class TextContentStrategy : IContentResolverStrategy
{
/// <inheritdoc />
public float Priority => Constants.StrategyPriorities.Text;
/// <inheritdoc />
public bool CanResolve(object content) => content is string;
/// <inheritdoc />
public Task<BinaryContent> 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
});
}
}

View file

@ -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<ManagementOptions> options)
: INotificationHandler<EvaluatingJavaScript>
{
/// <inheritdoc />
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;
}
}

View file

@ -34,7 +34,7 @@ namespace Elsa.Workflows.Management.Features;
/// <summary>
/// Installs and configures the workflow management feature.
/// </summary>
[DependsOn(typeof(CompressionFeature))]
[DependsOn(typeof(StringCompressionFeature))]
[DependsOn(typeof(MediatorFeature))]
[DependsOn(typeof(MemoryCacheFeature))]
[DependsOn(typeof(SystemClockFeature))]