elsa-core/src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs
Sipke Schoorstra 3aec7924b1
Storage module (#4588)
* Add File Storage module
2023-10-30 21:48:36 +01:00

315 lines
13 KiB
C#

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;
/// <summary>
/// Sends a file to the HTTP response.
/// </summary>
[Activity("Elsa", "HTTP", "Send one ore more files (zipped) to the HTTP response.", DisplayName = "HTTP File Response")]
public class WriteFileHttpResponse : Activity
{
/// <summary>
/// The MIME type of the file to serve.
/// </summary>
[Input(Description = "The content type of the file to serve. Leave empty to let the system determine the content type.")]
public Input<string?> ContentType { get; set; } = default!;
/// <summary>
/// The name of the file to serve.
/// </summary>
[Input(Description = "The name of the file to serve. Leave empty to let the system determine the file name.")]
public Input<string?> Filename { get; set; } = default!;
/// <summary>
/// The Entity Tag of the file to serve.
/// </summary>
[Input(Description = "The Entity Tag of the file to serve. Leave empty to let the system determine the Entity Tag.")]
public Input<string?> EntityTag { get; set; } = default!;
/// <summary>
/// The file content to serve. Supports byte array, streams, string, Uri and an array of the aforementioned types.
/// </summary>
[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<object> Content { get; set; } = default!;
/// <summary>
/// Whether to enable resumable downloads. When enabled, the client can resume a download if the connection is lost.
/// </summary>
[Input(Description = "Whether to enable resumable downloads. When enabled, the client can resume a download if the connection is lost.")]
public Input<bool> EnableResumableDownloads { get; set; } = default!;
/// <summary>
/// The correlation ID of the download. Used to resume a download.
/// </summary>
[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<string> DownloadCorrelationId { get; set; } = default!;
/// <inheritdoc />
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
var httpContextAccessor = context.GetRequiredService<IHttpContextAccessor>();
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<Func<ValueTask<Downloadable>>> 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<ValueTask<Downloadable>> 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<Func<ValueTask<Downloadable>>> 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<ILogger<WriteFileHttpResponse>>();
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<ValueTask>)> GenerateZipFileAsync(ActivityExecutionContext context, HttpContext httpContext, ICollection<Func<ValueTask<Downloadable>>> downloadables)
{
var cancellationToken = context.CancellationToken;
var downloadCorrelationId = GetDownloadCorrelationId(context, httpContext);
var contentType = ContentType.GetOrDefault(context);
var downloadAsFilename = Filename.GetOrDefault(context);
var zipService = context.GetRequiredService<ZipManager>();
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<ValueTask>)?> TryLoadCachedFileAsync(ActivityExecutionContext context, HttpContext httpContext)
{
var downloadCorrelationId = GetDownloadCorrelationId(context, httpContext);
if (string.IsNullOrWhiteSpace(downloadCorrelationId))
return null;
var cancellationToken = context.CancellationToken;
var zipService = context.GetRequiredService<ZipManager>();
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<Func<ValueTask<Downloadable>>> GetDownloadables(ActivityExecutionContext context, HttpContext httpContext, object? content)
{
if (content == null)
return Enumerable.Empty<Func<ValueTask<Downloadable>>>();
var manager = context.GetRequiredService<IDownloadableManager>();
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<IContentTypeProvider>();
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<IHttpContextAccessor>();
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
throw new FaultException("Cannot execute in a non-HTTP context");
await WriteResponseAsync(context, httpContext);
}
}