| | | 1 | | using System.Security.Cryptography; |
| | | 2 | | using Elsa.Extensions; |
| | | 3 | | using Elsa.Http.Exceptions; |
| | | 4 | | using Elsa.Http.Options; |
| | | 5 | | using Elsa.Http.Services; |
| | | 6 | | using Elsa.Workflows; |
| | | 7 | | using Elsa.Workflows.Attributes; |
| | | 8 | | using Elsa.Workflows.Exceptions; |
| | | 9 | | using Elsa.Workflows.Models; |
| | | 10 | | using FluentStorage.Blobs; |
| | | 11 | | using FluentStorage.Utils.Extensions; |
| | | 12 | | using Microsoft.AspNetCore.Http; |
| | | 13 | | using Microsoft.AspNetCore.Mvc; |
| | | 14 | | using Microsoft.AspNetCore.Mvc.Abstractions; |
| | | 15 | | using Microsoft.AspNetCore.Routing; |
| | | 16 | | using Microsoft.AspNetCore.StaticFiles; |
| | | 17 | | using Microsoft.Extensions.Logging; |
| | | 18 | | using Microsoft.Net.Http.Headers; |
| | | 19 | | using EntityTagHeaderValue = System.Net.Http.Headers.EntityTagHeaderValue; |
| | | 20 | | using RangeHeaderValue = System.Net.Http.Headers.RangeHeaderValue; |
| | | 21 | | |
| | | 22 | | namespace Elsa.Http; |
| | | 23 | | |
| | | 24 | | /// <summary> |
| | | 25 | | /// Sends a file to the HTTP response. |
| | | 26 | | /// </summary> |
| | | 27 | | [Activity("Elsa", "HTTP", "Send one ore more files (zipped) to the HTTP response.", DisplayName = "HTTP File Response")] |
| | | 28 | | public class WriteFileHttpResponse : Activity |
| | | 29 | | { |
| | | 30 | | /// <summary> |
| | | 31 | | /// The MIME type of the file to serve. |
| | | 32 | | /// </summary> |
| | | 33 | | [Input(Description = "The content type of the file to serve. Leave empty to let the system determine the content typ |
| | 82 | 34 | | public Input<string?> ContentType { get; set; } = null!; |
| | | 35 | | |
| | | 36 | | /// <summary> |
| | | 37 | | /// The name of the file to serve. |
| | | 38 | | /// </summary> |
| | | 39 | | [Input(Description = "The name of the file to serve. Leave empty to let the system determine the file name.")] |
| | 93 | 40 | | public Input<string?> Filename { get; set; } = null!; |
| | | 41 | | |
| | | 42 | | /// <summary> |
| | | 43 | | /// The Entity Tag of the file to serve. |
| | | 44 | | /// </summary> |
| | | 45 | | [Input(Description = "The Entity Tag of the file to serve. Leave empty to let the system determine the Entity Tag.") |
| | 83 | 46 | | public Input<string?> EntityTag { get; set; } = null!; |
| | | 47 | | |
| | | 48 | | /// <summary> |
| | | 49 | | /// The file content to serve. Supports byte array, streams, string, Uri and an array of the aforementioned types. |
| | | 50 | | /// </summary> |
| | | 51 | | [Input(Description = "The file content to serve. Supports various types, such as byte array, stream, string, Uri, Do |
| | 104 | 52 | | public Input<object> Content { get; set; } = null!; |
| | | 53 | | |
| | | 54 | | /// <summary> |
| | | 55 | | /// Whether to enable resumable downloads. When enabled, the client can resume a download if the connection is lost. |
| | | 56 | | /// </summary> |
| | | 57 | | [Input(Description = "Whether to enable resumable downloads. When enabled, the client can resume a download if the c |
| | 82 | 58 | | public Input<bool> EnableResumableDownloads { get; set; } = null!; |
| | | 59 | | |
| | | 60 | | /// <summary> |
| | | 61 | | /// The correlation ID of the download. Used to resume a download. |
| | | 62 | | /// </summary> |
| | | 63 | | [Input(Description = "The correlation ID of the download used to resume a download. If left empty, the x-download-id |
| | 54 | 64 | | public Input<string> DownloadCorrelationId { get; set; } = null!; |
| | | 65 | | |
| | | 66 | | /// <inheritdoc /> |
| | | 67 | | protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) |
| | | 68 | | { |
| | 27 | 69 | | var httpContextAccessor = context.GetRequiredService<IHttpContextAccessor>(); |
| | 27 | 70 | | var httpContext = httpContextAccessor.HttpContext; |
| | | 71 | | |
| | 27 | 72 | | if (httpContext == null) |
| | | 73 | | { |
| | | 74 | | // We're executing in a non-HTTP context (e.g. in a virtual actor). |
| | | 75 | | // Create a bookmark to allow the invoker to export the state and resume execution from there. |
| | | 76 | | |
| | 2 | 77 | | context.CreateBookmark(OnResumeAsync, BookmarkMetadata.HttpCrossBoundary); |
| | 2 | 78 | | return; |
| | | 79 | | } |
| | | 80 | | |
| | 25 | 81 | | await WriteResponseAsync(context, httpContext); |
| | 26 | 82 | | } |
| | | 83 | | |
| | | 84 | | private async Task WriteResponseAsync(ActivityExecutionContext context, HttpContext httpContext) |
| | | 85 | | { |
| | | 86 | | // Get content and content type. |
| | 25 | 87 | | var content = context.Get(Content); |
| | | 88 | | |
| | | 89 | | // Write content. |
| | 25 | 90 | | var downloadables = GetDownloadables(context, httpContext, content).ToList(); |
| | 25 | 91 | | await SendDownloadablesAsync(context, httpContext, downloadables); |
| | | 92 | | |
| | | 93 | | // Complete activity. |
| | 24 | 94 | | await context.CompleteActivityAsync(); |
| | 24 | 95 | | } |
| | | 96 | | |
| | | 97 | | private async Task SendDownloadablesAsync(ActivityExecutionContext context, HttpContext httpContext, IEnumerable<Fun |
| | | 98 | | { |
| | 25 | 99 | | var downloadableList = downloadables.ToList(); |
| | | 100 | | |
| | 25 | 101 | | switch (downloadableList.Count) |
| | | 102 | | { |
| | | 103 | | case 0: |
| | 1 | 104 | | SendNoContent(context, httpContext); |
| | 1 | 105 | | return; |
| | | 106 | | case 1: |
| | | 107 | | { |
| | 24 | 108 | | var downloadable = downloadableList[0]; |
| | 24 | 109 | | await SendSingleFileAsync(context, httpContext, downloadable); |
| | 23 | 110 | | return; |
| | | 111 | | } |
| | | 112 | | default: |
| | 0 | 113 | | await SendMultipleFilesAsync(context, httpContext, downloadableList); |
| | | 114 | | break; |
| | | 115 | | } |
| | 24 | 116 | | } |
| | | 117 | | |
| | | 118 | | private void SendNoContent(ActivityExecutionContext context, HttpContext httpContext) |
| | | 119 | | { |
| | 1 | 120 | | httpContext.Response.StatusCode = StatusCodes.Status204NoContent; |
| | 1 | 121 | | } |
| | | 122 | | |
| | | 123 | | private async Task SendSingleFileAsync(ActivityExecutionContext context, HttpContext httpContext, Func<ValueTask<Dow |
| | | 124 | | { |
| | 24 | 125 | | var contentType = ContentType.GetOrDefault(context); |
| | 24 | 126 | | var filename = Filename.GetOrDefault(context); |
| | 24 | 127 | | var eTag = EntityTag.GetOrDefault(context); |
| | 24 | 128 | | var downloadable = await downloadableFunc(); |
| | 24 | 129 | | filename = !string.IsNullOrWhiteSpace(filename) ? filename : !string.IsNullOrWhiteSpace(downloadable.Filename) ? |
| | 24 | 130 | | contentType = !string.IsNullOrWhiteSpace(contentType) ? contentType : !string.IsNullOrWhiteSpace(downloadable.Co |
| | 24 | 131 | | eTag = !string.IsNullOrWhiteSpace(eTag) ? eTag : !string.IsNullOrWhiteSpace(downloadable.ETag) ? downloadable.ET |
| | | 132 | | |
| | 24 | 133 | | var eTagHeaderValue = !string.IsNullOrWhiteSpace(eTag) ? new EntityTagHeaderValue(eTag) : null; |
| | 23 | 134 | | var stream = downloadable.Stream; |
| | 23 | 135 | | await SendFileStream(context, httpContext, stream, contentType, filename, eTagHeaderValue); |
| | 23 | 136 | | } |
| | | 137 | | |
| | | 138 | | private async Task SendMultipleFilesAsync(ActivityExecutionContext context, HttpContext httpContext, ICollection<Fun |
| | | 139 | | { |
| | | 140 | | // If resumable downloads are enabled, check to see if we have a cached file. |
| | 0 | 141 | | var (zipBlob, zipStream, cleanupCallback) = await TryLoadCachedFileAsync(context, httpContext) ?? await Generate |
| | | 142 | | |
| | | 143 | | try |
| | | 144 | | { |
| | | 145 | | // Send the temporary file back to the client. |
| | 0 | 146 | | var contentType = zipBlob.Metadata["ContentType"]; |
| | 0 | 147 | | var downloadAsFilename = zipBlob.Metadata["Filename"]; |
| | 0 | 148 | | var hash = ComputeHash(zipStream); |
| | 0 | 149 | | var eTag = $"\"{hash}\""; |
| | 0 | 150 | | var eTagHeaderValue = new EntityTagHeaderValue(eTag); |
| | 0 | 151 | | await SendFileStream(context, httpContext, zipStream, contentType, downloadAsFilename, eTagHeaderValue); |
| | | 152 | | |
| | | 153 | | // TODO: Delete the cached file after the workflow completes. |
| | 0 | 154 | | } |
| | 0 | 155 | | catch (Exception e) |
| | | 156 | | { |
| | 0 | 157 | | var logger = context.GetRequiredService<ILogger<WriteFileHttpResponse>>(); |
| | 0 | 158 | | logger.LogWarning(e, "Failed to send zip file to HTTP response"); |
| | 0 | 159 | | } |
| | | 160 | | finally |
| | | 161 | | { |
| | | 162 | | // Delete any temporary files. |
| | 0 | 163 | | await cleanupCallback(); |
| | | 164 | | } |
| | 0 | 165 | | } |
| | | 166 | | |
| | | 167 | | private string ComputeHash(Stream stream) |
| | | 168 | | { |
| | 0 | 169 | | stream.Seek(0, SeekOrigin.Begin); |
| | 0 | 170 | | var bytes = stream.ToByteArray()!; |
| | 0 | 171 | | using var md5Hash = MD5.Create(); |
| | 0 | 172 | | var hash = md5Hash.ComputeHash(bytes); |
| | 0 | 173 | | stream.Seek(0, SeekOrigin.Begin); |
| | 0 | 174 | | return Convert.ToBase64String(hash); |
| | 0 | 175 | | } |
| | | 176 | | |
| | | 177 | | private async Task<(Blob, Stream, Func<ValueTask>)> GenerateZipFileAsync(ActivityExecutionContext context, HttpConte |
| | | 178 | | { |
| | 0 | 179 | | var cancellationToken = context.CancellationToken; |
| | 0 | 180 | | var downloadCorrelationId = GetDownloadCorrelationId(context, httpContext); |
| | 0 | 181 | | var contentType = ContentType.GetOrDefault(context); |
| | 0 | 182 | | var downloadAsFilename = Filename.GetOrDefault(context); |
| | 0 | 183 | | var zipService = context.GetRequiredService<ZipManager>(); |
| | 0 | 184 | | var (zipBlob, zipStream, cleanup) = await zipService.CreateAsync(downloadables, true, downloadCorrelationId, dow |
| | | 185 | | |
| | 0 | 186 | | return (zipBlob, zipStream, Cleanup); |
| | | 187 | | |
| | | 188 | | ValueTask Cleanup() |
| | | 189 | | { |
| | 0 | 190 | | cleanup(); |
| | 0 | 191 | | return default; |
| | | 192 | | } |
| | 0 | 193 | | } |
| | | 194 | | |
| | | 195 | | private async Task<(Blob, Stream, Func<ValueTask>)?> TryLoadCachedFileAsync(ActivityExecutionContext context, HttpCo |
| | | 196 | | { |
| | 0 | 197 | | var downloadCorrelationId = GetDownloadCorrelationId(context, httpContext); |
| | | 198 | | |
| | 0 | 199 | | if (string.IsNullOrWhiteSpace(downloadCorrelationId)) |
| | 0 | 200 | | return null; |
| | | 201 | | |
| | 0 | 202 | | var cancellationToken = context.CancellationToken; |
| | 0 | 203 | | var zipService = context.GetRequiredService<ZipManager>(); |
| | 0 | 204 | | var tuple = await zipService.LoadAsync(downloadCorrelationId, cancellationToken); |
| | | 205 | | |
| | 0 | 206 | | if (tuple == null) |
| | 0 | 207 | | return null; |
| | | 208 | | |
| | 0 | 209 | | return (tuple.Value.Item1, tuple.Value.Item2, Noop); |
| | | 210 | | |
| | 0 | 211 | | ValueTask Noop() => default; |
| | 0 | 212 | | } |
| | | 213 | | |
| | | 214 | | private string GetDownloadCorrelationId(ActivityExecutionContext context, HttpContext httpContext) |
| | | 215 | | { |
| | 0 | 216 | | var downloadCorrelationId = DownloadCorrelationId.GetOrDefault(context); |
| | | 217 | | |
| | 0 | 218 | | if (string.IsNullOrWhiteSpace(downloadCorrelationId)) |
| | 0 | 219 | | downloadCorrelationId = httpContext.Request.Headers["x-download-id"]; |
| | | 220 | | |
| | 0 | 221 | | if (string.IsNullOrWhiteSpace(downloadCorrelationId)) |
| | | 222 | | { |
| | 0 | 223 | | var identity = context.WorkflowExecutionContext.Workflow.Identity; |
| | 0 | 224 | | var definitionId = identity.DefinitionId; |
| | 0 | 225 | | var version = identity.Version.ToString(); |
| | 0 | 226 | | var correlationId = context.WorkflowExecutionContext.CorrelationId; |
| | 0 | 227 | | var sources = new[] { definitionId, version, correlationId }.Where(x => !string.IsNullOrWhiteSpace(x)).ToArr |
| | | 228 | | |
| | 0 | 229 | | downloadCorrelationId = string.Join("-", sources); |
| | | 230 | | } |
| | | 231 | | |
| | 0 | 232 | | return downloadCorrelationId; |
| | | 233 | | } |
| | | 234 | | |
| | | 235 | | private async Task SendFileStream(ActivityExecutionContext context, HttpContext httpContext, Stream source, string c |
| | | 236 | | { |
| | 23 | 237 | | if(source.CanSeek) |
| | 23 | 238 | | source.Seek(0, SeekOrigin.Begin); |
| | | 239 | | |
| | 23 | 240 | | var enableResumableDownloads = EnableResumableDownloads.GetOrDefault(context, () => false); |
| | | 241 | | |
| | 23 | 242 | | var result = new FileStreamResult(source, contentType) |
| | 23 | 243 | | { |
| | 23 | 244 | | EnableRangeProcessing = enableResumableDownloads, |
| | 23 | 245 | | EntityTag = enableResumableDownloads ? eTag != null ? new Microsoft.Net.Http.Headers.EntityTagHeaderValue(eT |
| | 23 | 246 | | FileDownloadName = filename |
| | 23 | 247 | | }; |
| | | 248 | | |
| | 23 | 249 | | var actionContext = new ActionContext(httpContext, httpContext.GetRouteData(), new ActionDescriptor()); |
| | 23 | 250 | | await result.ExecuteResultAsync(actionContext); |
| | 23 | 251 | | } |
| | | 252 | | |
| | | 253 | | private IEnumerable<Func<ValueTask<Downloadable>>> GetDownloadables(ActivityExecutionContext context, HttpContext ht |
| | | 254 | | { |
| | 25 | 255 | | if (content == null) |
| | 1 | 256 | | return Enumerable.Empty<Func<ValueTask<Downloadable>>>(); |
| | | 257 | | |
| | 24 | 258 | | var manager = context.GetRequiredService<IDownloadableManager>(); |
| | 24 | 259 | | var headers = httpContext.Request.Headers; |
| | 24 | 260 | | var eTag = GetIfMatchHeaderValue(headers); |
| | 24 | 261 | | var range = GetRangeHeaderHeaderValue(headers); |
| | 24 | 262 | | var options = new DownloadableOptions { ETag = eTag, Range = range }; |
| | 24 | 263 | | return manager.GetDownloadablesAsync(content, options, context.CancellationToken); |
| | | 264 | | } |
| | | 265 | | |
| | | 266 | | private string GetContentType(ActivityExecutionContext context, string filename) |
| | | 267 | | { |
| | 17 | 268 | | var provider = context.GetRequiredService<IContentTypeProvider>(); |
| | 17 | 269 | | return provider.TryGetContentType(filename, out var contentType) ? contentType : System.Net.Mime.MediaTypeNames. |
| | | 270 | | } |
| | | 271 | | |
| | | 272 | | private static RangeHeaderValue? GetRangeHeaderHeaderValue(IHeaderDictionary headers) |
| | | 273 | | { |
| | | 274 | | try |
| | | 275 | | { |
| | 24 | 276 | | return headers.TryGetValue(HeaderNames.Range, out var header) ? RangeHeaderValue.Parse(header.ToString()) : |
| | | 277 | | |
| | | 278 | | } |
| | 0 | 279 | | catch (Exception e) |
| | | 280 | | { |
| | 0 | 281 | | throw new HttpBadRequestException("Failed to parse Range header value", e); |
| | | 282 | | } |
| | 24 | 283 | | } |
| | | 284 | | |
| | | 285 | | private static EntityTagHeaderValue? GetIfMatchHeaderValue(IHeaderDictionary headers) |
| | | 286 | | { |
| | | 287 | | try |
| | | 288 | | { |
| | 24 | 289 | | return headers.TryGetValue(HeaderNames.IfMatch, out var header) ? new EntityTagHeaderValue(header.ToString() |
| | | 290 | | |
| | | 291 | | } |
| | 0 | 292 | | catch (Exception e) |
| | | 293 | | { |
| | 0 | 294 | | throw new HttpBadRequestException("Failed to parse If-Match header value", e); |
| | | 295 | | } |
| | 24 | 296 | | } |
| | | 297 | | |
| | | 298 | | private async ValueTask OnResumeAsync(ActivityExecutionContext context) |
| | | 299 | | { |
| | 0 | 300 | | var httpContextAccessor = context.GetRequiredService<IHttpContextAccessor>(); |
| | 0 | 301 | | var httpContext = httpContextAccessor.HttpContext; |
| | | 302 | | |
| | 0 | 303 | | if (httpContext == null) |
| | 0 | 304 | | throw new FaultException(HttpFaultCodes.NoHttpContext, HttpFaultCategories.Http, DefaultFaultTypes.System, " |
| | | 305 | | |
| | 0 | 306 | | await WriteResponseAsync(context, httpContext); |
| | 0 | 307 | | } |
| | | 308 | | } |