Skip to content

Commit

Permalink
feat!: set content disposition of file downloads to "inline" instead …
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
ascott18 committed Sep 11, 2024
1 parent 66a483f commit c39c05c
Show file tree
Hide file tree
Showing 8 changed files with 67 additions and 59 deletions.
20 changes: 2 additions & 18 deletions playground/Coalesce.Web.Vue2/Api/Generated/CaseController.g.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion playground/Coalesce.Web.Vue2/src/models.g.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 2 additions & 18 deletions playground/Coalesce.Web.Vue3/Api/Generated/CaseController.g.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiResult>())
Expand Down
25 changes: 25 additions & 0 deletions src/IntelliTect.Coalesce/Api/Controllers/BaseApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -89,6 +91,29 @@ protected Task<ItemResult<int>> CountImplementation(FilterParameters parameters,
{
return behaviors.DeleteAsync<TDtoOut>(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<T, TDtoIn, TDtoOut, TContext> : BaseApiController<T, TDtoIn, TDtoOut>
Expand Down
8 changes: 8 additions & 0 deletions src/IntelliTect.Coalesce/Models/File.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ public File(IQueryable<byte[]> contentQuery)
public string? ContentType { get; set; }

public Stream? Content { get; set; }

/// <summary>
/// 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.
/// </summary>
public bool ForceDownload { get; set; }
}

}
1 change: 1 addition & 0 deletions src/IntelliTect.Coalesce/Models/IFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ public interface IFile
string? ContentType { get; }
string? Name { get; }
long Length { get; }
bool ForceDownload { get; }
}
}
32 changes: 25 additions & 7 deletions src/coalesce-vue/src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,20 +811,38 @@ export class ApiClient<T extends ApiRoutedType> {
// 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];
}
}
}
}

// Wrap the blob obtained from Axios into a Coalesce ItemResult.
const blob: Blob = r.data;
r.data = <ItemResult<File>>{
wasSuccessful: true,
object: new File([blob], filename, { type: blob.type }),
object: new File([blob], fileName, { type: blob.type }),
};
return r;

Expand Down

0 comments on commit c39c05c

Please sign in to comment.