diff --git a/CHANGELOG.md b/CHANGELOG.md index e70431d..50a1f36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ # Changelog +## [3.2.0] - 2026-08-20 +### Changes +- StreamCopyingResultHandler now handles ForbiddenResult (403). Previously a forbidden result fell through to the default case and returned 500. +- StreamResponse gained a Disposition property (default "inline") so callers can force "attachment" for downloads (e.g. zips) while PDFs and similar keep previewing inline. ## [3.0.0] - 2026-05-12 ### Changes - Updated to .NET 10 diff --git a/Linn.Common.Service.sln b/Linn.Common.Service.sln index b1084b3..be86272 100644 --- a/Linn.Common.Service.sln +++ b/Linn.Common.Service.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.12.35506.116 +# Visual Studio Version 18 +VisualStudioVersion = 18.4.11620.152 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Linn.Common.Service", "src\Linn.Common.Service.csproj", "{C58B1BE7-9DAD-4F3A-AF6E-B9515DE0612B}" EndProject @@ -35,4 +35,7 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B39241F2-FBE3-462C-8F75-F37B6990D3C2} + EndGlobalSection EndGlobal diff --git a/src/Handlers/StreamCopyingResultHandler.cs b/src/Handlers/StreamCopyingResultHandler.cs index c618469..798780d 100644 --- a/src/Handlers/StreamCopyingResultHandler.cs +++ b/src/Handlers/StreamCopyingResultHandler.cs @@ -40,9 +40,12 @@ public async Task Handle( if (!string.IsNullOrEmpty(success.Data.FileName)) { + var disposition = string.IsNullOrEmpty(success.Data.Disposition) + ? "inline" + : success.Data.Disposition; res.Headers["Content-Disposition"] = - $"inline; filename=\"{success.Data.FileName}\""; - } + $"{disposition}; filename=\"{success.Data.FileName}\""; + } res.StatusCode = (int)HttpStatusCode.OK; @@ -73,6 +76,14 @@ public async Task Handle( } break; + case ForbiddenResult forbidden: + res.StatusCode = 403; + if (!string.IsNullOrEmpty(forbidden.Message)) + { + await res.WriteAsync(forbidden.Message, cancellationToken); + } + break; + case NotFoundResult _: res.StatusCode = 404; break; diff --git a/src/Handlers/StreamResponse.cs b/src/Handlers/StreamResponse.cs index 84cab61..bbd4582 100644 --- a/src/Handlers/StreamResponse.cs +++ b/src/Handlers/StreamResponse.cs @@ -7,5 +7,8 @@ public class StreamResponse public string ContentType { get; set; } public string FileName { get; set; } + + // inline (preview, e.g. PDFs) or attachment (force download, e.g. zips) + public string Disposition { get; set; } = "inline"; } } diff --git a/src/Linn.Common.Service.csproj b/src/Linn.Common.Service.csproj index 5d9e629..90411ea 100644 --- a/src/Linn.Common.Service.csproj +++ b/src/Linn.Common.Service.csproj @@ -5,7 +5,7 @@ enable Linn.Common.Service Linn.Common.Service - 3.1.0 + 3.2.0 enable diff --git a/tests/WhenCopyingStreamResult.cs b/tests/WhenCopyingStreamResult.cs new file mode 100644 index 0000000..d446a17 --- /dev/null +++ b/tests/WhenCopyingStreamResult.cs @@ -0,0 +1,98 @@ +namespace Linn.Common.Service.Tests +{ + using System.IO; + using System.Net; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + using FluentAssertions; + + using Linn.Common.Facade; + using Linn.Common.Service.Handlers; + + using Microsoft.AspNetCore.Http; + + using NUnit.Framework; + + public class WhenCopyingStreamResult + { + private StreamCopyingResultHandler handler; + + private DefaultHttpContext context; + + [SetUp] + public void SetUp() + { + this.handler = new StreamCopyingResultHandler(); + this.context = new DefaultHttpContext(); + this.context.Response.Body = new MemoryStream(); + } + + [Test] + public async Task ShouldCopySuccessStreamAndSetAttachmentDisposition() + { + var payload = Encoding.UTF8.GetBytes("zip-bytes"); + var result = new SuccessResult( + new StreamResponse + { + Stream = new MemoryStream(payload), + ContentType = "application/zip", + FileName = "linn-resources.zip", + Disposition = "attachment" + }); + + await this.handler.Handle(this.context.Request, this.context.Response, result, CancellationToken.None); + + this.context.Response.StatusCode.Should().Be((int)HttpStatusCode.OK); + this.context.Response.ContentType.Should().Be("application/zip"); + this.context.Response.Headers["Content-Disposition"].ToString() + .Should().Be("attachment; filename=\"linn-resources.zip\""); + + this.context.Response.Body.Position = 0; + using var reader = new StreamReader(this.context.Response.Body); + (await reader.ReadToEndAsync()).Should().Be("zip-bytes"); + } + + [Test] + public async Task ShouldDefaultToInlineDisposition() + { + var result = new SuccessResult( + new StreamResponse + { + Stream = new MemoryStream(Encoding.UTF8.GetBytes("pdf")), + ContentType = "application/pdf", + FileName = "invoice.pdf" + }); + + await this.handler.Handle(this.context.Request, this.context.Response, result, CancellationToken.None); + + this.context.Response.Headers["Content-Disposition"].ToString() + .Should().Be("inline; filename=\"invoice.pdf\""); + } + + [Test] + public async Task ShouldReturn403ForForbiddenResult() + { + var result = new ForbiddenResult("Access denied"); + + await this.handler.Handle(this.context.Request, this.context.Response, result, CancellationToken.None); + + this.context.Response.StatusCode.Should().Be((int)HttpStatusCode.Forbidden); + + this.context.Response.Body.Position = 0; + using var reader = new StreamReader(this.context.Response.Body); + (await reader.ReadToEndAsync()).Should().Be("Access denied"); + } + + [Test] + public async Task ShouldReturn404ForNotFoundResult() + { + var result = new NotFoundResult("nope"); + + await this.handler.Handle(this.context.Request, this.context.Response, result, CancellationToken.None); + + this.context.Response.StatusCode.Should().Be((int)HttpStatusCode.NotFound); + } + } +}