< Summary

Information
Class: Elsa.Http.WriteFileHttpResponse
Assembly: Elsa.Http
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs
Line coverage
50%
Covered lines: 65
Uncovered lines: 64
Coverable lines: 129
Total lines: 308
Line coverage: 50.3%
Branch coverage
65%
Covered branches: 30
Total branches: 46
Branch coverage: 65.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs

#LineLine coverage
 1using System.Security.Cryptography;
 2using Elsa.Extensions;
 3using Elsa.Http.Exceptions;
 4using Elsa.Http.Options;
 5using Elsa.Http.Services;
 6using Elsa.Workflows;
 7using Elsa.Workflows.Attributes;
 8using Elsa.Workflows.Exceptions;
 9using Elsa.Workflows.Models;
 10using FluentStorage.Blobs;
 11using FluentStorage.Utils.Extensions;
 12using Microsoft.AspNetCore.Http;
 13using Microsoft.AspNetCore.Mvc;
 14using Microsoft.AspNetCore.Mvc.Abstractions;
 15using Microsoft.AspNetCore.Routing;
 16using Microsoft.AspNetCore.StaticFiles;
 17using Microsoft.Extensions.Logging;
 18using Microsoft.Net.Http.Headers;
 19using EntityTagHeaderValue = System.Net.Http.Headers.EntityTagHeaderValue;
 20using RangeHeaderValue = System.Net.Http.Headers.RangeHeaderValue;
 21
 22namespace Elsa.Http;
 23
 24/// <summary>
 25/// Sends a file to the HTTP response.
 26/// </summary>
 27[Activity("Elsa", "HTTP", "Send one ore more files (zipped) to the HTTP response.", DisplayName = "HTTP File Response")]
 28public class WriteFileHttpResponse : Activity
 29{
 30    /// <summary>
 31    /// The MIME type of the file to serve.
 32    /// </summary>
 33    [Input(Description = "The content type of the file to serve. Leave empty to let the system determine the content typ
 8234    public Input<string?> ContentType { get; set; } = null!;
 35
 36    /// <summary>
 37    /// The name of the file to serve.
 38    /// </summary>
 39    [Input(Description = "The name of the file to serve. Leave empty to let the system determine the file name.")]
 9340    public Input<string?> Filename { get; set; } = null!;
 41
 42    /// <summary>
 43    /// The Entity Tag of the file to serve.
 44    /// </summary>
 45    [Input(Description = "The Entity Tag of the file to serve. Leave empty to let the system determine the Entity Tag.")
 8346    public Input<string?> EntityTag { get; set; } = null!;
 47
 48    /// <summary>
 49    /// The file content to serve. Supports byte array, streams, string, Uri and an array of the aforementioned types.
 50    /// </summary>
 51    [Input(Description = "The file content to serve. Supports various types, such as byte array, stream, string, Uri, Do
 10452    public Input<object> Content { get; set; } = null!;
 53
 54    /// <summary>
 55    /// Whether to enable resumable downloads. When enabled, the client can resume a download if the connection is lost.
 56    /// </summary>
 57    [Input(Description = "Whether to enable resumable downloads. When enabled, the client can resume a download if the c
 8258    public Input<bool> EnableResumableDownloads { get; set; } = null!;
 59
 60    /// <summary>
 61    /// The correlation ID of the download. Used to resume a download.
 62    /// </summary>
 63    [Input(Description = "The correlation ID of the download used to resume a download. If left empty, the x-download-id
 5464    public Input<string> DownloadCorrelationId { get; set; } = null!;
 65
 66    /// <inheritdoc />
 67    protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
 68    {
 2769        var httpContextAccessor = context.GetRequiredService<IHttpContextAccessor>();
 2770        var httpContext = httpContextAccessor.HttpContext;
 71
 2772        if (httpContext == null)
 73        {
 74            // We're executing in a non-HTTP context (e.g. in a virtual actor).
 75            // Create a bookmark to allow the invoker to export the state and resume execution from there.
 76
 277            context.CreateBookmark(OnResumeAsync, BookmarkMetadata.HttpCrossBoundary);
 278            return;
 79        }
 80
 2581        await WriteResponseAsync(context, httpContext);
 2682    }
 83
 84    private async Task WriteResponseAsync(ActivityExecutionContext context, HttpContext httpContext)
 85    {
 86        // Get content and content type.
 2587        var content = context.Get(Content);
 88
 89        // Write content.
 2590        var downloadables = GetDownloadables(context, httpContext, content).ToList();
 2591        await SendDownloadablesAsync(context, httpContext, downloadables);
 92
 93        // Complete activity.
 2494        await context.CompleteActivityAsync();
 2495    }
 96
 97    private async Task SendDownloadablesAsync(ActivityExecutionContext context, HttpContext httpContext, IEnumerable<Fun
 98    {
 2599        var downloadableList = downloadables.ToList();
 100
 25101        switch (downloadableList.Count)
 102        {
 103            case 0:
 1104                SendNoContent(context, httpContext);
 1105                return;
 106            case 1:
 107            {
 24108                var downloadable = downloadableList[0];
 24109                await SendSingleFileAsync(context, httpContext, downloadable);
 23110                return;
 111            }
 112            default:
 0113                await SendMultipleFilesAsync(context, httpContext, downloadableList);
 114                break;
 115        }
 24116    }
 117
 118    private void SendNoContent(ActivityExecutionContext context, HttpContext httpContext)
 119    {
 1120        httpContext.Response.StatusCode = StatusCodes.Status204NoContent;
 1121    }
 122
 123    private async Task SendSingleFileAsync(ActivityExecutionContext context, HttpContext httpContext, Func<ValueTask<Dow
 124    {
 24125        var contentType = ContentType.GetOrDefault(context);
 24126        var filename = Filename.GetOrDefault(context);
 24127        var eTag = EntityTag.GetOrDefault(context);
 24128        var downloadable = await downloadableFunc();
 24129        filename = !string.IsNullOrWhiteSpace(filename) ? filename : !string.IsNullOrWhiteSpace(downloadable.Filename) ?
 24130        contentType = !string.IsNullOrWhiteSpace(contentType) ? contentType : !string.IsNullOrWhiteSpace(downloadable.Co
 24131        eTag = !string.IsNullOrWhiteSpace(eTag) ? eTag : !string.IsNullOrWhiteSpace(downloadable.ETag) ? downloadable.ET
 132
 24133        var eTagHeaderValue = !string.IsNullOrWhiteSpace(eTag) ? new EntityTagHeaderValue(eTag) : null;
 23134        var stream = downloadable.Stream;
 23135        await SendFileStream(context, httpContext, stream, contentType, filename, eTagHeaderValue);
 23136    }
 137
 138    private async Task SendMultipleFilesAsync(ActivityExecutionContext context, HttpContext httpContext, ICollection<Fun
 139    {
 140        // If resumable downloads are enabled, check to see if we have a cached file.
 0141        var (zipBlob, zipStream, cleanupCallback) = await TryLoadCachedFileAsync(context, httpContext) ?? await Generate
 142
 143        try
 144        {
 145            // Send the temporary file back to the client.
 0146            var contentType = zipBlob.Metadata["ContentType"];
 0147            var downloadAsFilename = zipBlob.Metadata["Filename"];
 0148            var hash = ComputeHash(zipStream);
 0149            var eTag = $"\"{hash}\"";
 0150            var eTagHeaderValue = new EntityTagHeaderValue(eTag);
 0151            await SendFileStream(context, httpContext, zipStream, contentType, downloadAsFilename, eTagHeaderValue);
 152
 153            // TODO: Delete the cached file after the workflow completes.
 0154        }
 0155        catch (Exception e)
 156        {
 0157            var logger = context.GetRequiredService<ILogger<WriteFileHttpResponse>>();
 0158            logger.LogWarning(e, "Failed to send zip file to HTTP response");
 0159        }
 160        finally
 161        {
 162            // Delete any temporary files.
 0163            await cleanupCallback();
 164        }
 0165    }
 166
 167    private string ComputeHash(Stream stream)
 168    {
 0169        stream.Seek(0, SeekOrigin.Begin);
 0170        var bytes = stream.ToByteArray()!;
 0171        using var md5Hash = MD5.Create();
 0172        var hash = md5Hash.ComputeHash(bytes);
 0173        stream.Seek(0, SeekOrigin.Begin);
 0174        return Convert.ToBase64String(hash);
 0175    }
 176
 177    private async Task<(Blob, Stream, Func<ValueTask>)> GenerateZipFileAsync(ActivityExecutionContext context, HttpConte
 178    {
 0179        var cancellationToken = context.CancellationToken;
 0180        var downloadCorrelationId = GetDownloadCorrelationId(context, httpContext);
 0181        var contentType = ContentType.GetOrDefault(context);
 0182        var downloadAsFilename = Filename.GetOrDefault(context);
 0183        var zipService = context.GetRequiredService<ZipManager>();
 0184        var (zipBlob, zipStream, cleanup) = await zipService.CreateAsync(downloadables, true, downloadCorrelationId, dow
 185
 0186        return (zipBlob, zipStream, Cleanup);
 187
 188        ValueTask Cleanup()
 189        {
 0190            cleanup();
 0191            return default;
 192        }
 0193    }
 194
 195    private async Task<(Blob, Stream, Func<ValueTask>)?> TryLoadCachedFileAsync(ActivityExecutionContext context, HttpCo
 196    {
 0197        var downloadCorrelationId = GetDownloadCorrelationId(context, httpContext);
 198
 0199        if (string.IsNullOrWhiteSpace(downloadCorrelationId))
 0200            return null;
 201
 0202        var cancellationToken = context.CancellationToken;
 0203        var zipService = context.GetRequiredService<ZipManager>();
 0204        var tuple = await zipService.LoadAsync(downloadCorrelationId, cancellationToken);
 205
 0206        if (tuple == null)
 0207            return null;
 208
 0209        return (tuple.Value.Item1, tuple.Value.Item2, Noop);
 210
 0211        ValueTask Noop() => default;
 0212    }
 213
 214    private string GetDownloadCorrelationId(ActivityExecutionContext context, HttpContext httpContext)
 215    {
 0216        var downloadCorrelationId = DownloadCorrelationId.GetOrDefault(context);
 217
 0218        if (string.IsNullOrWhiteSpace(downloadCorrelationId))
 0219            downloadCorrelationId = httpContext.Request.Headers["x-download-id"];
 220
 0221        if (string.IsNullOrWhiteSpace(downloadCorrelationId))
 222        {
 0223            var identity = context.WorkflowExecutionContext.Workflow.Identity;
 0224            var definitionId = identity.DefinitionId;
 0225            var version = identity.Version.ToString();
 0226            var correlationId = context.WorkflowExecutionContext.CorrelationId;
 0227            var sources = new[] { definitionId, version, correlationId }.Where(x => !string.IsNullOrWhiteSpace(x)).ToArr
 228
 0229            downloadCorrelationId = string.Join("-", sources);
 230        }
 231
 0232        return downloadCorrelationId;
 233    }
 234
 235    private async Task SendFileStream(ActivityExecutionContext context, HttpContext httpContext, Stream source, string c
 236    {
 23237        if(source.CanSeek)
 23238            source.Seek(0, SeekOrigin.Begin);
 239
 23240        var enableResumableDownloads = EnableResumableDownloads.GetOrDefault(context, () => false);
 241
 23242        var result = new FileStreamResult(source, contentType)
 23243        {
 23244            EnableRangeProcessing = enableResumableDownloads,
 23245            EntityTag = enableResumableDownloads ? eTag != null ? new Microsoft.Net.Http.Headers.EntityTagHeaderValue(eT
 23246            FileDownloadName = filename
 23247        };
 248
 23249        var actionContext = new ActionContext(httpContext, httpContext.GetRouteData(), new ActionDescriptor());
 23250        await result.ExecuteResultAsync(actionContext);
 23251    }
 252
 253    private IEnumerable<Func<ValueTask<Downloadable>>> GetDownloadables(ActivityExecutionContext context, HttpContext ht
 254    {
 25255        if (content == null)
 1256            return Enumerable.Empty<Func<ValueTask<Downloadable>>>();
 257
 24258        var manager = context.GetRequiredService<IDownloadableManager>();
 24259        var headers = httpContext.Request.Headers;
 24260        var eTag = GetIfMatchHeaderValue(headers);
 24261        var range = GetRangeHeaderHeaderValue(headers);
 24262        var options = new DownloadableOptions { ETag = eTag, Range = range };
 24263        return manager.GetDownloadablesAsync(content, options, context.CancellationToken);
 264    }
 265
 266    private string GetContentType(ActivityExecutionContext context, string filename)
 267    {
 17268        var provider = context.GetRequiredService<IContentTypeProvider>();
 17269        return provider.TryGetContentType(filename, out var contentType) ? contentType : System.Net.Mime.MediaTypeNames.
 270    }
 271
 272    private static RangeHeaderValue? GetRangeHeaderHeaderValue(IHeaderDictionary headers)
 273    {
 274        try
 275        {
 24276            return headers.TryGetValue(HeaderNames.Range, out var header) ? RangeHeaderValue.Parse(header.ToString()) : 
 277
 278        }
 0279        catch (Exception e)
 280        {
 0281            throw new HttpBadRequestException("Failed to parse Range header value", e);
 282        }
 24283    }
 284
 285    private static EntityTagHeaderValue? GetIfMatchHeaderValue(IHeaderDictionary headers)
 286    {
 287        try
 288        {
 24289            return headers.TryGetValue(HeaderNames.IfMatch, out var header) ? new EntityTagHeaderValue(header.ToString()
 290
 291        }
 0292        catch (Exception e)
 293        {
 0294            throw new HttpBadRequestException("Failed to parse If-Match header value", e);
 295        }
 24296    }
 297
 298    private async ValueTask OnResumeAsync(ActivityExecutionContext context)
 299    {
 0300        var httpContextAccessor = context.GetRequiredService<IHttpContextAccessor>();
 0301        var httpContext = httpContextAccessor.HttpContext;
 302
 0303        if (httpContext == null)
 0304            throw new FaultException(HttpFaultCodes.NoHttpContext, HttpFaultCategories.Http, DefaultFaultTypes.System, "
 305
 0306        await WriteResponseAsync(context, httpContext);
 0307    }
 308}