diff --git a/Elsa.sln b/Elsa.sln index 186bbe3e9..76e444749 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -277,6 +277,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Quartz.EntityFramework EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Quartz.EntityFrameworkCore.PostgreSql", "src\modules\Elsa.Quartz.EntityFrameworkCore.PostgreSql\Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj", "{CCCCEF8C-7D96-4BEA-B9D0-E91EDF08E65D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.FileStorage", "src\modules\Elsa.FileStorage\Elsa.FileStorage.csproj", "{732BF088-6AD7-4C4D-9A48-8074253596D4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "storage", "storage", "{B818988E-639C-4E6E-85C1-B231BCAD9DAB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -695,6 +699,10 @@ Global {CCCCEF8C-7D96-4BEA-B9D0-E91EDF08E65D}.Debug|Any CPU.Build.0 = Debug|Any CPU {CCCCEF8C-7D96-4BEA-B9D0-E91EDF08E65D}.Release|Any CPU.ActiveCfg = Release|Any CPU {CCCCEF8C-7D96-4BEA-B9D0-E91EDF08E65D}.Release|Any CPU.Build.0 = Release|Any CPU + {732BF088-6AD7-4C4D-9A48-8074253596D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {732BF088-6AD7-4C4D-9A48-8074253596D4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {732BF088-6AD7-4C4D-9A48-8074253596D4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {732BF088-6AD7-4C4D-9A48-8074253596D4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -822,6 +830,8 @@ Global {05F3B23E-CF28-467B-AD3C-595EA4ED6B96} = {AB797AF0-C12C-46DE-A157-7E25625C6200} {74ACD9D0-8B4C-42FA-A582-E93BF0075023} = {AB797AF0-C12C-46DE-A157-7E25625C6200} {CCCCEF8C-7D96-4BEA-B9D0-E91EDF08E65D} = {AB797AF0-C12C-46DE-A157-7E25625C6200} + {B818988E-639C-4E6E-85C1-B231BCAD9DAB} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {732BF088-6AD7-4C4D-9A48-8074253596D4} = {B818988E-639C-4E6E-85C1-B231BCAD9DAB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/src/bundles/Elsa.WorkflowServer.Web/Elsa.WorkflowServer.Web.csproj b/src/bundles/Elsa.WorkflowServer.Web/Elsa.WorkflowServer.Web.csproj index fd10cdc39..b6a957b6a 100644 --- a/src/bundles/Elsa.WorkflowServer.Web/Elsa.WorkflowServer.Web.csproj +++ b/src/bundles/Elsa.WorkflowServer.Web/Elsa.WorkflowServer.Web.csproj @@ -15,6 +15,7 @@ + @@ -39,6 +40,7 @@ + diff --git a/src/bundles/Elsa.WorkflowServer.Web/Program.cs b/src/bundles/Elsa.WorkflowServer.Web/Program.cs index dc2a02c63..bf8c8d8f8 100644 --- a/src/bundles/Elsa.WorkflowServer.Web/Program.cs +++ b/src/bundles/Elsa.WorkflowServer.Web/Program.cs @@ -1,4 +1,5 @@ using System.Text.Encodings.Web; +using Elastic.Clients.Elasticsearch.IndexManagement; using Elsa.Alterations.Extensions; using Elsa.Alterations.MassTransit.Extensions; using Elsa.Dapper.Extensions; @@ -17,6 +18,7 @@ using Elsa.MongoDb.Modules.Identity; using Elsa.MongoDb.Modules.Management; using Elsa.MongoDb.Modules.Runtime; using Elsa.WorkflowServer.Web.WorkflowContexts; +using FluentStorage; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Options; using Proto.Persistence.Sqlite; @@ -65,6 +67,8 @@ services .AddActivitiesFrom() .AddWorkflowsFrom() .UseFluentStorageProvider() + .UseFileStorage() + // .UseFileStorage(sp => StorageFactory.Blobs.AzureBlobStorageWithSas(configuration.GetConnectionString("AzureStorageSasUrl"))) .UseIdentity(identity => { if (useMongoDb) diff --git a/src/bundles/Elsa.WorkflowServer.Web/appsettings.json b/src/bundles/Elsa.WorkflowServer.Web/appsettings.json index 294b390fb..b4853ecc6 100644 --- a/src/bundles/Elsa.WorkflowServer.Web/appsettings.json +++ b/src/bundles/Elsa.WorkflowServer.Web/appsettings.json @@ -96,8 +96,5 @@ "SweepInterval": "00:00:10:00", "BatchSize": 1000 } - }, - "Quartz": { - } } \ No newline at end of file diff --git a/src/modules/Elsa.FileStorage/Activities/OpenFile.cs b/src/modules/Elsa.FileStorage/Activities/OpenFile.cs new file mode 100644 index 000000000..e2f079390 --- /dev/null +++ b/src/modules/Elsa.FileStorage/Activities/OpenFile.cs @@ -0,0 +1,31 @@ +using Elsa.Extensions; +using Elsa.Workflows.Core; +using Elsa.Workflows.Core.Attributes; +using Elsa.Workflows.Core.Models; + +namespace Elsa.FileStorage.Activities; + +/// +/// Save a file to the configured storage provider. +/// +[Activity("Elsa", "Storage", "Open a file from the configured storage provider.", Kind = ActivityKind.Task)] +public class OpenFile : CodeActivity +{ + /// + /// Gets or sets the path to save the file to. + /// + [Input(Description = "The path to the file to open.")] + public Input Path { get; set; } = default!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var cancellationToken = context.CancellationToken; + var path = Path.Get(context); + var blobStorageProvider = context.GetRequiredService(); + var blobStorage = blobStorageProvider.GetBlobStorage(); + var data = await blobStorage.OpenReadAsync(path, cancellationToken); + + Result.Set(context, data); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.FileStorage/Activities/SaveFile.cs b/src/modules/Elsa.FileStorage/Activities/SaveFile.cs new file mode 100644 index 000000000..951a8fa02 --- /dev/null +++ b/src/modules/Elsa.FileStorage/Activities/SaveFile.cs @@ -0,0 +1,92 @@ +using System.Collections; +using System.IO.Compression; +using System.Text; +using Elsa.Extensions; +using Elsa.Workflows.Core; +using Elsa.Workflows.Core.Attributes; +using Elsa.Workflows.Core.Models; +using Microsoft.AspNetCore.Http; + +namespace Elsa.FileStorage.Activities; + +/// +/// Save a file to the configured storage provider. +/// +[Activity("Elsa", "Storage", "Save a file to the configured storage provider.", Kind = ActivityKind.Task)] +public class SaveFile : CodeActivity +{ + /// + /// Gets or sets the file data to save. + /// + [Input(Description = "The file data to save. This can be a stream, binary data, a string, a form file or a collection of files.")] + public Input Data { get; set; } = default!; + + /// + /// Gets or sets the path to save the file to. + /// + [Input(Description = "The path to save the file to.")] + public Input Path { get; set; } = default!; + + /// + /// Gets or sets a value indicating whether to append to the file if it already exists. + /// + [Input(Description = "Whether to append to the file if it already exists.")] + public Input Append { get; set; } = default!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var cancellationToken = context.CancellationToken; + var data = await ResolveAsStreamAsync(Data.Get(context), cancellationToken); + var path = Path.Get(context); + var append = Append.GetOrDefault(context); + var blobStorageProvider = context.GetRequiredService(); + var blobStorage = blobStorageProvider.GetBlobStorage(); + await blobStorage.WriteAsync(path, data, append, cancellationToken); + } + + private async Task ResolveAsStreamAsync(object data, CancellationToken cancellationToken) + { + if(data is Stream stream) + return stream; + + if(data is byte[] bytes) + return new MemoryStream(bytes); + + if(data is IFormFile formFile) + return formFile.OpenReadStream(); + + if(data is string stringData) + return new MemoryStream(Encoding.UTF8.GetBytes(stringData)); + + if (data is IEnumerable enumerable) + { + var files = enumerable.Cast().ToList(); + return files.Count == 1 ? await ResolveAsStreamAsync(files[0], cancellationToken) : await CreateZipArchiveAsync(files, cancellationToken); + } + + throw new NotSupportedException($"The provided data type is not supported: {data.GetType().Name}"); + } + + private async Task CreateZipArchiveAsync(IEnumerable files, CancellationToken cancellationToken = default) + { + var currentFileIndex = 0; + var zipStream = new MemoryStream(); + var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true); + + foreach (var file in files) + { + var entryName = $"file-{currentFileIndex}.bin"; + var entry = zipArchive.CreateEntry(entryName); + var fileStream = await ResolveAsStreamAsync(file, cancellationToken); + await using var entryStream = entry.Open(); + await fileStream.CopyToAsync(entryStream, cancellationToken); + await entryStream.FlushAsync(cancellationToken); + entryStream.Close(); + currentFileIndex++; + } + + zipStream.Seek(0, SeekOrigin.Begin); + return zipStream; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.FileStorage/Contracts/IStorageProvider.cs b/src/modules/Elsa.FileStorage/Contracts/IStorageProvider.cs new file mode 100644 index 000000000..e7e04f028 --- /dev/null +++ b/src/modules/Elsa.FileStorage/Contracts/IStorageProvider.cs @@ -0,0 +1,15 @@ +using FluentStorage.Blobs; + +namespace Elsa.FileStorage; + +/// +/// A provider of . The point of this interface is to provide a wrapper for actual implementations. +/// This prevents collisions when the application uses multiple implementations. +/// +public interface IBlobStorageProvider +{ + /// + /// Gets the . + /// + IBlobStorage GetBlobStorage(); +} \ No newline at end of file diff --git a/src/modules/Elsa.FileStorage/Elsa.FileStorage.csproj b/src/modules/Elsa.FileStorage/Elsa.FileStorage.csproj new file mode 100644 index 000000000..e9632da8f --- /dev/null +++ b/src/modules/Elsa.FileStorage/Elsa.FileStorage.csproj @@ -0,0 +1,24 @@ + + + + + + + + net6.0;net7.0 + + Provides activities to save and load files to and from a confogurable storage provider. + + elsa module activities storage + + + + + + + + + + + + diff --git a/src/modules/Elsa.FileStorage/Extensions/ModuleExtensions.cs b/src/modules/Elsa.FileStorage/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..08a084e85 --- /dev/null +++ b/src/modules/Elsa.FileStorage/Extensions/ModuleExtensions.cs @@ -0,0 +1,31 @@ +using Elsa.Features.Services; +using Elsa.FileStorage.Features; +using FluentStorage.Blobs; +using JetBrains.Annotations; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +/// +/// Extension methods for to install the Storage feature. +/// +[PublicAPI] +public static class ModuleExtensions +{ + /// + /// Installs the Storage feature. + /// + public static IModule UseFileStorage(this IModule module, Func blobStorage) + { + return module.UseFileStorage(feature => feature.BlobStorage = blobStorage); + } + + /// + /// Installs the Storage feature. + /// + public static IModule UseFileStorage(this IModule module, Action? configure = default) + { + module.Use(configure); + return module; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.FileStorage/Features/BlobStorageFeature.cs b/src/modules/Elsa.FileStorage/Features/BlobStorageFeature.cs new file mode 100644 index 000000000..3c01ec7e9 --- /dev/null +++ b/src/modules/Elsa.FileStorage/Features/BlobStorageFeature.cs @@ -0,0 +1,46 @@ +using Elsa.Extensions; +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using Elsa.FileStorage.Services; +using FluentStorage; +using FluentStorage.Blobs; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.FileStorage.Features; + +/// +/// The Storage feature provides activities to interact with a storage provider. +/// +public class FileStorageFeature : FeatureBase +{ + /// + public FileStorageFeature(IModule module) : base(module) + { + } + + /// + /// The blob storage to use. + /// + public Func BlobStorage { get; set; } = _ => StorageFactory.Blobs.DirectoryFiles(GetDefaultStorageDirectory()); + + /// + public override void Configure() + { + Module.AddActivitiesFrom(); + } + + /// + public override void Apply() + { + Services.AddSingleton(sp => new BlobStorageProvider(BlobStorage(sp))); + } + + /// + /// Gets the default workflows directory. + /// + /// The default workflows directory. + public static string GetDefaultStorageDirectory() + { + return Path.Combine(Path.GetTempPath(), "Elsa", "Storage", "Blobs"); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.FileStorage/FodyWeavers.xml b/src/modules/Elsa.FileStorage/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.FileStorage/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.FileStorage/Services/BlobStorageProvider.cs b/src/modules/Elsa.FileStorage/Services/BlobStorageProvider.cs new file mode 100644 index 000000000..0b68b084d --- /dev/null +++ b/src/modules/Elsa.FileStorage/Services/BlobStorageProvider.cs @@ -0,0 +1,22 @@ +using FluentStorage.Blobs; + +namespace Elsa.FileStorage.Services; + +/// +/// A provider of . +/// +public class BlobStorageProvider : IBlobStorageProvider +{ + private readonly IBlobStorage _blobStorage; + + /// + /// Initializes a new instance of the class. + /// + public BlobStorageProvider(IBlobStorage blobStorage) + { + _blobStorage = blobStorage; + } + + /// + public IBlobStorage GetBlobStorage() => _blobStorage; +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs b/src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs index 46108eccb..92be4b112 100644 --- a/src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs +++ b/src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs @@ -241,7 +241,9 @@ public class WriteFileHttpResponse : Activity private async Task SendFileStream(ActivityExecutionContext context, HttpContext httpContext, Stream source, string contentType, string filename, EntityTagHeaderValue? eTag) { - source.Seek(0, SeekOrigin.Begin); + if(source.CanSeek) + source.Seek(0, SeekOrigin.Begin); + var enableResumableDownloads = EnableResumableDownloads.GetOrDefault(context, () => false); var result = new FileStreamResult(source, contentType) diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Contracts/IBlobStorageProvider.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Contracts/IBlobStorageProvider.cs index 1f0f95cf5..4186854cd 100644 --- a/src/modules/Elsa.WorkflowProviders.BlobStorage/Contracts/IBlobStorageProvider.cs +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Contracts/IBlobStorageProvider.cs @@ -11,6 +11,5 @@ public interface IBlobStorageProvider /// /// Gets the . /// - /// The . IBlobStorage GetBlobStorage(); } \ No newline at end of file