diff --git a/Elsa.sln b/Elsa.sln
index b49eccd67..7687e4512 100644
--- a/Elsa.sln
+++ b/Elsa.sln
@@ -21,6 +21,7 @@ ProjectSection(SolutionItems) = preProject
.github\workflows\npm-packages.yml = .github\workflows\npm-packages.yml
update-migrations.sh = update-migrations.sh
.github\workflows\pr-body-generator.yml = .github\workflows\pr-body-generator.yml
+ packages.props = packages.props
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{0354F050-3992-4DD4-B0EE-5FBA04AC72B6}"
diff --git a/packages.props b/packages.props
new file mode 100644
index 000000000..fb0eb45a3
--- /dev/null
+++ b/packages.props
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Abstractions/DownloadableProviderBase.cs b/src/modules/Elsa.Http/Abstractions/DownloadableContentHandlerBase.cs
similarity index 59%
rename from src/modules/Elsa.Http/Abstractions/DownloadableProviderBase.cs
rename to src/modules/Elsa.Http/Abstractions/DownloadableContentHandlerBase.cs
index 7f1f9a696..02c7d236f 100644
--- a/src/modules/Elsa.Http/Abstractions/DownloadableProviderBase.cs
+++ b/src/modules/Elsa.Http/Abstractions/DownloadableContentHandlerBase.cs
@@ -5,9 +5,9 @@ using Elsa.Http.Models;
namespace Elsa.Http.Abstractions;
///
-/// Provides a base class for implementations.
+/// Provides a base class for implementations.
///
-public abstract class DownloadableProviderBase : IDownloadableProvider
+public abstract class DownloadableContentHandlerBase : IDownloadableContentHandler
{
///
public virtual float Priority => 0;
@@ -18,19 +18,17 @@ public abstract class DownloadableProviderBase : IDownloadableProvider
///
/// Returns a list of downloadables from the specified content.
///
- protected virtual async ValueTask> GetDownloadablesAsync(DownloadableContext context)
+ protected virtual IEnumerable>> GetDownloadablesAsync(DownloadableContext context)
{
- var downloadable = await GetDownloadableAsync(context);
- return new[]{ downloadable };
+ return new[] { GetDownloadableAsync(context) };
}
///
/// Returns a downloadable from the specified content.
///
- protected virtual ValueTask GetDownloadableAsync(DownloadableContext context)
+ protected virtual Func> GetDownloadableAsync(DownloadableContext context)
{
- var downloadable = GetDownloadable(context);
- return new (downloadable);
+ return () => ValueTask.FromResult(GetDownloadable(context));
}
///
@@ -42,8 +40,8 @@ public abstract class DownloadableProviderBase : IDownloadableProvider
throw new NotImplementedException();
}
- async ValueTask> IDownloadableProvider.GetDownloadablesAsync(DownloadableContext context)
+ IEnumerable>> IDownloadableContentHandler.GetDownloadablesAsync(DownloadableContext context)
{
- return await GetDownloadablesAsync(context);
+ return GetDownloadablesAsync(context);
}
}
\ 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 5e97652c4..ab5ebe145 100644
--- a/src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs
+++ b/src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs
@@ -1,18 +1,22 @@
-using System.IO.Compression;
-using System.Net;
using Elsa.Extensions;
using Elsa.Http.Contracts;
using Elsa.Http.Models;
+using Elsa.Http.Options;
+using Elsa.Http.Services;
using Elsa.Workflows.Core;
using Elsa.Workflows.Core.Attributes;
using Elsa.Workflows.Core.Exceptions;
using Elsa.Workflows.Core.Models;
+using FluentStorage.Blobs;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Logging;
+using Microsoft.Net.Http.Headers;
+using EntityTagHeaderValue = System.Net.Http.Headers.EntityTagHeaderValue;
+using RangeHeaderValue = System.Net.Http.Headers.RangeHeaderValue;
namespace Elsa.Http;
@@ -32,7 +36,13 @@ public class WriteFileHttpResponse : Activity
/// The name of the file to serve.
///
[Input(Description = "The name of the file to serve. Leave empty to let the system determine the file name.")]
- public Input FileName { get; set; } = default!;
+ public Input Filename { get; set; } = default!;
+
+ ///
+ /// The Entity Tag of the file to serve.
+ ///
+ [Input(Description = "The Entity Tag of the file to serve. Leave empty to let the system determine the Entity Tag.")]
+ public Input EntityTag { get; set; } = default!;
///
/// The file content to serve. Supports byte array, streams, string, Uri and an array of the aforementioned types.
@@ -40,6 +50,18 @@ public class WriteFileHttpResponse : Activity
[Input(Description = "The file content to serve. Supports various types, such as byte array, stream, string, Uri, Downloadable and a (mixed) array of the aforementioned types.")]
public Input
/// The manager.
/// The content to get downloadables from.
+/// An optional ETag.
/// The cancellation token.
-public record DownloadableContext(IDownloadableManager Manager, object Content, CancellationToken CancellationToken);
\ No newline at end of file
+public record DownloadableContext(
+ IDownloadableManager Manager,
+ object Content,
+ DownloadableOptions Options,
+ CancellationToken CancellationToken);
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Contracts/IAbsoluteUrlProvider.cs b/src/modules/Elsa.Http/Contracts/IAbsoluteUrlProvider.cs
index 9bfb03a24..97d45267d 100644
--- a/src/modules/Elsa.Http/Contracts/IAbsoluteUrlProvider.cs
+++ b/src/modules/Elsa.Http/Contracts/IAbsoluteUrlProvider.cs
@@ -1,6 +1,14 @@
namespace Elsa.Http.Contracts;
+///
+/// Provides a way to convert a relative URL to an absolute URL.
+///
public interface IAbsoluteUrlProvider
{
+ ///
+ /// Converts a relative URL to an absolute URL.
+ ///
+ /// The relative URL.
+ /// The absolute URL.
Uri ToAbsoluteUrl(string relativePath);
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Contracts/IDownloadableProvider.cs b/src/modules/Elsa.Http/Contracts/IDownloadableContentHandler.cs
similarity index 82%
rename from src/modules/Elsa.Http/Contracts/IDownloadableProvider.cs
rename to src/modules/Elsa.Http/Contracts/IDownloadableContentHandler.cs
index 829e003df..3451bdbda 100644
--- a/src/modules/Elsa.Http/Contracts/IDownloadableProvider.cs
+++ b/src/modules/Elsa.Http/Contracts/IDownloadableContentHandler.cs
@@ -6,7 +6,7 @@ namespace Elsa.Http.Contracts;
///
/// Provides downloadables from the specified content, if supported.
///
-public interface IDownloadableProvider
+public interface IDownloadableContentHandler
{
///
/// The priority of this provider. Providers with lower priority are tried first.
@@ -22,5 +22,5 @@ public interface IDownloadableProvider
///
/// Returns a list of downloadables from the specified content.
///
- ValueTask> GetDownloadablesAsync(DownloadableContext context);
+ IEnumerable>> GetDownloadablesAsync(DownloadableContext context);
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Contracts/IDownloadableManager.cs b/src/modules/Elsa.Http/Contracts/IDownloadableManager.cs
index 3674add4f..69919b48f 100644
--- a/src/modules/Elsa.Http/Contracts/IDownloadableManager.cs
+++ b/src/modules/Elsa.Http/Contracts/IDownloadableManager.cs
@@ -1,4 +1,5 @@
using Elsa.Http.Models;
+using Elsa.Http.Options;
namespace Elsa.Http.Contracts;
@@ -10,5 +11,5 @@ public interface IDownloadableManager
///
/// Returns a list of downloadables from the specified content.
///
- ValueTask> GetDownloadablesAsync(object content, CancellationToken cancellationToken = default);
+ IEnumerable>> GetDownloadablesAsync(object content, DownloadableOptions? options = default, CancellationToken cancellationToken = default);
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Contracts/IFileCacheStorageProvider.cs b/src/modules/Elsa.Http/Contracts/IFileCacheStorageProvider.cs
new file mode 100644
index 000000000..2d7806de9
--- /dev/null
+++ b/src/modules/Elsa.Http/Contracts/IFileCacheStorageProvider.cs
@@ -0,0 +1,15 @@
+using FluentStorage.Blobs;
+
+namespace Elsa.Http.Contracts;
+
+///
+/// Represents a provider of a file cache storage.
+///
+public interface IFileCacheStorageProvider
+{
+ ///
+ /// Gets the storage.
+ ///
+ ///
+ IBlobStorage GetStorage();
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Contracts/IFileDownloader.cs b/src/modules/Elsa.Http/Contracts/IFileDownloader.cs
index e97983f81..090a29682 100644
--- a/src/modules/Elsa.Http/Contracts/IFileDownloader.cs
+++ b/src/modules/Elsa.Http/Contracts/IFileDownloader.cs
@@ -1,3 +1,5 @@
+using Elsa.Http.Options;
+
namespace Elsa.Http.Contracts;
///
@@ -8,5 +10,5 @@ public interface IFileDownloader
///
/// Downloads a file from the specified URL.
///
- Task DownloadAsync(Uri url, CancellationToken cancellationToken = default);
+ Task DownloadAsync(Uri url, FileDownloadOptions? options = default, CancellationToken cancellationToken = default);
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/DownloadableProviders/BinaryDownloadableProvider.cs b/src/modules/Elsa.Http/DownloadableContentHandlers/BinaryDownloadableContentHandler.cs
similarity index 76%
rename from src/modules/Elsa.Http/DownloadableProviders/BinaryDownloadableProvider.cs
rename to src/modules/Elsa.Http/DownloadableContentHandlers/BinaryDownloadableContentHandler.cs
index 2fe2bfd2d..364039f40 100644
--- a/src/modules/Elsa.Http/DownloadableProviders/BinaryDownloadableProvider.cs
+++ b/src/modules/Elsa.Http/DownloadableContentHandlers/BinaryDownloadableContentHandler.cs
@@ -2,12 +2,12 @@ using Elsa.Http.Abstractions;
using Elsa.Http.Contexts;
using Elsa.Http.Models;
-namespace Elsa.Http.DownloadableProviders;
+namespace Elsa.Http.DownloadableContentHandlers;
///
/// Handles content that represents a downloadable binary file.
///
-public class BinaryDownloadableProvider : DownloadableProviderBase
+public class BinaryDownloadableContentHandler : DownloadableContentHandlerBase
{
///
public override bool GetSupportsContent(object content) => content is byte[];
@@ -19,6 +19,6 @@ public class BinaryDownloadableProvider : DownloadableProviderBase
var stream = new MemoryStream(bytes);
var fileName = "file.bin";
var contentType = "application/octet-stream";
- return new Downloadable(stream, fileName, contentType);
+ return new(stream, fileName, contentType);
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/DownloadableProviders/DownloadableDownloadableProvider.cs b/src/modules/Elsa.Http/DownloadableContentHandlers/DownloadableDownloadableContentHandler.cs
similarity index 76%
rename from src/modules/Elsa.Http/DownloadableProviders/DownloadableDownloadableProvider.cs
rename to src/modules/Elsa.Http/DownloadableContentHandlers/DownloadableDownloadableContentHandler.cs
index c031a130d..c3ff81b8b 100644
--- a/src/modules/Elsa.Http/DownloadableProviders/DownloadableDownloadableProvider.cs
+++ b/src/modules/Elsa.Http/DownloadableContentHandlers/DownloadableDownloadableContentHandler.cs
@@ -2,12 +2,12 @@ using Elsa.Http.Abstractions;
using Elsa.Http.Contexts;
using Elsa.Http.Models;
-namespace Elsa.Http.DownloadableProviders;
+namespace Elsa.Http.DownloadableContentHandlers;
///
/// Handles content that represents a downloadable.
///
-public class DownloadableDownloadableProvider : DownloadableProviderBase
+public class DownloadableDownloadableContentHandler : DownloadableContentHandlerBase
{
///
public override bool GetSupportsContent(object content) => content is Downloadable;
diff --git a/src/modules/Elsa.Http/DownloadableProviders/MultiDownloadableProvider.cs b/src/modules/Elsa.Http/DownloadableContentHandlers/MultiDownloadableContentHandler.cs
similarity index 65%
rename from src/modules/Elsa.Http/DownloadableProviders/MultiDownloadableProvider.cs
rename to src/modules/Elsa.Http/DownloadableContentHandlers/MultiDownloadableContentHandler.cs
index dc1215ade..346b721cf 100644
--- a/src/modules/Elsa.Http/DownloadableProviders/MultiDownloadableProvider.cs
+++ b/src/modules/Elsa.Http/DownloadableContentHandlers/MultiDownloadableContentHandler.cs
@@ -3,27 +3,27 @@ using Elsa.Http.Abstractions;
using Elsa.Http.Contexts;
using Elsa.Http.Models;
-namespace Elsa.Http.DownloadableProviders;
+namespace Elsa.Http.DownloadableContentHandlers;
///
/// Handles content that represents a list of downloadable objects.
///
-public class MultiDownloadableProvider : DownloadableProviderBase
+public class MultiDownloadableContentHandler : DownloadableContentHandlerBase
{
///
public override bool GetSupportsContent(object content) => content is IEnumerable enumerable and not string;
///
- protected override async ValueTask> GetDownloadablesAsync(DownloadableContext context)
+ protected override IEnumerable>> GetDownloadablesAsync(DownloadableContext context)
{
- var collectedDownloadables = new List();
+ var collectedDownloadables = new List>>();
var content = context.Content;
var enumerable = (IEnumerable) content;
var manager = context.Manager;
foreach (var item in enumerable)
{
- var downloadables = await manager.GetDownloadablesAsync(item, context.CancellationToken);
+ var downloadables = manager.GetDownloadablesAsync(item, context.Options, context.CancellationToken);
collectedDownloadables.AddRange(downloadables);
}
diff --git a/src/modules/Elsa.Http/DownloadableProviders/StreamDownloadableProvider.cs b/src/modules/Elsa.Http/DownloadableContentHandlers/StreamDownloadableContentHandler.cs
similarity index 82%
rename from src/modules/Elsa.Http/DownloadableProviders/StreamDownloadableProvider.cs
rename to src/modules/Elsa.Http/DownloadableContentHandlers/StreamDownloadableContentHandler.cs
index 4e82dcd54..dfd5d9913 100644
--- a/src/modules/Elsa.Http/DownloadableProviders/StreamDownloadableProvider.cs
+++ b/src/modules/Elsa.Http/DownloadableContentHandlers/StreamDownloadableContentHandler.cs
@@ -2,12 +2,12 @@ using Elsa.Http.Abstractions;
using Elsa.Http.Contexts;
using Elsa.Http.Models;
-namespace Elsa.Http.DownloadableProviders;
+namespace Elsa.Http.DownloadableContentHandlers;
///
/// Handles content that represents a downloadable stream.
///
-public class StreamDownloadableProvider : DownloadableProviderBase
+public class StreamDownloadableContentHandler : DownloadableContentHandlerBase
{
///
public override bool GetSupportsContent(object content) => content is Stream;
diff --git a/src/modules/Elsa.Http/DownloadableProviders/UrlDownloadableProvider.cs b/src/modules/Elsa.Http/DownloadableContentHandlers/UrlDownloadableContentHandler.cs
similarity index 66%
rename from src/modules/Elsa.Http/DownloadableProviders/UrlDownloadableProvider.cs
rename to src/modules/Elsa.Http/DownloadableContentHandlers/UrlDownloadableContentHandler.cs
index 6d235ce2c..10f836a9d 100644
--- a/src/modules/Elsa.Http/DownloadableProviders/UrlDownloadableProvider.cs
+++ b/src/modules/Elsa.Http/DownloadableContentHandlers/UrlDownloadableContentHandler.cs
@@ -1,22 +1,22 @@
-using System.Text.RegularExpressions;
using Elsa.Http.Abstractions;
using Elsa.Http.Contexts;
using Elsa.Http.Contracts;
using Elsa.Http.Models;
+using Elsa.Http.Options;
using Microsoft.AspNetCore.StaticFiles;
-namespace Elsa.Http.DownloadableProviders;
+namespace Elsa.Http.DownloadableContentHandlers;
///
/// Handles content that represents a downloadable URL.
///
-public class UrlDownloadableProvider : DownloadableProviderBase
+public class UrlDownloadableContentHandler : DownloadableContentHandlerBase
{
private readonly IFileDownloader _fileDownloader;
private readonly IContentTypeProvider _contentTypeProvider;
///
- public UrlDownloadableProvider(IFileDownloader fileDownloader, IContentTypeProvider contentTypeProvider)
+ public UrlDownloadableContentHandler(IFileDownloader fileDownloader, IContentTypeProvider contentTypeProvider)
{
_fileDownloader = fileDownloader;
_contentTypeProvider = contentTypeProvider;
@@ -26,18 +26,27 @@ public class UrlDownloadableProvider : DownloadableProviderBase
public override bool GetSupportsContent(object content) => (content is string url && url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) || content is Uri;
///
- protected override async ValueTask GetDownloadableAsync(DownloadableContext context)
+ protected override Func> GetDownloadableAsync(DownloadableContext context) => async () => await DownloadAsync(context);
+
+ private async ValueTask DownloadAsync(DownloadableContext context)
{
var url = context.Content is string s ? new Uri(s) : (Uri)context.Content;
var cancellationToken = context.CancellationToken;
- var response = await _fileDownloader.DownloadAsync(url, cancellationToken);
+ var options = new FileDownloadOptions
+ {
+ // TODO: Uncomment the next two lines if we implement file caching for this handler.
+ // ETag = context.Options.ETag,
+ // Range = context.Options.Range
+ };
+ var response = await _fileDownloader.DownloadAsync(url, options, cancellationToken);
+ var eTag = response.Headers.ETag?.Tag;
var filename = GetFilename(response) ?? url.Segments.Last();
- var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var contentType = response.Content.Headers.ContentType?.MediaType ?? GetContentType(filename);
+ var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
- return new Downloadable(stream, filename, contentType);
+ return new Downloadable(stream, filename, contentType, eTag);
}
-
+
private static string? GetFilename(HttpResponseMessage response)
{
if (!response.Content.Headers.TryGetValues("Content-Disposition", out var values))
diff --git a/src/modules/Elsa.Http/Elsa.Http.csproj b/src/modules/Elsa.Http/Elsa.Http.csproj
index d11a2f801..7a879f69d 100644
--- a/src/modules/Elsa.Http/Elsa.Http.csproj
+++ b/src/modules/Elsa.Http/Elsa.Http.csproj
@@ -1,6 +1,7 @@
+
diff --git a/src/modules/Elsa.Http/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs
index 5820e5d96..f90402b03 100644
--- a/src/modules/Elsa.Http/Features/HttpFeature.cs
+++ b/src/modules/Elsa.Http/Features/HttpFeature.cs
@@ -5,7 +5,8 @@ using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Elsa.Http.ContentWriters;
using Elsa.Http.Contracts;
-using Elsa.Http.DownloadableProviders;
+using Elsa.Http.DownloadableContentHandlers;
+using Elsa.Http.FileCaches;
using Elsa.Http.Handlers;
using Elsa.Http.HostedServices;
using Elsa.Http.Models;
@@ -20,6 +21,7 @@ using Elsa.Liquid.Features;
using Elsa.Workflows.Core.Contracts;
using Elsa.Workflows.Management.Requests;
using Elsa.Workflows.Management.Responses;
+using FluentStorage;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.StaticFiles;
@@ -45,6 +47,11 @@ public class HttpFeature : FeatureBase
///
public Action? ConfigureHttpOptions { get; set; }
+ ///
+ /// A delegate to configure .
+ ///
+ public Action? ConfigureHttpFileCacheOptions { get; set; }
+
///
/// A delegate that is invoked when authorizing an inbound HTTP request.
///
@@ -54,12 +61,21 @@ public class HttpFeature : FeatureBase
/// A delegate that is invoked when an HTTP workflow faults.
///
public Func HttpEndpointWorkflowFaultHandler { get; set; } = sp => sp.GetRequiredService();
-
+
///
/// A delegate to configure the .
///
public Func ContentTypeProvider { get; set; } = _ => new FileExtensionContentTypeProvider();
+ ///
+ /// A delegate to configure the .
+ ///
+ public Func FileCache { get; set; } = _ =>
+ {
+ var blobStorage = StorageFactory.Blobs.DirectoryFiles(Path.GetTempPath());
+ return new BlobFileCacheStorageProvider(blobStorage);
+ };
+
///
/// A delegate to configure the used when by the activity.
///
@@ -75,7 +91,7 @@ public class HttpFeature : FeatureBase
///
public ICollection HttpCorrelationIdSelectorTypes { get; } = new List
{
- typeof(HeaderHttpCorrelationIdSelector),
+ typeof(HeaderHttpCorrelationIdSelector),
typeof(QueryStringHttpCorrelationIdSelector)
};
@@ -84,7 +100,7 @@ public class HttpFeature : FeatureBase
///
public ICollection HttpWorkflowInstanceIdSelectorTypes { get; } = new List
{
- typeof(HeaderHttpWorkflowInstanceIdSelector),
+ typeof(HeaderHttpWorkflowInstanceIdSelector),
typeof(QueryStringHttpWorkflowInstanceIdSelector)
};
@@ -123,7 +139,10 @@ public class HttpFeature : FeatureBase
options.BaseUrl = new Uri("http://localhost");
});
+ var configureFileCacheOptions = ConfigureHttpFileCacheOptions ?? (options => { options.TimeToLive = TimeSpan.FromDays(7); });
+
Services.Configure(configureOptions);
+ Services.Configure(configureFileCacheOptions);
var httpClientBuilder = Services.AddHttpClient(HttpClient);
HttpClientBuilder(httpClientBuilder);
@@ -161,14 +180,18 @@ public class HttpFeature : FeatureBase
.AddSingleton()
.AddSingleton(HttpEndpointWorkflowFaultHandler)
.AddSingleton(HttpEndpointAuthorizationHandler)
-
- // File related services.
+
+ // Downloadable content handlers.
.AddSingleton()
- .AddSingleton()
- .AddSingleton()
- .AddSingleton()
- .AddSingleton()
- .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+
+ // File caches.
+ .AddSingleton(FileCache)
+ .AddSingleton()
// Add mediator handlers.
.AddNotificationHandlersFrom()
@@ -179,11 +202,11 @@ public class HttpFeature : FeatureBase
// HTTP clients.
Services.AddHttpClient();
-
+
// Add selectors.
- foreach (var httpCorrelationIdSelectorType in HttpCorrelationIdSelectorTypes)
+ foreach (var httpCorrelationIdSelectorType in HttpCorrelationIdSelectorTypes)
Services.AddSingleton(typeof(IHttpCorrelationIdSelector), httpCorrelationIdSelectorType);
-
+
foreach (var httpWorkflowInstanceIdSelectorType in HttpWorkflowInstanceIdSelectorTypes)
Services.AddSingleton(typeof(IHttpWorkflowInstanceIdSelector), httpWorkflowInstanceIdSelectorType);
}
diff --git a/src/modules/Elsa.Http/FileCaches/BlobFileCacheStorageProvider.cs b/src/modules/Elsa.Http/FileCaches/BlobFileCacheStorageProvider.cs
new file mode 100644
index 000000000..b067a1c59
--- /dev/null
+++ b/src/modules/Elsa.Http/FileCaches/BlobFileCacheStorageProvider.cs
@@ -0,0 +1,26 @@
+using Elsa.Http.Contracts;
+using FluentStorage.Blobs;
+
+namespace Elsa.Http.FileCaches;
+
+///
+/// A file cache that stores files in blob storage using FluentStorage.
+///
+public class BlobFileCacheStorageProvider : IFileCacheStorageProvider
+{
+ private readonly IBlobStorage _blobStorage;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public BlobFileCacheStorageProvider(IBlobStorage blobStorage)
+ {
+ _blobStorage = blobStorage;
+ }
+
+ ///
+ public IBlobStorage GetStorage()
+ {
+ return _blobStorage;
+ }
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Models/Downloadable.cs b/src/modules/Elsa.Http/Models/Downloadable.cs
index fe53677e6..151647676 100644
--- a/src/modules/Elsa.Http/Models/Downloadable.cs
+++ b/src/modules/Elsa.Http/Models/Downloadable.cs
@@ -21,11 +21,13 @@ public class Downloadable
/// The stream to download.
/// The filename to use when downloading the stream.
/// The content type to use when downloading the stream.
- public Downloadable(Stream stream, string? filename = default, string? contentType = default)
+ /// The ETag to use when downloading the stream.
+ public Downloadable(Stream stream, string? filename = default, string? contentType = default, string? eTag = default)
{
Stream = stream;
Filename = filename;
ContentType = contentType;
+ ETag = eTag;
}
///
@@ -42,4 +44,9 @@ public class Downloadable
/// The content type to use when downloading the stream.
///
public string? ContentType { get; set; }
+
+ ///
+ /// The ETag to use when downloading the stream.
+ ///
+ public string? ETag { get; set; }
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Options/DownloadableOptions.cs b/src/modules/Elsa.Http/Options/DownloadableOptions.cs
new file mode 100644
index 000000000..d66528fc5
--- /dev/null
+++ b/src/modules/Elsa.Http/Options/DownloadableOptions.cs
@@ -0,0 +1,19 @@
+using System.Net.Http.Headers;
+
+namespace Elsa.Http.Options;
+
+///
+/// Options for downloading a file.
+///
+public class DownloadableOptions
+{
+ ///
+ /// Gets or sets the entity tag.
+ ///
+ public EntityTagHeaderValue? ETag { get; set; }
+
+ ///
+ /// Gets or sets the range.
+ ///
+ public RangeHeaderValue? Range { get; set; }
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Options/FileDownloadOptions.cs b/src/modules/Elsa.Http/Options/FileDownloadOptions.cs
new file mode 100644
index 000000000..2d99499f9
--- /dev/null
+++ b/src/modules/Elsa.Http/Options/FileDownloadOptions.cs
@@ -0,0 +1,19 @@
+using System.Net.Http.Headers;
+
+namespace Elsa.Http.Options;
+
+///
+/// Options for downloading a file.
+///
+public class FileDownloadOptions
+{
+ ///
+ /// Gets or sets the entity tag.
+ ///
+ public EntityTagHeaderValue? ETag { get; set; }
+
+ ///
+ /// Gets or sets the range.
+ ///
+ public RangeHeaderValue? Range { get; set; }
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Options/HttpFileCacheOptions.cs b/src/modules/Elsa.Http/Options/HttpFileCacheOptions.cs
new file mode 100644
index 000000000..2aa18777e
--- /dev/null
+++ b/src/modules/Elsa.Http/Options/HttpFileCacheOptions.cs
@@ -0,0 +1,12 @@
+namespace Elsa.Http.Options;
+
+///
+/// Provides options for the HTTP file cache.
+///
+public class HttpFileCacheOptions
+{
+ ///
+ /// The time to live for cached files.
+ ///
+ public TimeSpan TimeToLive { get; set; } = TimeSpan.FromDays(7);
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Services/DefaultDownloadableManager.cs b/src/modules/Elsa.Http/Services/DefaultDownloadableManager.cs
index b646b5c96..5e7ad3fe8 100644
--- a/src/modules/Elsa.Http/Services/DefaultDownloadableManager.cs
+++ b/src/modules/Elsa.Http/Services/DefaultDownloadableManager.cs
@@ -1,33 +1,35 @@
using Elsa.Http.Contexts;
using Elsa.Http.Contracts;
using Elsa.Http.Models;
+using Elsa.Http.Options;
namespace Elsa.Http.Services;
///
public class DefaultDownloadableManager : IDownloadableManager
{
- private readonly IEnumerable _providers;
+ private readonly IEnumerable _providers;
///
/// Initializes a new instance of the class.
///
- public DefaultDownloadableManager(IEnumerable providers)
+ public DefaultDownloadableManager(IEnumerable providers)
{
_providers = providers.OrderBy(x => x.Priority).ToList();
}
///
- public async ValueTask> GetDownloadablesAsync(object content, CancellationToken cancellationToken = default)
+ public IEnumerable>> GetDownloadablesAsync(object content, DownloadableOptions? options = default, CancellationToken cancellationToken = default)
{
var provider = _providers.FirstOrDefault(x => x.GetSupportsContent(content));
-
+
if (provider == null)
- return Enumerable.Empty();
-
- var context = new DownloadableContext(this, content, cancellationToken);
- var downloadables = await provider.GetDownloadablesAsync(context);
-
+ return Enumerable.Empty>>();
+
+ options ??= new();
+ var context = new DownloadableContext(this, content, options, cancellationToken);
+ var downloadables = provider.GetDownloadablesAsync(context);
+
return downloadables;
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Services/HttpClientFileDownloader.cs b/src/modules/Elsa.Http/Services/HttpClientFileDownloader.cs
index 1a751b642..d1950d65c 100644
--- a/src/modules/Elsa.Http/Services/HttpClientFileDownloader.cs
+++ b/src/modules/Elsa.Http/Services/HttpClientFileDownloader.cs
@@ -1,4 +1,5 @@
using Elsa.Http.Contracts;
+using Elsa.Http.Options;
namespace Elsa.Http.Services;
@@ -18,8 +19,16 @@ public class HttpClientFileDownloader : IFileDownloader
}
///
- public async Task DownloadAsync(Uri url, CancellationToken cancellationToken = default)
+ public async Task DownloadAsync(Uri url, FileDownloadOptions? options = default, CancellationToken cancellationToken = default)
{
- return await _httpClient.GetAsync(url, cancellationToken);
+ var request = new HttpRequestMessage(HttpMethod.Get, url);
+
+ if (options?.ETag != null)
+ request.Headers.IfNoneMatch.Add(options.ETag);
+
+ if(options?.Range != null)
+ request.Headers.Range = options.Range;
+
+ return await _httpClient.SendAsync(request, cancellationToken);
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Services/ZipManager.cs b/src/modules/Elsa.Http/Services/ZipManager.cs
new file mode 100644
index 000000000..559a6a2cc
--- /dev/null
+++ b/src/modules/Elsa.Http/Services/ZipManager.cs
@@ -0,0 +1,188 @@
+using System.IO.Compression;
+using Elsa.Common.Contracts;
+using Elsa.Http.Contracts;
+using Elsa.Http.Models;
+using Elsa.Http.Options;
+using FluentStorage.Blobs;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Elsa.Http.Services;
+
+///
+/// Provides a helper service for zipping downloadable content.
+///
+internal class ZipManager
+{
+ private readonly ISystemClock _clock;
+ private readonly IFileCacheStorageProvider _fileCacheStorageProvider;
+ private readonly IOptions _fileCacheOptions;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ZipManager(ISystemClock clock, IFileCacheStorageProvider fileCacheStorageProvider, IOptions fileCacheOptions, ILogger logger)
+ {
+ _clock = clock;
+ _fileCacheStorageProvider = fileCacheStorageProvider;
+ _fileCacheOptions = fileCacheOptions;
+ _logger = logger;
+ }
+
+ public async Task<(Blob, Stream, Action)> CreateAsync(
+ ICollection>> downloadables,
+ bool enableResumableDownloads,
+ string? downloadCorrelationId,
+ string? downloadAsFilename = default,
+ string? contentType = default,
+ CancellationToken cancellationToken = default)
+ {
+ // Create a temporary file.
+ var tempFilePath = Path.GetTempFileName();
+
+ // Create a zip archive from the downloadables.
+ await CreateZipArchiveAsync(tempFilePath, downloadables, cancellationToken);
+
+ // Create a blob with metadata for resuming the download.
+ var zipBlob = CreateBlob(tempFilePath, downloadAsFilename, contentType);
+
+ // If resumable downloads are enabled, cache the file.
+ if (enableResumableDownloads && !string.IsNullOrWhiteSpace(downloadCorrelationId))
+ await CreateCachedZipBlobAsync(tempFilePath, downloadCorrelationId, downloadAsFilename, contentType, cancellationToken);
+
+ var zipStream = File.OpenRead(tempFilePath);
+ return (zipBlob, zipStream, () => Cleanup(tempFilePath));
+ }
+
+ ///
+ /// Loads a cached zip blob for the specified download correlation ID.
+ ///
+ /// The download correlation ID.
+ /// An optional cancellation token.
+ /// A tuple containing the blob and the stream.
+ public async Task<(Blob, Stream)?> LoadAsync(string downloadCorrelationId, CancellationToken cancellationToken = default)
+ {
+ var fileCacheStorage = _fileCacheStorageProvider.GetStorage();
+ var fileCacheFilename = $"{downloadCorrelationId}.tmp";
+ var blob = await fileCacheStorage.GetBlobAsync(fileCacheFilename, cancellationToken);
+
+ if (blob == null)
+ return null;
+
+ // Check if the blob has expired.
+ var expiresAt = DateTimeOffset.Parse(blob.Metadata["ExpiresAt"]);
+
+ if (_clock.UtcNow > expiresAt)
+ {
+ // File expired. Try to delete it.
+ try
+ {
+ await fileCacheStorage.DeleteAsync(blob.FullPath, cancellationToken);
+ }
+ catch (Exception e)
+ {
+ _logger.LogWarning(e, "Failed to delete expired file {FullPath}", blob.FullPath);
+ }
+
+ return null;
+ }
+
+ var stream = await fileCacheStorage.OpenReadAsync(blob.FullPath, cancellationToken);
+ return (blob, stream);
+ }
+
+ ///
+ /// Creates a zip archive from the specified instances.
+ ///
+ private async Task CreateZipArchiveAsync(string filePath, IEnumerable>> downloadables, CancellationToken cancellationToken = default)
+ {
+ var currentFileIndex = 0;
+
+ // Write the zip archive to the temporary file.
+ await using var tempFileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize: 4096, useAsync: true);
+
+ using var zipArchive = new ZipArchive(tempFileStream, ZipArchiveMode.Create, true);
+ foreach (var downloadableFunc in downloadables)
+ {
+ var downloadable = await downloadableFunc();
+ var entryName = !string.IsNullOrWhiteSpace(downloadable.Filename) ? downloadable.Filename : $"file-{currentFileIndex}.bin";
+ var entry = zipArchive.CreateEntry(entryName);
+ var fileStream = downloadable.Stream;
+ await using var entryStream = entry.Open();
+ await fileStream.CopyToAsync(entryStream, cancellationToken);
+ await entryStream.FlushAsync(cancellationToken);
+ entryStream.Close();
+ currentFileIndex++;
+ }
+ }
+
+ ///
+ /// Creates a cached zip blob for the specified file.
+ ///
+ /// The full path of the file to upload.
+ /// The download correlation ID.
+ /// The filename to use when downloading the file.
+ /// The content type of the file.
+ /// An optional cancellation token.
+ private async Task CreateCachedZipBlobAsync(string localPath, string downloadCorrelationId, string? downloadAsFilename = default, string? contentType = default, CancellationToken cancellationToken = default)
+ {
+ var fileCacheStorage = _fileCacheStorageProvider.GetStorage();
+ var fileCacheFilename = $"{downloadCorrelationId}.tmp";
+ var expiresAt = _clock.UtcNow.Add(_fileCacheOptions.Value.TimeToLive);
+ var cachedBlob = CreateBlob(fileCacheFilename, downloadAsFilename, contentType, expiresAt);
+ await fileCacheStorage.WriteFileAsync(fileCacheFilename, localPath, cancellationToken);
+ await fileCacheStorage.SetBlobAsync(cachedBlob, cancellationToken: cancellationToken);
+ }
+
+ ///
+ /// Creates a blob for the specified file.
+ ///
+ /// The full path of the file.
+ /// The filename to use when downloading the file.
+ /// The content type of the file.
+ /// The date and time at which the file expires.
+ /// The blob.
+ private Blob CreateBlob(string fullPath, string? downloadAsFilename, string? contentType, DateTimeOffset? expiresAt = default)
+ {
+ (downloadAsFilename, contentType) = GetDownloadableMetadata(downloadAsFilename, contentType);
+
+ var now = _clock.UtcNow;
+
+ var blob = new Blob(fullPath)
+ {
+ Metadata =
+ {
+ ["ContentType"] = contentType,
+ ["Filename"] = downloadAsFilename
+ },
+ CreatedTime = now,
+ LastModificationTime = now
+ };
+
+ if(expiresAt.HasValue)
+ blob.Metadata["ExpiresAt"] = expiresAt.Value.ToString("O");
+
+ return blob;
+ }
+
+ private (string downloadAsFilename, string contentType) GetDownloadableMetadata(string? contentType, string? downloadAsFilename)
+ {
+ contentType = !string.IsNullOrWhiteSpace(contentType) ? contentType : System.Net.Mime.MediaTypeNames.Application.Zip;
+ downloadAsFilename = !string.IsNullOrWhiteSpace(downloadAsFilename) ? downloadAsFilename : "download.zip";
+
+ return (downloadAsFilename, contentType);
+ }
+
+ private void Cleanup(string filePath)
+ {
+ try
+ {
+ File.Delete(filePath);
+ }
+ catch (Exception e)
+ {
+ _logger.LogWarning(e, "Failed to delete temporary file {TempFilePath}", filePath);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Elsa.WorkflowProviders.BlobStorage.csproj b/src/modules/Elsa.WorkflowProviders.BlobStorage/Elsa.WorkflowProviders.BlobStorage.csproj
index 237dbd422..ced61c1bd 100644
--- a/src/modules/Elsa.WorkflowProviders.BlobStorage/Elsa.WorkflowProviders.BlobStorage.csproj
+++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Elsa.WorkflowProviders.BlobStorage.csproj
@@ -1,6 +1,7 @@
+
@@ -15,8 +16,4 @@
-
-
-
-
diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Extensions/ModuleExtensions.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Extensions/ModuleExtensions.cs
index 34c0b19c3..3fb0c3347 100644
--- a/src/modules/Elsa.WorkflowProviders.BlobStorage/Extensions/ModuleExtensions.cs
+++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Extensions/ModuleExtensions.cs
@@ -29,7 +29,7 @@ public static class ModuleExtensions
/// The module.
/// The configuration delegate.
/// The module.
- public static IModule UseFluentStorageProvider(this IModule module, Action? configure = default)
+ public static IModule UseFluentStorageProvider(this IModule module, Action? configure = default)
{
module.Use(configure);
return module;
diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Features/FluentStorageFeature.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Features/BlobStorageFeature.cs
similarity index 90%
rename from src/modules/Elsa.WorkflowProviders.BlobStorage/Features/FluentStorageFeature.cs
rename to src/modules/Elsa.WorkflowProviders.BlobStorage/Features/BlobStorageFeature.cs
index 2ef5f6e8e..1237e0070 100644
--- a/src/modules/Elsa.WorkflowProviders.BlobStorage/Features/FluentStorageFeature.cs
+++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Features/BlobStorageFeature.cs
@@ -7,7 +7,6 @@ using Elsa.WorkflowProviders.BlobStorage.Providers;
using Elsa.Workflows.Management.Features;
using FluentStorage;
using FluentStorage.Blobs;
-using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.WorkflowProviders.BlobStorage.Features;
@@ -17,11 +16,10 @@ namespace Elsa.WorkflowProviders.BlobStorage.Features;
///
[DependsOn(typeof(WorkflowManagementFeature))]
[DependsOn(typeof(DslIntegrationFeature))]
-[PublicAPI]
-public class FluentStorageFeature : FeatureBase
+public class BlobStorageFeature : FeatureBase
{
///
- public FluentStorageFeature(IModule module) : base(module)
+ public BlobStorageFeature(IModule module) : base(module)
{
}
diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Endpoint.cs
index 0d98dfd54..e395dbb4e 100644
--- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Endpoint.cs
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Endpoint.cs
@@ -44,7 +44,8 @@ internal class Execute : ElsaEndpoint
///
public override void Configure()
{
- Post("/workflow-definitions/{definitionId}/execute");
+ Routes("/workflow-definitions/{definitionId}/execute");
+ Verbs(FastEndpoints.Http.GET, FastEndpoints.Http.POST);
ConfigurePermissions("exec:workflow-definitions");
}
@@ -102,7 +103,10 @@ internal class Execute : ElsaEndpoint
{
// Write a response header to indicate that the response is a workflow state response.
HttpContext.Response.Headers.Add("x-elsa-response", "true");
- await SendOkAsync(new Response(workflowState), cancellationToken);
+
+ // Only write a response if the status code wasn't changed by the workflow.
+ if (HttpContext.Response.StatusCode == StatusCodes.Status200OK)
+ await SendOkAsync(new Response(workflowState), cancellationToken);
}
}
}