using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Threading.Tasks; using Elsa.Extensions; using Elsa.Http.Contracts; using Elsa.Http.Exceptions; 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 FluentStorage.Utils.Extensions; 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; /// /// Sends a file to the HTTP response. /// [Activity("Elsa", "HTTP", "Send one ore more files (zipped) to the HTTP response.", DisplayName = "HTTP File Response")] public class WriteFileHttpResponse : Activity { /// /// The MIME type of the file to serve. /// [Input(Description = "The content type of the file to serve. Leave empty to let the system determine the content type.")] public Input ContentType { get; set; } = default!; /// /// 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!; /// /// 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. /// [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-download-id header will be used.")] public Input DownloadCorrelationId { get; set; } = default!; /// protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { var httpContextAccessor = context.GetRequiredService(); var httpContext = httpContextAccessor.HttpContext; if (httpContext == null) { // We're executing in a non-HTTP context (e.g. in a virtual actor). // Create a bookmark to allow the invoker to export the state and resume execution from there. context.CreateBookmark(OnResumeAsync, BookmarkMetadata.HttpCrossBoundary); return; } await WriteResponseAsync(context, httpContext); } private async Task WriteResponseAsync(ActivityExecutionContext context, HttpContext httpContext) { // Get content and content type. var content = context.Get(Content); // Write 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) { var downloadableList = downloadables.ToList(); switch (downloadableList.Count) { case 0: SendNoContent(context, httpContext); return; case 1: { var downloadable = downloadableList[0]; await SendSingleFileAsync(context, httpContext, downloadable); return; } default: await SendMultipleFilesAsync(context, httpContext, downloadableList); break; } } private void SendNoContent(ActivityExecutionContext context, HttpContext httpContext) { httpContext.Response.StatusCode = StatusCodes.Status204NoContent; } private async Task SendSingleFileAsync(ActivityExecutionContext context, HttpContext httpContext, Func> downloadableFunc) { var contentType = ContentType.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); try { // Send the temporary file back to the client. var contentType = zipBlob.Metadata["ContentType"]; var downloadAsFilename = zipBlob.Metadata["Filename"]; var hash = ComputeHash(zipStream); var eTag = $"\"{hash}\""; 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) { var logger = context.GetRequiredService>(); logger.LogWarning(e, "Failed to send zip file to HTTP response"); } finally { // Delete any temporary files. await cleanupCallback(); } } private string ComputeHash(Stream stream) { stream.Seek(0, SeekOrigin.Begin); var bytes = stream.ToByteArray()!; using var md5Hash = MD5.Create(); var hash = md5Hash.ComputeHash(bytes); stream.Seek(0, SeekOrigin.Begin); return Convert.ToBase64String(hash); } private async Task<(Blob, Stream, Func)> GenerateZipFileAsync(ActivityExecutionContext context, HttpContext httpContext, ICollection>> downloadables) { var cancellationToken = context.CancellationToken; 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, true, 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 downloadCorrelationId = GetDownloadCorrelationId(context, httpContext); if (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-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) { if(source.CanSeek) source.Seek(0, SeekOrigin.Begin); var enableResumableDownloads = EnableResumableDownloads.GetOrDefault(context, () => false); var result = new FileStreamResult(source, contentType) { EnableRangeProcessing = enableResumableDownloads, EntityTag = enableResumableDownloads ? eTag != null ? new Microsoft.Net.Http.Headers.EntityTagHeaderValue(eTag.ToString()) : default : default, FileDownloadName = filename }; var actionContext = new ActionContext(httpContext, httpContext.GetRouteData(), new ActionDescriptor()); await result.ExecuteResultAsync(actionContext); } private IEnumerable>> GetDownloadables(ActivityExecutionContext context, HttpContext httpContext, object? content) { if (content == null) return Enumerable.Empty>>(); var manager = context.GetRequiredService(); var headers = httpContext.Request.Headers; var eTag = GetIfMatchHeaderValue(headers); var range = GetRangeHeaderHeaderValue(headers); var options = new DownloadableOptions { ETag = eTag, Range = range }; return manager.GetDownloadablesAsync(content, options, context.CancellationToken); } private string GetContentType(ActivityExecutionContext context, string filename) { var provider = context.GetRequiredService(); return provider.TryGetContentType(filename, out var contentType) ? contentType : System.Net.Mime.MediaTypeNames.Application.Octet; } private static RangeHeaderValue? GetRangeHeaderHeaderValue(IHeaderDictionary headers) { try { return headers.TryGetValue(HeaderNames.Range, out var header) ? RangeHeaderValue.Parse(header.ToString()) : default; } catch (Exception e) { throw new HttpBadRequestException("Failed to parse Range header value", e); } } private static EntityTagHeaderValue? GetIfMatchHeaderValue(IHeaderDictionary headers) { try { return headers.TryGetValue(HeaderNames.IfMatch, out var header) ? new EntityTagHeaderValue(header.ToString()) : default; } catch (Exception e) { throw new HttpBadRequestException("Failed to parse If-Match header value", e); } } private async ValueTask OnResumeAsync(ActivityExecutionContext context) { var httpContextAccessor = context.GetRequiredService(); var httpContext = httpContextAccessor.HttpContext; if (httpContext == null) throw new FaultException("Cannot execute in a non-HTTP context"); await WriteResponseAsync(context, httpContext); } }