From cb87d2ed82025fa014bc3c6ca1504a6197f7d127 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 17 Sep 2023 22:35:13 +0200 Subject: [PATCH] Implement resumable downloads (#4448) * Incremental work on persistent file cache * Rename providers to handlers * Implement zip file caching for resumable downloads * Implement partial download for zipped files * Allow executing workflows using GET verb * Implement custom header for correlating downloads * Rename FileName to Filename for consistency * Update interface XML comment * Cleanup * Fix that source Downloadables were executed even when cached zip exists * Make download correlation ID optional --- Elsa.sln | 1 + packages.props | 5 + ...e.cs => DownloadableContentHandlerBase.cs} | 18 +- .../Activities/WriteFileHttpResponse.cs | 209 +++++++++++------- .../Elsa.Http/Contexts/DownloadableContext.cs | 8 +- .../Contracts/IAbsoluteUrlProvider.cs | 8 + ...ider.cs => IDownloadableContentHandler.cs} | 4 +- .../Contracts/IDownloadableManager.cs | 3 +- .../Contracts/IFileCacheStorageProvider.cs | 15 ++ .../Elsa.Http/Contracts/IFileDownloader.cs | 4 +- .../BinaryDownloadableContentHandler.cs} | 6 +- ...DownloadableDownloadableContentHandler.cs} | 4 +- .../MultiDownloadableContentHandler.cs} | 10 +- .../StreamDownloadableContentHandler.cs} | 4 +- .../UrlDownloadableContentHandler.cs} | 27 ++- src/modules/Elsa.Http/Elsa.Http.csproj | 1 + src/modules/Elsa.Http/Features/HttpFeature.cs | 51 +++-- .../BlobFileCacheStorageProvider.cs | 26 +++ src/modules/Elsa.Http/Models/Downloadable.cs | 9 +- .../Elsa.Http/Options/DownloadableOptions.cs | 19 ++ .../Elsa.Http/Options/FileDownloadOptions.cs | 19 ++ .../Elsa.Http/Options/HttpFileCacheOptions.cs | 12 + .../Services/DefaultDownloadableManager.cs | 20 +- .../Services/HttpClientFileDownloader.cs | 13 +- src/modules/Elsa.Http/Services/ZipManager.cs | 188 ++++++++++++++++ .../Elsa.WorkflowProviders.BlobStorage.csproj | 5 +- .../Extensions/ModuleExtensions.cs | 2 +- ...torageFeature.cs => BlobStorageFeature.cs} | 6 +- .../WorkflowDefinitions/Execute/Endpoint.cs | 8 +- 29 files changed, 558 insertions(+), 147 deletions(-) create mode 100644 packages.props rename src/modules/Elsa.Http/Abstractions/{DownloadableProviderBase.cs => DownloadableContentHandlerBase.cs} (59%) rename src/modules/Elsa.Http/Contracts/{IDownloadableProvider.cs => IDownloadableContentHandler.cs} (82%) create mode 100644 src/modules/Elsa.Http/Contracts/IFileCacheStorageProvider.cs rename src/modules/Elsa.Http/{DownloadableProviders/BinaryDownloadableProvider.cs => DownloadableContentHandlers/BinaryDownloadableContentHandler.cs} (76%) rename src/modules/Elsa.Http/{DownloadableProviders/DownloadableDownloadableProvider.cs => DownloadableContentHandlers/DownloadableDownloadableContentHandler.cs} (76%) rename src/modules/Elsa.Http/{DownloadableProviders/MultiDownloadableProvider.cs => DownloadableContentHandlers/MultiDownloadableContentHandler.cs} (65%) rename src/modules/Elsa.Http/{DownloadableProviders/StreamDownloadableProvider.cs => DownloadableContentHandlers/StreamDownloadableContentHandler.cs} (82%) rename src/modules/Elsa.Http/{DownloadableProviders/UrlDownloadableProvider.cs => DownloadableContentHandlers/UrlDownloadableContentHandler.cs} (66%) create mode 100644 src/modules/Elsa.Http/FileCaches/BlobFileCacheStorageProvider.cs create mode 100644 src/modules/Elsa.Http/Options/DownloadableOptions.cs create mode 100644 src/modules/Elsa.Http/Options/FileDownloadOptions.cs create mode 100644 src/modules/Elsa.Http/Options/HttpFileCacheOptions.cs create mode 100644 src/modules/Elsa.Http/Services/ZipManager.cs rename src/modules/Elsa.WorkflowProviders.BlobStorage/Features/{FluentStorageFeature.cs => BlobStorageFeature.cs} (90%) 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 Content { get; set; } = default!; + /// + /// Whether to enable resumable downloads. When enabled, the client can resume a download if the connection is lost. + /// + [Input(Description = "Whether to enable resumable downloads. When enabled, the client can resume a download if the connection is lost.")] + public Input EnableResumableDownloads { get; set; } = default!; + + /// + /// The correlation ID of the download. Used to resume a download. + /// + [Input(Description = "The correlation ID of the download used to resume a download. If left empty, the x-elsa-download-id header will be used.")] + public Input DownloadCorrelationId { get; set; } = default!; + /// protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { @@ -60,32 +82,25 @@ public class WriteFileHttpResponse : Activity private async Task WriteResponseAsync(ActivityExecutionContext context, HttpContext httpContext) { - // Set status code. - var statusCode = HttpStatusCode.OK; - var response = httpContext.Response; - response.StatusCode = (int)statusCode; - // Get content and content type. var content = context.Get(Content); - if (content == null) - return; - // Write content. - var downloadables = await GetDownloadablesAsync(context, content); + var downloadables = GetDownloadables(context, httpContext, content).ToList(); await SendDownloadablesAsync(context, httpContext, downloadables); // Complete activity. await context.CompleteActivityAsync(); } - private async Task SendDownloadablesAsync(ActivityExecutionContext context, HttpContext httpContext, IEnumerable downloadables) + private async Task SendDownloadablesAsync(ActivityExecutionContext context, HttpContext httpContext, IEnumerable>> downloadables) { var downloadableList = downloadables.ToList(); switch (downloadableList.Count) { case 0: + SendNoContent(context, httpContext); return; case 1: { @@ -99,81 +114,140 @@ public class WriteFileHttpResponse : Activity } } - private async Task SendSingleFileAsync(ActivityExecutionContext context, HttpContext httpContext, Downloadable downloadable) + private void SendNoContent(ActivityExecutionContext context, HttpContext httpContext) { - var contentType = ContentType.GetOrDefault(context); - var filename = FileName.GetOrDefault(context); - filename = !string.IsNullOrWhiteSpace(filename) ? filename : !string.IsNullOrWhiteSpace(downloadable.Filename) ? downloadable.Filename : "file.bin"; - contentType = !string.IsNullOrWhiteSpace(contentType) ? contentType : !string.IsNullOrWhiteSpace(downloadable.ContentType) ? downloadable.ContentType : GetContentType(context, filename); - await SendFileStream(httpContext, downloadable.Stream, contentType, filename); + httpContext.Response.StatusCode = StatusCodes.Status204NoContent; } - private async Task SendMultipleFilesAsync(ActivityExecutionContext context, HttpContext httpContext, ICollection downloadables) + private async Task SendSingleFileAsync(ActivityExecutionContext context, HttpContext httpContext, Func> downloadableFunc) { - var logger = context.GetRequiredService>(); - - // 1. Create a temporary file - var tempFilePath = Path.GetTempFileName(); - var currentFileIndex = 0; - - // 2. Write the zip archive to the temporary file - await using var tempFileStream = new FileStream(tempFilePath, FileMode.Create); - using var zipArchive = new ZipArchive(tempFileStream, ZipArchiveMode.Create, true); - - foreach (var downloadable in downloadables) - { - 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); - await entryStream.FlushAsync(); - entryStream.Close(); - currentFileIndex++; - } - var contentType = ContentType.GetOrDefault(context); - var filename = FileName.GetOrDefault(context); + var filename = Filename.GetOrDefault(context); + var eTag = EntityTag.GetOrDefault(context); + var downloadable = await downloadableFunc(); + filename = !string.IsNullOrWhiteSpace(filename) ? filename : !string.IsNullOrWhiteSpace(downloadable.Filename) ? downloadable.Filename : "file.bin"; + contentType = !string.IsNullOrWhiteSpace(contentType) ? contentType : !string.IsNullOrWhiteSpace(downloadable.ContentType) ? downloadable.ContentType : GetContentType(context, filename); + eTag = !string.IsNullOrWhiteSpace(eTag) ? eTag : !string.IsNullOrWhiteSpace(downloadable.ETag) ? downloadable.ETag : default; + + var eTagHeaderValue = !string.IsNullOrWhiteSpace(eTag) ? new EntityTagHeaderValue(eTag) : default; + var stream = downloadable.Stream; + await SendFileStream(context, httpContext, stream, contentType, filename, eTagHeaderValue); + } + + private async Task SendMultipleFilesAsync(ActivityExecutionContext context, HttpContext httpContext, ICollection>> downloadables) + { + // If resumable downloads are enabled, check to see if we have a cached file. + var (zipBlob, zipStream, cleanupCallback) = await TryLoadCachedFileAsync(context, httpContext) ?? await GenerateZipFileAsync(context, httpContext, downloadables); - contentType = !string.IsNullOrWhiteSpace(contentType) ? contentType : System.Net.Mime.MediaTypeNames.Application.Zip; - filename = !string.IsNullOrWhiteSpace(filename) ? filename : "download.zip"; - - // 3. Use FileStreamResult to stream the temporary file back to the client - var resultStream = new FileStream(tempFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true); - await SendFileStream(httpContext, resultStream, contentType, filename); - - // 4. Delete the temporary file. try { - File.Delete(tempFilePath); + // Send the zip stream the temporary file back to the client. + var contentType = zipBlob.Metadata["ContentType"]; + var downloadAsFilename = zipBlob.Metadata["Filename"]; + var eTag = $"\"{zipBlob.LastModificationTime?.ToString("O")}\""; + var eTagHeaderValue = new EntityTagHeaderValue(eTag); + await SendFileStream(context, httpContext, zipStream, contentType, downloadAsFilename, eTagHeaderValue); + + // TODO: Delete the cached file after the workflow completes. } catch (Exception e) { - logger.LogWarning(e, "Failed to delete temporary file {TempFilePath}", tempFilePath); + var logger = context.GetRequiredService>(); + logger.LogWarning(e, "Failed to send zip file to HTTP response"); + } + finally + { + // Delete any temporary files. + await cleanupCallback(); } } - private async Task SendFileStream(HttpContext httpContext, Stream source, string contentType, string filename) + private async Task<(Blob, Stream, Func)> GenerateZipFileAsync(ActivityExecutionContext context, HttpContext httpContext, ICollection>> downloadables) + { + var cancellationToken = context.CancellationToken; + var enableResumableDownloads = EnableResumableDownloads.GetOrDefault(context, () => false); + var downloadCorrelationId = GetDownloadCorrelationId(context, httpContext); + var contentType = ContentType.GetOrDefault(context); + var downloadAsFilename = Filename.GetOrDefault(context); + var zipService = context.GetRequiredService(); + var (zipBlob, zipStream, cleanup) = await zipService.CreateAsync(downloadables, enableResumableDownloads, downloadCorrelationId, downloadAsFilename, contentType, cancellationToken); + + return (zipBlob, zipStream, Cleanup); + + ValueTask Cleanup() + { + cleanup(); + return default; + } + } + + private async Task<(Blob, Stream, Func)?> TryLoadCachedFileAsync(ActivityExecutionContext context, HttpContext httpContext) + { + var enableResumableDownloads = EnableResumableDownloads.GetOrDefault(context, () => false); + var downloadCorrelationId = GetDownloadCorrelationId(context, httpContext); + + if (!enableResumableDownloads || string.IsNullOrWhiteSpace(downloadCorrelationId)) + return null; + + var cancellationToken = context.CancellationToken; + var zipService = context.GetRequiredService(); + var tuple = await zipService.LoadAsync(downloadCorrelationId, cancellationToken); + + if (tuple == null) + return null; + + return (tuple.Value.Item1, tuple.Value.Item2, Noop); + + ValueTask Noop() => default; + } + + private string GetDownloadCorrelationId(ActivityExecutionContext context, HttpContext httpContext) + { + var downloadCorrelationId = DownloadCorrelationId.GetOrDefault(context); + + if (string.IsNullOrWhiteSpace(downloadCorrelationId)) + downloadCorrelationId = httpContext.Request.Headers["x-elsa-download-id"]; + + if (string.IsNullOrWhiteSpace(downloadCorrelationId)) + { + var identity = context.WorkflowExecutionContext.Workflow.Identity; + var definitionId = identity.DefinitionId; + var version = identity.Version.ToString(); + var correlationId = context.WorkflowExecutionContext.CorrelationId; + var sources = new[] { definitionId, version, correlationId }.Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); + + downloadCorrelationId = string.Join("-", sources); + } + + return downloadCorrelationId; + } + + private async Task SendFileStream(ActivityExecutionContext context, HttpContext httpContext, Stream source, string contentType, string filename, EntityTagHeaderValue? eTag) { source.Seek(0, SeekOrigin.Begin); - + var result = new FileStreamResult(source, contentType) { EnableRangeProcessing = true, + EntityTag = eTag != null ? new Microsoft.Net.Http.Headers.EntityTagHeaderValue(eTag.ToString()) : default, FileDownloadName = filename }; - + var actionContext = new ActionContext(httpContext, httpContext.GetRouteData(), new ActionDescriptor()); await result.ExecuteResultAsync(actionContext); } - /// - /// Leverages the to get a list of instances from the . - /// - private async Task> GetDownloadablesAsync(ActivityExecutionContext context, object content) + private IEnumerable>> GetDownloadables(ActivityExecutionContext context, HttpContext httpContext, object? content) { + if (content == null) + return Enumerable.Empty>>(); + var manager = context.GetRequiredService(); - return await manager.GetDownloadablesAsync(content, context.CancellationToken); + var headers = httpContext.Request.Headers; + var eTag = headers.TryGetValue(HeaderNames.IfMatch, out var header) ? new EntityTagHeaderValue(header.ToString()) : default; + var range = headers.TryGetValue(HeaderNames.Range, out header) ? RangeHeaderValue.Parse(header.ToString()) : default; + var options = new DownloadableOptions { ETag = eTag, Range = range }; + return manager.GetDownloadablesAsync(content, options, context.CancellationToken); } private string GetContentType(ActivityExecutionContext context, string filename) @@ -182,26 +256,13 @@ public class WriteFileHttpResponse : Activity return provider.TryGetContentType(filename, out var contentType) ? contentType : System.Net.Mime.MediaTypeNames.Application.Octet; } - private static string CreateContentDisposition(string filename) - { - var contentDisposition = new System.Net.Mime.ContentDisposition - { - FileName = filename - }; - - return contentDisposition.ToString(); - } - private async ValueTask OnResumeAsync(ActivityExecutionContext context) { var httpContextAccessor = context.GetRequiredService(); var httpContext = httpContextAccessor.HttpContext; if (httpContext == null) - { - // We're not in an HTTP context, so let's fail. throw new FaultException("Cannot execute in a non-HTTP context"); - } await WriteResponseAsync(context, httpContext); } diff --git a/src/modules/Elsa.Http/Contexts/DownloadableContext.cs b/src/modules/Elsa.Http/Contexts/DownloadableContext.cs index 5678f24ce..4b72f02ef 100644 --- a/src/modules/Elsa.Http/Contexts/DownloadableContext.cs +++ b/src/modules/Elsa.Http/Contexts/DownloadableContext.cs @@ -1,4 +1,5 @@ using Elsa.Http.Contracts; +using Elsa.Http.Options; namespace Elsa.Http.Contexts; @@ -7,5 +8,10 @@ namespace Elsa.Http.Contexts; /// /// 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); } } }