2023-10-20 07:50:57 +00:00
using System ;
using System.Collections.Generic ;
using System.IO ;
using System.Linq ;
2023-09-21 17:42:41 +00:00
using System.Security.Cryptography ;
2023-10-20 07:50:57 +00:00
using System.Threading.Tasks ;
2023-09-16 09:06:56 +00:00
using Elsa.Extensions ;
using Elsa.Http.Contracts ;
2023-10-03 08:25:57 +00:00
using Elsa.Http.Exceptions ;
2023-09-16 09:06:56 +00:00
using Elsa.Http.Models ;
2023-09-17 20:35:13 +00:00
using Elsa.Http.Options ;
using Elsa.Http.Services ;
2023-09-16 09:06:56 +00:00
using Elsa.Workflows.Core ;
using Elsa.Workflows.Core.Attributes ;
using Elsa.Workflows.Core.Exceptions ;
using Elsa.Workflows.Core.Models ;
2023-09-17 20:35:13 +00:00
using FluentStorage.Blobs ;
2023-09-21 17:42:41 +00:00
using FluentStorage.Utils.Extensions ;
2023-09-16 09:06:56 +00:00
using Microsoft.AspNetCore.Http ;
2023-09-16 17:02:07 +00:00
using Microsoft.AspNetCore.Mvc ;
using Microsoft.AspNetCore.Mvc.Abstractions ;
using Microsoft.AspNetCore.Routing ;
2023-09-16 09:06:56 +00:00
using Microsoft.AspNetCore.StaticFiles ;
2023-09-16 17:02:07 +00:00
using Microsoft.Extensions.Logging ;
2023-09-17 20:35:13 +00:00
using Microsoft.Net.Http.Headers ;
using EntityTagHeaderValue = System . Net . Http . Headers . EntityTagHeaderValue ;
using RangeHeaderValue = System . Net . Http . Headers . RangeHeaderValue ;
2023-09-16 09:06:56 +00:00
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.")]
2023-09-17 20:35:13 +00:00
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 ! ;
2023-09-16 09:06:56 +00:00
/// <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 ! ;
2023-09-17 20:35:13 +00:00
/// <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>
2023-09-17 20:35:56 +00:00
[Input(Description = "The correlation ID of the download used to resume a download. If left empty, the x-download-id header will be used.")]
2023-09-17 20:35:13 +00:00
public Input < string > DownloadCorrelationId { get ; set ; } = default ! ;
2023-09-16 09:06:56 +00:00
/// <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 ;
}
2023-09-16 17:02:07 +00:00
await WriteResponseAsync ( context , httpContext ) ;
2023-09-16 09:06:56 +00:00
}
2023-09-16 17:02:07 +00:00
private async Task WriteResponseAsync ( ActivityExecutionContext context , HttpContext httpContext )
2023-09-16 09:06:56 +00:00
{
// Get content and content type.
var content = context . Get ( Content ) ;
// Write content.
2023-09-17 20:35:13 +00:00
var downloadables = GetDownloadables ( context , httpContext , content ) . ToList ( ) ;
2023-09-16 17:02:07 +00:00
await SendDownloadablesAsync ( context , httpContext , downloadables ) ;
2023-09-16 09:06:56 +00:00
// Complete activity.
await context . CompleteActivityAsync ( ) ;
}
2023-09-17 20:35:13 +00:00
private async Task SendDownloadablesAsync ( ActivityExecutionContext context , HttpContext httpContext , IEnumerable < Func < ValueTask < Downloadable > > > downloadables )
2023-09-16 09:06:56 +00:00
{
var downloadableList = downloadables . ToList ( ) ;
switch ( downloadableList . Count )
{
case 0 :
2023-09-17 20:35:13 +00:00
SendNoContent ( context , httpContext ) ;
2023-09-16 09:06:56 +00:00
return ;
case 1 :
{
var downloadable = downloadableList [ 0 ] ;
2023-09-16 17:02:07 +00:00
await SendSingleFileAsync ( context , httpContext , downloadable ) ;
2023-09-16 09:06:56 +00:00
return ;
}
default :
2023-09-16 17:02:07 +00:00
await SendMultipleFilesAsync ( context , httpContext , downloadableList ) ;
2023-09-16 09:06:56 +00:00
break ;
}
}
2023-09-17 20:35:13 +00:00
private void SendNoContent ( ActivityExecutionContext context , HttpContext httpContext )
{
httpContext . Response . StatusCode = StatusCodes . Status204NoContent ;
}
private async Task SendSingleFileAsync ( ActivityExecutionContext context , HttpContext httpContext , Func < ValueTask < Downloadable > > downloadableFunc )
2023-09-16 09:06:56 +00:00
{
var contentType = ContentType . GetOrDefault ( context ) ;
2023-09-17 20:35:13 +00:00
var filename = Filename . GetOrDefault ( context ) ;
var eTag = EntityTag . GetOrDefault ( context ) ;
var downloadable = await downloadableFunc ( ) ;
2023-09-16 09:06:56 +00:00
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 ) ;
2023-09-17 20:35:13 +00:00
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 ) ;
2023-09-16 09:06:56 +00:00
}
2023-09-17 20:35:13 +00:00
private async Task SendMultipleFilesAsync ( ActivityExecutionContext context , HttpContext httpContext , ICollection < Func < ValueTask < Downloadable > > > downloadables )
2023-09-16 09:06:56 +00:00
{
2023-09-17 20:35:13 +00:00
// 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 ) ;
2023-09-16 09:06:56 +00:00
2023-09-17 20:35:13 +00:00
try
{
2023-09-21 17:42:41 +00:00
// Send the temporary file back to the client.
2023-09-17 20:35:13 +00:00
var contentType = zipBlob . Metadata [ "ContentType" ] ;
var downloadAsFilename = zipBlob . Metadata [ "Filename" ] ;
2023-09-21 17:42:41 +00:00
var hash = ComputeHash ( zipStream ) ;
var eTag = $"\" { hash } \ "" ;
2023-09-17 20:35:13 +00:00
var eTagHeaderValue = new EntityTagHeaderValue ( eTag ) ;
await SendFileStream ( context , httpContext , zipStream , contentType , downloadAsFilename , eTagHeaderValue ) ;
2023-09-16 17:02:07 +00:00
2023-09-17 20:35:13 +00:00
// 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
2023-09-16 09:06:56 +00:00
{
2023-09-17 20:35:13 +00:00
// Delete any temporary files.
await cleanupCallback ( ) ;
2023-09-16 09:06:56 +00:00
}
2023-09-17 20:35:13 +00:00
}
2023-09-21 17:42:41 +00:00
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 ) ;
}
2023-09-17 20:35:13 +00:00
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 ) ;
2023-09-16 09:06:56 +00:00
var contentType = ContentType . GetOrDefault ( context ) ;
2023-09-17 20:35:13 +00:00
var downloadAsFilename = Filename . GetOrDefault ( context ) ;
var zipService = context . GetRequiredService < ZipManager > ( ) ;
2023-09-25 11:58:05 +00:00
var ( zipBlob , zipStream , cleanup ) = await zipService . CreateAsync ( downloadables , true , downloadCorrelationId , downloadAsFilename , contentType , cancellationToken ) ;
2023-09-17 20:35:13 +00:00
return ( zipBlob , zipStream , Cleanup ) ;
ValueTask Cleanup ( )
2023-09-16 17:02:07 +00:00
{
2023-09-17 20:35:13 +00:00
cleanup ( ) ;
return default ;
2023-09-16 17:02:07 +00:00
}
2023-09-17 20:35:13 +00:00
}
private async Task < ( Blob , Stream , Func < ValueTask > ) ? > TryLoadCachedFileAsync ( ActivityExecutionContext context , HttpContext httpContext )
{
var downloadCorrelationId = GetDownloadCorrelationId ( context , httpContext ) ;
2023-09-25 11:58:05 +00:00
if ( string . IsNullOrWhiteSpace ( downloadCorrelationId ) )
2023-09-17 20:35:13 +00:00
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 ) )
2023-09-17 20:35:56 +00:00
downloadCorrelationId = httpContext . Request . Headers [ "x-download-id" ] ;
2023-09-17 20:35:13 +00:00
if ( string . IsNullOrWhiteSpace ( downloadCorrelationId ) )
2023-09-16 17:02:07 +00:00
{
2023-09-17 20:35:13 +00:00
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 ) ;
2023-09-16 17:02:07 +00:00
}
2023-09-17 20:35:13 +00:00
return downloadCorrelationId ;
2023-09-16 17:02:07 +00:00
}
2023-09-16 09:06:56 +00:00
2023-09-17 20:35:13 +00:00
private async Task SendFileStream ( ActivityExecutionContext context , HttpContext httpContext , Stream source , string contentType , string filename , EntityTagHeaderValue ? eTag )
2023-09-16 17:02:07 +00:00
{
source . Seek ( 0 , SeekOrigin . Begin ) ;
2023-09-25 11:58:05 +00:00
var enableResumableDownloads = EnableResumableDownloads . GetOrDefault ( context , ( ) = > false ) ;
2023-09-17 20:35:13 +00:00
2023-09-16 17:02:07 +00:00
var result = new FileStreamResult ( source , contentType )
{
2023-09-25 11:58:05 +00:00
EnableRangeProcessing = enableResumableDownloads ,
EntityTag = enableResumableDownloads ? eTag ! = null ? new Microsoft . Net . Http . Headers . EntityTagHeaderValue ( eTag . ToString ( ) ) : default : default ,
2023-09-16 17:02:07 +00:00
FileDownloadName = filename
} ;
2023-09-17 20:35:13 +00:00
2023-09-16 17:02:07 +00:00
var actionContext = new ActionContext ( httpContext , httpContext . GetRouteData ( ) , new ActionDescriptor ( ) ) ;
await result . ExecuteResultAsync ( actionContext ) ;
2023-09-16 09:06:56 +00:00
}
2023-09-17 20:35:13 +00:00
private IEnumerable < Func < ValueTask < Downloadable > > > GetDownloadables ( ActivityExecutionContext context , HttpContext httpContext , object? content )
2023-09-16 09:06:56 +00:00
{
2023-09-17 20:35:13 +00:00
if ( content = = null )
return Enumerable . Empty < Func < ValueTask < Downloadable > > > ( ) ;
2023-09-16 09:06:56 +00:00
var manager = context . GetRequiredService < IDownloadableManager > ( ) ;
2023-09-17 20:35:13 +00:00
var headers = httpContext . Request . Headers ;
2023-10-03 08:25:57 +00:00
var eTag = GetIfMatchHeaderValue ( headers ) ;
var range = GetRangeHeaderHeaderValue ( headers ) ;
2023-09-17 20:35:13 +00:00
var options = new DownloadableOptions { ETag = eTag , Range = range } ;
return manager . GetDownloadablesAsync ( content , options , context . CancellationToken ) ;
2023-09-16 09:06:56 +00:00
}
2023-09-16 17:02:07 +00:00
2023-09-16 09:06:56 +00:00
private string GetContentType ( ActivityExecutionContext context , string filename )
{
2023-09-16 09:43:34 +00:00
var provider = context . GetRequiredService < IContentTypeProvider > ( ) ;
return provider . TryGetContentType ( filename , out var contentType ) ? contentType : System . Net . Mime . MediaTypeNames . Application . Octet ;
}
2023-10-03 08:25:57 +00:00
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 ) ;
}
}
2023-09-16 09:43:34 +00:00
2023-09-16 09:06:56 +00:00
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" ) ;
2023-09-16 17:02:07 +00:00
await WriteResponseAsync ( context , httpContext ) ;
2023-09-16 09:06:56 +00:00
}
}