2023-10-20 07:51:56 +00:00
using System ;
using System.Collections.Generic ;
using System.Linq ;
using System.Runtime.CompilerServices ;
using System.Threading.Tasks ;
2022-05-26 10:47:31 +00:00
using Elsa.Expressions.Models ;
2022-12-31 15:16:43 +00:00
using Elsa.Extensions ;
2023-09-16 09:06:56 +00:00
using Elsa.Http.Bookmarks ;
2023-03-03 21:47:23 +00:00
using Elsa.Http.Contracts ;
2023-03-03 13:48:05 +00:00
using Elsa.Workflows.Core ;
2022-05-26 10:47:31 +00:00
using Elsa.Workflows.Core.Attributes ;
using Elsa.Workflows.Core.Models ;
2023-01-16 18:42:45 +00:00
using Microsoft.AspNetCore.Http ;
using Microsoft.AspNetCore.Routing ;
2023-09-09 12:40:48 +00:00
using Microsoft.AspNetCore.Routing.Patterns ;
2023-01-16 18:42:45 +00:00
using Microsoft.Extensions.DependencyInjection ;
2023-03-07 21:02:46 +00:00
using Microsoft.Extensions.Primitives ;
2022-01-04 08:42:12 +00:00
2022-05-26 10:47:31 +00:00
namespace Elsa.Http ;
2022-01-04 08:42:12 +00:00
2023-01-16 18:42:45 +00:00
/// <summary>
/// Wait for an inbound HTTP request that matches the specified path and methods.
/// </summary>
2023-05-08 20:38:59 +00:00
[Activity("Elsa", "HTTP", "Wait for an inbound HTTP request that matches the specified path and methods.", DisplayName = "HTTP Endpoint")]
2023-09-20 20:02:37 +00:00
[Output(IsSerializable = false)]
2023-01-16 18:42:45 +00:00
public class HttpEndpoint : Trigger < HttpRequest >
2022-01-04 08:42:12 +00:00
{
2023-01-16 18:42:45 +00:00
internal const string HttpContextInputKey = "HttpContext" ;
internal const string RequestPathInputKey = "RequestPath" ;
2022-03-09 23:19:00 +00:00
2023-01-16 18:42:45 +00:00
/// <inheritdoc />
2023-08-06 18:55:24 +00:00
public HttpEndpoint ( [ CallerFilePath ] string? source = default , [ CallerLineNumber ] int? line = default ) : base ( source , line )
2022-04-07 10:51:08 +00:00
{
}
2023-01-16 18:42:45 +00:00
/// <summary>
/// The path to associate with the workflow.
/// </summary>
2023-02-15 11:09:45 +00:00
[Input(Description = "The path to associate with the workflow.")]
2023-01-16 18:42:45 +00:00
public Input < string > Path { get ; set ; } = default ! ;
2022-01-04 08:42:12 +00:00
2023-01-16 18:42:45 +00:00
/// <summary>
/// The HTTP methods to accept.
/// </summary>
2022-01-04 08:42:12 +00:00
[ Input (
2023-01-16 18:42:45 +00:00
Description = "The HTTP methods to accept." ,
2022-04-04 14:00:10 +00:00
Options = new [ ] { "GET" , "POST" , "PUT" , "HEAD" , "DELETE" } ,
2023-01-16 18:42:45 +00:00
UIHint = InputUIHints . CheckList ) ]
2023-03-17 20:11:55 +00:00
public Input < ICollection < string > > SupportedMethods { get ; set ; } = new ( ObjectLiteral . From ( new [ ] { HttpMethods . Get } ) ) ;
2022-01-04 08:42:12 +00:00
2023-01-16 18:42:45 +00:00
/// <summary>
/// Allow authenticated requests only.
/// </summary>
[Input(Description = "Allow authenticated requests only.", Category = "Security")]
2022-03-09 23:19:00 +00:00
public Input < bool > Authorize { get ; set ; } = new ( false ) ;
2023-01-16 18:42:45 +00:00
/// <summary>
/// Provide a policy to evaluate. If the policy fails, the request is forbidden.
/// </summary>
[Input(Description = "Provide a policy to evaluate. If the policy fails, the request is forbidden.", Category = "Security")]
2022-03-09 23:19:00 +00:00
public Input < string? > Policy { get ; set ; } = new ( default ( string? ) ) ;
2023-02-15 11:09:45 +00:00
2023-09-19 20:42:53 +00:00
/// <summary>
/// The maximum time allowed to process the request.
/// </summary>
[Input(Description = "The maximum time allowed to process the request.", Category = "Upload")]
public Input < TimeSpan ? > RequestTimeout { get ; set ; } = default ! ;
/// <summary>
/// The maximum request size allowed in bytes.
/// </summary>
[Input(Description = "The maximum request size allowed in bytes.", Category = "Upload")]
public Input < long? > RequestSizeLimit { get ; set ; } = default ! ;
/// <summary>
/// The maximum request size allowed in bytes.
/// </summary>
[Input(Description = "The maximum file size allowed in bytes for an individual file.", Category = "Upload")]
public Input < long? > FileSizeLimit { get ; set ; } = default ! ;
/// <summary>
/// The allowed file extensions,
/// </summary>
[Input(Description = "Only file extensions in this list are allowed. Leave empty to allow all extensions", Category = "Upload", UIHint = InputUIHints.MultiText)]
public Input < ICollection < string > > AllowedFileExtensions { get ; set ; } = default ! ;
/// <summary>
/// The allowed file extensions,
/// </summary>
[Input(Description = "File extensions in this list are forbidden. Leave empty to not block any extension.", Category = "Upload", UIHint = InputUIHints.MultiText)]
public Input < ICollection < string > > BlockedFileExtensions { get ; set ; } = default ! ;
/// <summary>
/// The allowed file extensions,
/// </summary>
[Input(Description = "Only MIME types in this list are allowed. Leave empty to allow all types", Category = "Upload", UIHint = InputUIHints.MultiText)]
public Input < ICollection < string > > AllowedMimeTypes { get ; set ; } = default ! ;
2023-09-22 16:45:18 +00:00
/// <summary>
/// A value indicating whether to expose the "Request too large" outcome.
/// </summary>
[Input(Description = "A value indicating whether to expose the \"Request too large\" outcome.", Category = "Outcomes")]
public bool ExposeRequestTooLargeOutcome { get ; set ; }
/// <summary>
/// A value indicating whether to expose the "File too large" outcome.
/// </summary>
[Input(Description = "A value indicating whether to expose the \"File too large\" outcome.", Category = "Outcomes")]
public bool ExposeFileTooLargeOutcome { get ; set ; }
/// <summary>
/// A value indicating whether to expose the "Invalid file extension" outcome.
/// </summary>
[Input(Description = "A value indicating whether to expose the \"Invalid file extension\" outcome.", Category = "Outcomes")]
public bool ExposeInvalidFileExtensionOutcome { get ; set ; }
/// <summary>
/// A value indicating whether to expose the "Invalid file MIME type" outcome.
/// </summary>
[Input(Description = "A value indicating whether to expose the \"Invalid file MIME type\" outcome.", Category = "Outcomes")]
public bool ExposeInvalidFileMimeTypeOutcome { get ; set ; }
2023-01-16 18:42:45 +00:00
/// <summary>
/// The parsed request content, if any.
/// </summary>
[Output(Description = "The parsed request content, if any.")]
public Output < object? > ParsedContent { get ; set ; } = default ! ;
2023-02-15 11:09:45 +00:00
2023-09-19 20:42:53 +00:00
/// <summary>
/// The uploaded files, if any.
/// </summary>
2023-09-20 20:02:37 +00:00
[Output(Description = "The uploaded files, if any.", IsSerializable = false)]
2023-09-19 20:42:53 +00:00
public Output < IFormFile [ ] > Files { get ; set ; } = default ! ;
2023-01-16 18:42:45 +00:00
/// <summary>
/// The parsed route data, if any.
/// </summary>
[Output(Description = "The parsed route data, if any.")]
2023-03-07 21:02:46 +00:00
public Output < IDictionary < string , object > > RouteData { get ; set ; } = default ! ;
2023-03-07 13:44:34 +00:00
/// <summary>
/// The querystring data, if any.
/// </summary>
[Output(Description = "The querystring data, if any.")]
2023-03-07 21:02:46 +00:00
public Output < IDictionary < string , object > > QueryStringData { get ; set ; } = default ! ;
2023-03-07 13:44:34 +00:00
/// <summary>
/// The headers, if any.
/// </summary>
[Output(Description = "The headers, if any.")]
2023-03-07 21:02:46 +00:00
public Output < IDictionary < string , object > > Headers { get ; set ; } = default ! ;
2022-01-04 08:42:12 +00:00
2022-11-23 10:20:00 +00:00
/// <inheritdoc />
protected override IEnumerable < object > GetTriggerPayloads ( TriggerIndexingContext context ) = > GetBookmarkPayloads ( context . ExpressionExecutionContext ) ;
2022-03-09 23:19:00 +00:00
2022-11-23 10:20:00 +00:00
/// <inheritdoc />
protected override async ValueTask ExecuteAsync ( ActivityExecutionContext context )
2022-03-07 14:08:01 +00:00
{
2023-09-09 12:40:48 +00:00
var path = Path . Get ( context ) ;
if ( path . Contains ( "//" ) )
throw new RoutePatternException ( path , "Path cannot contain double slashes (//)" ) ;
2023-06-28 18:55:57 +00:00
if ( ! context . IsTriggerOfWorkflow ( ) )
2022-03-07 14:08:01 +00:00
{
2023-10-14 22:24:00 +00:00
context . CreateBookmarks ( GetBookmarkPayloads ( context . ExpressionExecutionContext ) , includeActivityInstanceId : false ) ;
2022-03-07 14:08:01 +00:00
return ;
}
2023-02-15 11:09:45 +00:00
2023-01-23 22:30:00 +00:00
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.
2023-09-16 09:06:56 +00:00
context . CreateBookmark ( OnResumeAsync , BookmarkMetadata . HttpCrossBoundary ) ;
2023-01-23 22:30:00 +00:00
return ;
}
await HandleRequestAsync ( context , httpContext ) ;
2022-03-07 14:08:01 +00:00
}
2023-02-15 11:09:45 +00:00
2023-01-23 22:30:00 +00:00
private async ValueTask OnResumeAsync ( ActivityExecutionContext context )
{
var httpContextAccessor = context . GetRequiredService < IHttpContextAccessor > ( ) ;
var httpContext = httpContextAccessor . HttpContext ;
if ( httpContext = = null )
{
// We're not in an HTTP context, so let's fail.
throw new Exception ( "Cannot execute in a non-HTTP context" ) ;
}
await HandleRequestAsync ( context , httpContext ) ;
}
private async Task HandleRequestAsync ( ActivityExecutionContext context , HttpContext httpContext )
{
// Provide the received HTTP request as output.
var request = httpContext . Request ;
context . Set ( Result , request ) ;
2023-02-15 11:09:45 +00:00
2023-01-23 22:30:00 +00:00
// Read route data, if any.
2023-10-07 12:22:51 +00:00
var path = context . GetWorkflowInput < PathString > ( RequestPathInputKey ) ;
2023-01-23 22:30:00 +00:00
var routeData = GetRouteData ( httpContext , path ) ;
2023-03-07 13:44:34 +00:00
2023-05-28 16:35:45 +00:00
var routeDictionary = routeData . Values . ToDictionary ( route = > route . Key , route = > route . Value ! ) ;
2023-03-07 21:02:46 +00:00
var queryStringDictionary = httpContext . Request . Query . ToDictionary < KeyValuePair < string , StringValues > , string , object > ( queryString = > queryString . Key , queryString = > queryString . Value [ 0 ] ! ) ;
var headersDictionary = httpContext . Request . Headers . ToDictionary < KeyValuePair < string , StringValues > , string , object > ( header = > header . Key , header = > header . Value [ 0 ] ! ) ;
2023-03-07 13:44:34 +00:00
context . Set ( RouteData , routeDictionary ) ;
context . Set ( QueryStringData , queryStringDictionary ) ;
context . Set ( Headers , headersDictionary ) ;
2023-09-22 16:45:18 +00:00
// Validate request size.
if ( ! ValidateRequestSize ( context , httpContext ) )
{
await HandleRequestTooLargeAsync ( context , httpContext ) ;
return ;
}
2023-02-15 11:09:45 +00:00
2023-09-19 20:42:53 +00:00
// Read files, if any.
var files = ReadFilesAsync ( context , request ) ;
2023-09-22 16:45:18 +00:00
if ( files . Any ( ) )
{
if ( ! ValidateFileSizes ( context , httpContext , files ) )
{
await HandleFileSizeTooLargeAsync ( context , httpContext ) ;
return ;
}
if ( ! ValidateFileExtensionWhitelist ( context , httpContext , files ) )
{
await HandleInvalidFileExtensionWhitelistAsync ( context , httpContext ) ;
return ;
}
if ( ! ValidateFileExtensionBlacklist ( context , httpContext , files ) )
{
await HandleInvalidFileExtensionBlacklistAsync ( context , httpContext ) ;
return ;
}
if ( ! ValidateFileMimeTypes ( context , httpContext , files ) )
{
await HandleInvalidFileMimeTypesAsync ( context , httpContext ) ;
return ;
}
Files . Set ( context , files . ToArray ( ) ) ;
}
2023-09-19 20:42:53 +00:00
2023-01-23 22:30:00 +00:00
// Read content, if any.
var content = await ParseContentAsync ( context , request ) ;
2023-09-19 20:42:53 +00:00
ParsedContent . Set ( context , content ) ;
2023-02-15 11:09:45 +00:00
2023-01-23 22:30:00 +00:00
// Complete.
await context . CompleteActivityAsync ( ) ;
}
2023-02-15 11:09:45 +00:00
2023-09-19 20:42:53 +00:00
private IFormFileCollection ReadFilesAsync ( ActivityExecutionContext context , HttpRequest request )
{
2023-09-20 20:02:37 +00:00
return request . HasFormContentType ? request . Form . Files : new FormFileCollection ( ) ;
2023-09-19 20:42:53 +00:00
}
2023-09-22 16:45:18 +00:00
private bool ValidateRequestSize ( ActivityExecutionContext context , HttpContext httpContext )
{
var requestSizeLimit = RequestSizeLimit . GetOrDefault ( context ) ;
if ( ! requestSizeLimit . HasValue )
return true ;
var requestSize = httpContext . Request . ContentLength ? ? 0 ;
return requestSize < = requestSizeLimit ;
}
private async Task HandleRequestTooLargeAsync ( ActivityExecutionContext context , HttpContext httpContext )
{
var exposeRequestTooLargeOutcome = ExposeRequestTooLargeOutcome ;
if ( exposeRequestTooLargeOutcome )
{
await context . CompleteActivityWithOutcomesAsync ( "Request too large" ) ;
}
else
{
var response = httpContext . Response ;
response . StatusCode = StatusCodes . Status413PayloadTooLarge ;
await response . WriteAsJsonAsync ( new
{
Message = $"The maximum request size allowed is {RequestSizeLimit.Get(context)} bytes."
} ) ;
await response . Body . FlushAsync ( ) ;
}
}
private bool ValidateFileSizes ( ActivityExecutionContext context , HttpContext httpContext , IFormFileCollection files )
2023-09-19 20:42:53 +00:00
{
var fileSizeLimit = FileSizeLimit . GetOrDefault ( context ) ;
if ( ! fileSizeLimit . HasValue )
return true ;
if ( ! files . Any ( file = > file . Length > fileSizeLimit . Value ) )
return true ;
return false ;
}
2023-09-22 16:45:18 +00:00
private async Task HandleFileSizeTooLargeAsync ( ActivityExecutionContext context , HttpContext httpContext )
{
var exposeFileTooLargeOutcome = ExposeFileTooLargeOutcome ;
if ( exposeFileTooLargeOutcome )
{
await context . CompleteActivityWithOutcomesAsync ( "File too large" ) ;
}
else
{
var response = httpContext . Response ;
response . StatusCode = StatusCodes . Status413PayloadTooLarge ;
await response . WriteAsJsonAsync ( new
{
Message = $"The maximum file size allowed is {FileSizeLimit.Get(context)} bytes."
} ) ;
await response . Body . FlushAsync ( ) ;
}
}
private bool ValidateFileExtensionWhitelist ( ActivityExecutionContext context , HttpContext httpContext , IFormFileCollection files )
2023-09-19 20:42:53 +00:00
{
var allowedFileExtensions = AllowedFileExtensions . GetOrDefault ( context ) ;
if ( allowedFileExtensions = = null | | ! allowedFileExtensions . Any ( ) )
return true ;
if ( files . All ( file = > allowedFileExtensions . Contains ( System . IO . Path . GetExtension ( file . FileName ) , StringComparer . OrdinalIgnoreCase ) ) )
return true ;
2023-09-22 16:45:18 +00:00
return false ;
}
private async Task HandleInvalidFileExtensionWhitelistAsync ( ActivityExecutionContext context , HttpContext httpContext )
{
if ( ExposeInvalidFileExtensionOutcome )
{
await context . CompleteActivityWithOutcomesAsync ( "Invalid file extension" ) ;
return ;
}
2023-09-19 20:42:53 +00:00
var response = httpContext . Response ;
2023-09-22 16:45:18 +00:00
var allowedFileExtensions = AllowedFileExtensions . GetOrDefault ( context ) ! ;
2023-09-19 20:42:53 +00:00
response . StatusCode = StatusCodes . Status415UnsupportedMediaType ;
await response . WriteAsJsonAsync ( new
{
Message = $"Only the following file extensions are allowed: {string.Join(" , ", allowedFileExtensions)}"
} ) ;
await response . Body . FlushAsync ( ) ;
}
2023-09-22 16:45:18 +00:00
private bool ValidateFileExtensionBlacklist ( ActivityExecutionContext context , HttpContext httpContext , IFormFileCollection files )
2023-09-19 20:42:53 +00:00
{
var blockedFileExtensions = BlockedFileExtensions . GetOrDefault ( context ) ;
if ( blockedFileExtensions = = null | | ! blockedFileExtensions . Any ( ) )
return true ;
if ( ! files . Any ( file = > blockedFileExtensions . Contains ( System . IO . Path . GetExtension ( file . FileName ) , StringComparer . OrdinalIgnoreCase ) ) )
return true ;
2023-09-22 16:45:18 +00:00
return false ;
}
private async Task HandleInvalidFileExtensionBlacklistAsync ( ActivityExecutionContext context , HttpContext httpContext )
{
if ( ExposeInvalidFileExtensionOutcome )
{
await context . CompleteActivityWithOutcomesAsync ( "Invalid file extension" ) ;
return ;
}
var blockedFileExtensions = BlockedFileExtensions . GetOrDefault ( context ) ! ;
2023-09-19 20:42:53 +00:00
var response = httpContext . Response ;
response . StatusCode = StatusCodes . Status415UnsupportedMediaType ;
await response . WriteAsJsonAsync ( new
{
Message = $"The following file extensions are not allowed: {string.Join(" , ", blockedFileExtensions)}"
} ) ;
await response . Body . FlushAsync ( ) ;
}
2023-09-22 16:45:18 +00:00
private bool ValidateFileMimeTypes ( ActivityExecutionContext context , HttpContext httpContext , IFormFileCollection files )
2023-09-19 20:42:53 +00:00
{
var allowedMimeTypes = AllowedMimeTypes . GetOrDefault ( context ) ;
if ( allowedMimeTypes = = null | | ! allowedMimeTypes . Any ( ) )
return true ;
if ( files . All ( file = > allowedMimeTypes . Contains ( file . ContentType , StringComparer . OrdinalIgnoreCase ) ) )
return true ;
2023-09-22 16:45:18 +00:00
return false ;
}
private async Task HandleInvalidFileMimeTypesAsync ( ActivityExecutionContext context , HttpContext httpContext )
{
if ( ExposeInvalidFileMimeTypeOutcome )
{
await context . CompleteActivityWithOutcomesAsync ( "Invalid file MIME type" ) ;
return ;
}
var allowedMimeTypes = AllowedMimeTypes . GetOrDefault ( context ) ! ;
2023-09-19 20:42:53 +00:00
var response = httpContext . Response ;
response . StatusCode = StatusCodes . Status415UnsupportedMediaType ;
await response . WriteAsJsonAsync ( new
{
Message = $"Only the following MIME types are allowed: {string.Join(" , ", allowedMimeTypes)}"
} ) ;
await response . Body . FlushAsync ( ) ;
}
private async Task < object? > ParseContentAsync ( ActivityExecutionContext context , HttpRequest httpRequest )
{
if ( ! HasContent ( httpRequest ) )
return null ;
var cancellationToken = context . CancellationToken ;
var targetType = ParsedContent . GetTargetType ( context ) ;
var contentStream = httpRequest . Body ;
var contentType = httpRequest . ContentType ! ;
return await context . ParseContentAsync ( contentStream , contentType , targetType , cancellationToken ) ;
}
private static bool HasContent ( HttpRequest httpRequest ) = > httpRequest . Headers . ContentLength > 0 ;
private IEnumerable < object > GetBookmarkPayloads ( ExpressionExecutionContext context )
{
// Generate bookmark data for path and selected methods.
2023-10-20 07:51:56 +00:00
var normalizedRoute = context . Get ( Path ) ! . NormalizeRoute ( ) ;
2023-09-19 20:42:53 +00:00
var methods = SupportedMethods . GetOrDefault ( context ) ? ? new List < string > { HttpMethods . Get } ;
var authorize = Authorize . GetOrDefault ( context ) ;
var policy = Policy . GetOrDefault ( context ) ;
var requestTimeout = RequestTimeout . GetOrDefault ( context ) ;
var requestSizeLimit = RequestSizeLimit . GetOrDefault ( context ) ;
return methods
2023-10-20 07:51:56 +00:00
. Select ( x = > new HttpEndpointBookmarkPayload ( normalizedRoute , x . ToLowerInvariant ( ) , authorize , policy , requestTimeout , requestSizeLimit ) )
2023-09-19 20:42:53 +00:00
. Cast < object > ( )
. ToArray ( ) ;
}
2023-01-16 18:42:45 +00:00
private static RouteData GetRouteData ( HttpContext httpContext , string path )
{
var routeData = httpContext . GetRouteData ( ) ;
var routeTable = httpContext . RequestServices . GetRequiredService < IRouteTable > ( ) ;
var routeMatcher = httpContext . RequestServices . GetRequiredService < IRouteMatcher > ( ) ;
var matchingRouteQuery =
from route in routeTable
let routeValues = routeMatcher . Match ( route , path )
where routeValues ! = null
select new { route , routeValues } ;
var matchingRoute = matchingRouteQuery . FirstOrDefault ( ) ;
if ( matchingRoute = = null )
return routeData ;
foreach ( var ( key , value ) in matchingRoute . routeValues ! )
routeData . Values [ key ] = value ;
return routeData ;
}
2022-01-04 08:42:12 +00:00
}