From c39c05c08ee14c03ecd61adc90a6fd3c1714826b Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 11 Sep 2024 14:58:07 -0700 Subject: [PATCH] feat!: set content disposition of file downloads to "inline" instead of "attachment" by default. This allows named files to be displayed directly in the browser where possible. Forcing downloads is now available by setting ForceDownload on the Coalesce `File` object, or using the `a[download]` attribute in the browser. --- .../Api/Generated/CaseController.g.cs | 20 ++---------- playground/Coalesce.Web.Vue2/src/models.g.ts | 4 ++- .../Api/Generated/CaseController.g.cs | 20 ++---------- .../BaseGenerators/ApiController.cs | 16 +--------- .../Api/Controllers/BaseApiController.cs | 25 +++++++++++++++ src/IntelliTect.Coalesce/Models/File.cs | 8 +++++ src/IntelliTect.Coalesce/Models/IFile.cs | 1 + src/coalesce-vue/src/api-client.ts | 32 +++++++++++++++---- 8 files changed, 67 insertions(+), 59 deletions(-) diff --git a/playground/Coalesce.Web.Vue2/Api/Generated/CaseController.g.cs b/playground/Coalesce.Web.Vue2/Api/Generated/CaseController.g.cs index df9c794ee..fd1c7932d 100644 --- a/playground/Coalesce.Web.Vue2/Api/Generated/CaseController.g.cs +++ b/playground/Coalesce.Web.Vue2/Api/Generated/CaseController.g.cs @@ -235,15 +235,7 @@ await item.UploadImage( ); if (_methodResult != null) { - string _contentType = _methodResult.ContentType; - if (string.IsNullOrWhiteSpace(_contentType) && ( - string.IsNullOrWhiteSpace(_methodResult.Name) || - !(new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider().TryGetContentType(_methodResult.Name, out _contentType)) - )) - { - _contentType = "application/octet-stream"; - } - return File(_methodResult.Content, _contentType, _methodResult.Name, !(_methodResult.Content is System.IO.MemoryStream)); + return File(_methodResult); } else { @@ -286,15 +278,7 @@ await item.UploadImage( ); if (_methodResult.Object != null) { - string _contentType = _methodResult.Object.ContentType; - if (string.IsNullOrWhiteSpace(_contentType) && ( - string.IsNullOrWhiteSpace(_methodResult.Object.Name) || - !(new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider().TryGetContentType(_methodResult.Object.Name, out _contentType)) - )) - { - _contentType = "application/octet-stream"; - } - return File(_methodResult.Object.Content, _contentType, _methodResult.Object.Name, !(_methodResult.Object.Content is System.IO.MemoryStream)); + return File(_methodResult.Object); } var _result = new ItemResult(_methodResult); _result.Object = _methodResult.Object; diff --git a/playground/Coalesce.Web.Vue2/src/models.g.ts b/playground/Coalesce.Web.Vue2/src/models.g.ts index 6b988146d..c2c9f62d7 100644 --- a/playground/Coalesce.Web.Vue2/src/models.g.ts +++ b/playground/Coalesce.Web.Vue2/src/models.g.ts @@ -811,12 +811,14 @@ export class WeatherData { declare module "coalesce-vue/lib/model" { - interface ModelTypeLookup { + interface EnumTypeLookup { AuditEntryState: AuditEntryState Genders: Genders SkyConditions: SkyConditions Statuses: Statuses Titles: Titles + } + interface ModelTypeLookup { AuditLog: AuditLog AuditLogProperty: AuditLogProperty Case: Case diff --git a/playground/Coalesce.Web.Vue3/Api/Generated/CaseController.g.cs b/playground/Coalesce.Web.Vue3/Api/Generated/CaseController.g.cs index c67e1fc80..f837bbccb 100644 --- a/playground/Coalesce.Web.Vue3/Api/Generated/CaseController.g.cs +++ b/playground/Coalesce.Web.Vue3/Api/Generated/CaseController.g.cs @@ -235,15 +235,7 @@ await item.UploadImage( ); if (_methodResult != null) { - string _contentType = _methodResult.ContentType; - if (string.IsNullOrWhiteSpace(_contentType) && ( - string.IsNullOrWhiteSpace(_methodResult.Name) || - !(new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider().TryGetContentType(_methodResult.Name, out _contentType)) - )) - { - _contentType = "application/octet-stream"; - } - return File(_methodResult.Content, _contentType, _methodResult.Name, !(_methodResult.Content is System.IO.MemoryStream)); + return File(_methodResult); } else { @@ -286,15 +278,7 @@ await item.UploadImage( ); if (_methodResult.Object != null) { - string _contentType = _methodResult.Object.ContentType; - if (string.IsNullOrWhiteSpace(_contentType) && ( - string.IsNullOrWhiteSpace(_methodResult.Object.Name) || - !(new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider().TryGetContentType(_methodResult.Object.Name, out _contentType)) - )) - { - _contentType = "application/octet-stream"; - } - return File(_methodResult.Object.Content, _contentType, _methodResult.Object.Name, !(_methodResult.Object.Content is System.IO.MemoryStream)); + return File(_methodResult.Object); } var _result = new ItemResult(_methodResult); _result.Object = _methodResult.Object; diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Api/BaseGenerators/ApiController.cs b/src/IntelliTect.Coalesce.CodeGeneration.Api/BaseGenerators/ApiController.cs index a24694c7f..b3ccdeb1a 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Api/BaseGenerators/ApiController.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Api/BaseGenerators/ApiController.cs @@ -407,22 +407,8 @@ public void WriteMethodResultProcessBlock(CSharpCodeBuilder b, MethodViewModel m { using (b.Block($"if ({resultVar} != null)")) { - var fileNameVar = $"{resultVar}.{nameof(IFile.Name)}"; - b.Line($"string _contentType = {resultVar}.{nameof(IFile.ContentType)};"); - - b.Line($"if (string.IsNullOrWhiteSpace(_contentType) && ("); - b.Indented($"string.IsNullOrWhiteSpace({fileNameVar}) ||"); - b.Indented($"!(new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider().TryGetContentType({fileNameVar}, out _contentType))"); - using (b.Block("))")) - { - b.Line($"_contentType = \"application/octet-stream\";"); - } - var contentStreamVar = $"{resultVar}.{nameof(IFile.Content)}"; - // Use range processing if the result stream isn't a MemoryStream. - // MemoryStreams are just going to mean we're dumping the whole byte array straight back to the client. - // Other streams might be more elegant, e.g. QueryableContentStream - b.Line($"return File({contentStreamVar}, _contentType, {fileNameVar}, !({contentStreamVar} is System.IO.MemoryStream));"); + b.Line($"return File({resultVar});"); } if (!method.TaskUnwrappedReturnType.IsA()) diff --git a/src/IntelliTect.Coalesce/Api/Controllers/BaseApiController.cs b/src/IntelliTect.Coalesce/Api/Controllers/BaseApiController.cs index b339e877b..29dbfde68 100644 --- a/src/IntelliTect.Coalesce/Api/Controllers/BaseApiController.cs +++ b/src/IntelliTect.Coalesce/Api/Controllers/BaseApiController.cs @@ -3,9 +3,11 @@ using IntelliTect.Coalesce.Models; using IntelliTect.Coalesce.TypeDefinition; using IntelliTect.Coalesce.TypeDefinition.Enums; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.EntityFrameworkCore; +using Microsoft.Net.Http.Headers; using System; using System.Collections.Generic; using System.Linq; @@ -89,6 +91,29 @@ protected Task> CountImplementation(FilterParameters parameters, { return behaviors.DeleteAsync(id, dataSource, parameters); } + + protected ActionResult File(IFile _methodResult) + { + string? _contentType = _methodResult.ContentType; + if (string.IsNullOrWhiteSpace(_contentType) && + ( + string.IsNullOrWhiteSpace(_methodResult.Name) || + !new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider().TryGetContentType(_methodResult.Name, out _contentType) + ) + ) + { + _contentType = "application/octet-stream"; + } + + ContentDispositionHeaderValue cd = new(_methodResult.ForceDownload ? "attachment" : "inline"); + cd.SetHttpFileName(_methodResult.Name); + Response.GetTypedHeaders().ContentDisposition = cd; + + // Use range processing if the result stream isn't a MemoryStream. + // MemoryStreams are just going to mean we're dumping the whole byte array straight back to the client. + // Other streams might be more elegant, e.g. QueryableContentStream + return File(_methodResult.Content!, _contentType, !(_methodResult.Content is System.IO.MemoryStream)); + } } public abstract class BaseApiController : BaseApiController diff --git a/src/IntelliTect.Coalesce/Models/File.cs b/src/IntelliTect.Coalesce/Models/File.cs index 19d9b24d8..b8acd0d43 100644 --- a/src/IntelliTect.Coalesce/Models/File.cs +++ b/src/IntelliTect.Coalesce/Models/File.cs @@ -47,6 +47,14 @@ public File(IQueryable contentQuery) public string? ContentType { get; set; } public Stream? Content { get; set; } + + /// + /// When used in a method result, forces the file to be downloaded by a browser + /// when the method's URL is opened directly as a link, + /// rather than attempting to display the file in the browser tab for supported content types. + /// This can also be forced with the `download` attribute on an `a` HTML element. + /// + public bool ForceDownload { get; set; } } } diff --git a/src/IntelliTect.Coalesce/Models/IFile.cs b/src/IntelliTect.Coalesce/Models/IFile.cs index a23b4bed0..baf898694 100644 --- a/src/IntelliTect.Coalesce/Models/IFile.cs +++ b/src/IntelliTect.Coalesce/Models/IFile.cs @@ -11,5 +11,6 @@ public interface IFile string? ContentType { get; } string? Name { get; } long Length { get; } + bool ForceDownload { get; } } } \ No newline at end of file diff --git a/src/coalesce-vue/src/api-client.ts b/src/coalesce-vue/src/api-client.ts index f2fdbc7ca..52191c1a9 100644 --- a/src/coalesce-vue/src/api-client.ts +++ b/src/coalesce-vue/src/api-client.ts @@ -811,12 +811,30 @@ export class ApiClient { // Determine the name of the downloaded file. // https://stackoverflow.com/a/40940790 let disposition = r.headers["content-disposition"]; - let filename = ""; - if (disposition && disposition.indexOf("attachment") !== -1) { - var filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; - var matches = filenameRegex.exec(disposition); - if (matches != null && matches[1]) { - filename = matches[1].replace(/['"]/g, ""); + let fileName = ""; + if (disposition) { + const utf8FilenameRegex = + /filename\*=UTF-8''([\w%\-\.]+)(?:; ?|$)/i; + const asciiFilenameRegex = + /^filename=(["']?)(.*?[^\\])\1(?:; ?|$)/i; + + if (utf8FilenameRegex.test(disposition)) { + fileName = decodeURIComponent( + utf8FilenameRegex.exec(disposition)![1] + ); + } else { + // prevent ReDos attacks by anchoring the ascii regex to string start and + // slicing off everything before 'filename=' + const filenameStart = disposition + .toLowerCase() + .indexOf("filename="); + if (filenameStart >= 0) { + const partialDisposition = disposition.slice(filenameStart); + const matches = asciiFilenameRegex.exec(partialDisposition); + if (matches != null && matches[2]) { + fileName = matches[2]; + } + } } } @@ -824,7 +842,7 @@ export class ApiClient { const blob: Blob = r.data; r.data = >{ wasSuccessful: true, - object: new File([blob], filename, { type: blob.type }), + object: new File([blob], fileName, { type: blob.type }), }; return r;