using System.Runtime.CompilerServices; using System.Text.Json; using Elsa.Expressions.Models; using Elsa.Extensions; using Elsa.Http.Bookmarks; using Elsa.Http.UIHints; using Elsa.Workflows; using Elsa.Workflows.Attributes; using Elsa.Workflows.UIHints; using Elsa.Workflows.Models; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Http; /// /// Wait for an inbound HTTP request that matches the specified path and methods. /// [Activity("Elsa", "HTTP", "Wait for an inbound HTTP request that matches the specified path and methods.", DisplayName = "HTTP Endpoint")] [Output(IsSerializable = false)] public class HttpEndpoint : Trigger { internal const string HttpContextInputKey = "HttpContext"; internal const string PathInputKey = "Path"; /// public HttpEndpoint([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) { } /// /// The path to associate with the workflow. /// [Input( Description = "The path to associate with the workflow.", UIHint = InputUIHints.SingleLine, UIHandler = typeof(HttpEndpointPathUIHandler) )] public Input Path { get; set; } = null!; /// /// The HTTP methods to accept. /// [Input( Description = "The HTTP methods to accept.", Options = new[] { "GET", "POST", "PUT", "HEAD", "DELETE" }, UIHint = InputUIHints.CheckList)] public Input> SupportedMethods { get; set; } = new(ObjectLiteral.From(new[] { HttpMethods.Get })); /// /// Allow authenticated requests only. /// [Input(Description = "Allow authenticated requests only.", Category = "Security")] public Input Authorize { get; set; } = new(false); /// /// Provide a policy to evaluate. If the policy fails, the request is forbidden. /// [Input(Description = "Provide a policy to evaluate. If the policy fails, the request is forbidden.", Category = "Security")] public Input Policy { get; set; } = new(default(string?)); /// /// The maximum time allowed to process the request. /// [Input(Description = "The maximum time allowed to process the request.", Category = "Upload")] public Input RequestTimeout { get; set; } = null!; /// /// The maximum request size allowed in bytes. /// [Input(Description = "The maximum request size allowed in bytes.", Category = "Upload")] public Input RequestSizeLimit { get; set; } = null!; /// /// The maximum request size allowed in bytes. /// [Input(Description = "The maximum file size allowed in bytes for an individual file.", Category = "Upload")] public Input FileSizeLimit { get; set; } = null!; /// /// The allowed file extensions, /// [Input(Description = "Only file extensions in this list are allowed. Leave empty to allow all extensions", Category = "Upload", UIHint = InputUIHints.MultiText)] public Input> AllowedFileExtensions { get; set; } = null!; /// /// The allowed file extensions, /// [Input(Description = "File extensions in this list are forbidden. Leave empty to not block any extension.", Category = "Upload", UIHint = InputUIHints.MultiText)] public Input> BlockedFileExtensions { get; set; } = null!; /// /// The allowed file extensions, /// [Input(Description = "Only MIME types in this list are allowed. Leave empty to allow all types", Category = "Upload", UIHint = InputUIHints.MultiText)] public Input> AllowedMimeTypes { get; set; } = null!; /// /// A value indicating whether to expose the "Request too large" outcome. /// [Input(Description = "A value indicating whether to expose the \"Request too large\" outcome.", Category = "Outcomes")] public bool ExposeRequestTooLargeOutcome { get; set; } /// /// A value indicating whether to expose the "File too large" outcome. /// [Input(Description = "A value indicating whether to expose the \"File too large\" outcome.", Category = "Outcomes")] public bool ExposeFileTooLargeOutcome { get; set; } /// /// A value indicating whether to expose the "Invalid file extension" outcome. /// [Input(Description = "A value indicating whether to expose the \"Invalid file extension\" outcome.", Category = "Outcomes")] public bool ExposeInvalidFileExtensionOutcome { get; set; } /// /// A value indicating whether to expose the "Invalid file MIME type" outcome. /// [Input(Description = "A value indicating whether to expose the \"Invalid file MIME type\" outcome.", Category = "Outcomes")] public bool ExposeInvalidFileMimeTypeOutcome { get; set; } /// /// The parsed request content, if any. /// [Output(Description = "The parsed request content, if any.")] public Output ParsedContent { get; set; } = null!; /// /// The uploaded files, if any. /// [Output(Description = "The uploaded files, if any.", IsSerializable = false)] public Output Files { get; set; } = null!; /// /// The parsed route data, if any. /// [Output(Description = "The parsed route data, if any.")] public Output> RouteData { get; set; } = null!; /// /// The querystring data, if any. /// [Output(Description = "The querystring data, if any.")] public Output> QueryStringData { get; set; } = null!; /// /// The headers, if any. /// [Output(Description = "The headers, if any.")] public Output> Headers { get; set; } = null!; /// protected override IEnumerable GetTriggerPayloads(TriggerIndexingContext context) => GetBookmarkPayloads(context.ExpressionExecutionContext); /// protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { var path = Path.Get(context); if (path.Contains("//")) throw new RoutePatternException(path, "Path cannot contain double slashes (//)"); if (!context.IsTriggerOfWorkflow()) { context.CreateBookmarks(GetBookmarkPayloads(context.ExpressionExecutionContext), includeActivityInstanceId: false, callback: OnResumeAsync); return; } 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 HandleRequestAsync(context, httpContext); } private async ValueTask OnResumeAsync(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 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); // Read route data, if any. var path = context.GetWorkflowInput(PathInputKey); var routeData = GetRouteData(httpContext, path); var routeDictionary = routeData.Values.ToDictionary(route => route.Key, route => route.Value!); var queryStringDictionary = httpContext.Request.Query.ToObjectDictionary(); var headersDictionary = httpContext.Request.Headers.ToObjectDictionary(); context.Set(RouteData, routeDictionary); context.Set(QueryStringData, queryStringDictionary); context.Set(Headers, headersDictionary); // Validate request size. if (!ValidateRequestSize(context, httpContext)) { await HandleRequestTooLargeAsync(context, httpContext); return; } // Handle Form Fields if (request.HasFormContentType) { var formFields = request.Form.ToObjectDictionary(); ParsedContent.Set(context, formFields); // Read files, if any. var files = ReadFilesAsync(context, request); 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()); } } else { // Parse Non-Form content. try { var content = await ParseContentAsync(context, request); ParsedContent.Set(context, content); } catch (JsonException e) { await HandleInvalidJsonPayloadAsync(context, httpContext, e); throw; } } // Complete. await context.CompleteActivityAsync(); } private IFormFileCollection ReadFilesAsync(ActivityExecutionContext context, HttpRequest request) { return request.HasFormContentType ? request.Form.Files : new FormFileCollection(); } 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) { var fileSizeLimit = FileSizeLimit.GetOrDefault(context); if (!fileSizeLimit.HasValue) return true; if (!files.Any(file => file.Length > fileSizeLimit.Value)) return true; return false; } 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) { 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; return false; } private async Task HandleInvalidFileExtensionWhitelistAsync(ActivityExecutionContext context, HttpContext httpContext) { if (ExposeInvalidFileExtensionOutcome) { await context.CompleteActivityWithOutcomesAsync("Invalid file extension"); return; } var response = httpContext.Response; var allowedFileExtensions = AllowedFileExtensions.GetOrDefault(context)!; response.StatusCode = StatusCodes.Status415UnsupportedMediaType; await response.WriteAsJsonAsync(new { Message = $"Only the following file extensions are allowed: {string.Join(", ", allowedFileExtensions)}" }); await response.Body.FlushAsync(); } private bool ValidateFileExtensionBlacklist(ActivityExecutionContext context, HttpContext httpContext, IFormFileCollection files) { 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; return false; } private async Task HandleInvalidFileExtensionBlacklistAsync(ActivityExecutionContext context, HttpContext httpContext) { if (ExposeInvalidFileExtensionOutcome) { await context.CompleteActivityWithOutcomesAsync("Invalid file extension"); return; } var blockedFileExtensions = BlockedFileExtensions.GetOrDefault(context)!; 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(); } private bool ValidateFileMimeTypes(ActivityExecutionContext context, HttpContext httpContext, IFormFileCollection files) { var allowedMimeTypes = AllowedMimeTypes.GetOrDefault(context); if (allowedMimeTypes == null || !allowedMimeTypes.Any()) return true; if (files.All(file => allowedMimeTypes.Contains(file.ContentType, StringComparer.OrdinalIgnoreCase))) return true; 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)!; 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 HandleInvalidJsonPayloadAsync(ActivityExecutionContext context, HttpContext httpContext, JsonException exception) { var response = httpContext.Response; response.StatusCode = StatusCodes.Status400BadRequest; await response.WriteAsJsonAsync(new { exception.Message, exception.Path, exception.LineNumber, }); await response.Body.FlushAsync(); } private async Task 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!; var headers = httpRequest.Headers.ToDictionary(x => x.Key, x => x.Value.ToArray()); return await context.ParseContentAsync(contentStream, contentType, targetType, headers, cancellationToken); } private static bool HasContent(HttpRequest httpRequest) => httpRequest.Headers.ContentLength > 0; private IEnumerable GetBookmarkPayloads(ExpressionExecutionContext context) { // Generate bookmark data for path and selected methods. var normalizedRoute = context.Get(Path)!.NormalizeRoute(); var methods = SupportedMethods.GetOrDefault(context) ?? new List { HttpMethods.Get }; var authorize = Authorize.GetOrDefault(context); var policy = Policy.GetOrDefault(context); var requestTimeout = RequestTimeout.GetOrDefault(context); var requestSizeLimit = RequestSizeLimit.GetOrDefault(context); return methods .Select(x => new HttpEndpointBookmarkPayload(normalizedRoute, x.ToLowerInvariant(), authorize, policy, requestTimeout, requestSizeLimit)) .Cast() .ToArray(); } private static RouteData GetRouteData(HttpContext httpContext, string path) { var routeData = httpContext.GetRouteData(); var routeTable = httpContext.RequestServices.GetRequiredService(); var routeMatcher = httpContext.RequestServices.GetRequiredService(); var matchingRouteQuery = from route in routeTable let routeValues = routeMatcher.Match(route.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; } }