diff --git a/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.csproj b/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.csproj index be1f1f3424..0bd2351f38 100644 --- a/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.csproj +++ b/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.netcoreapp3.0.cs b/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.netcoreapp3.0.cs index e7dba441d3..06fc6c792c 100644 --- a/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.netcoreapp3.0.cs +++ b/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.netcoreapp3.0.cs @@ -58,6 +58,13 @@ namespace Microsoft.AspNetCore.Builder public System.Action OnPrepareResponse { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } public bool ServeUnknownFileTypes { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } } + public static partial class StaticFilesEndpointRouteBuilderExtensions + { + public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToFile(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder builder, string filePath) { throw null; } + public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToFile(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder builder, string filePath, Microsoft.AspNetCore.Builder.StaticFileOptions options) { throw null; } + public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToFile(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder builder, string pattern, string filePath) { throw null; } + public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToFile(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder builder, string pattern, string filePath, Microsoft.AspNetCore.Builder.StaticFileOptions options) { throw null; } + } } namespace Microsoft.AspNetCore.StaticFiles { diff --git a/src/Middleware/StaticFiles/src/Microsoft.AspNetCore.StaticFiles.csproj b/src/Middleware/StaticFiles/src/Microsoft.AspNetCore.StaticFiles.csproj index 1b8d3d29a0..c4a164b987 100644 --- a/src/Middleware/StaticFiles/src/Microsoft.AspNetCore.StaticFiles.csproj +++ b/src/Middleware/StaticFiles/src/Microsoft.AspNetCore.StaticFiles.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core static files middleware. Includes middleware for serving static files, directory browsing, and default files. @@ -16,6 +16,7 @@ + diff --git a/src/Middleware/StaticFiles/src/StaticFilesEndpointRouteBuilderExtensions.cs b/src/Middleware/StaticFiles/src/StaticFilesEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000000..73c32b5467 --- /dev/null +++ b/src/Middleware/StaticFiles/src/StaticFilesEndpointRouteBuilderExtensions.cs @@ -0,0 +1,223 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Endpoints; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.StaticFiles; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Contains extension methods for using static files with endpoint routing. + /// + public static class StaticFilesEndpointRouteBuilderExtensions + { + /// + /// Adds a specialized to the that will match + /// requests for non-filenames with the lowest possible priority. The request will be routed to a + /// that attempts to serve the file specified by . + /// + /// The . + /// The file path of the file to serve. + /// The + /// + /// + /// is intended to handle cases where URL path of + /// the request does not contain a filename, and no other endpoint has matched. This is convenient for routing + /// requests for dynamic content to a SPA framework, while also allowing requests for non-existent files to + /// result in an HTTP 404. + /// + /// + /// The default for the will be used. + /// + /// + /// registers an endpoint using the pattern + /// {*path:nonfile}. The order of the registered endpoint will be int.MaxValue. + /// + /// + public static IEndpointConventionBuilder MapFallbackToFile( + this IEndpointRouteBuilder builder, + string filePath) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (filePath == null) + { + throw new ArgumentNullException(nameof(filePath)); + } + + return builder.MapFallback(CreateRequestDelegate(builder, filePath)); + } + + /// + /// Adds a specialized to the that will match + /// requests for non-filenames with the lowest possible priority. The request will be routed to a + /// that attempts to serve the file specified by . + /// + /// The . + /// The file path of the file to serve. + /// for the . + /// The + /// + /// + /// is intended to handle cases + /// where URL path of the request does not contain a file name, and no other endpoint has matched. This is convenient + /// for routing requests for dynamic content to a SPA framework, while also allowing requests for non-existent files to + /// result in an HTTP 404. + /// + /// + /// registers an endpoint using the pattern + /// {*path:nonfile}. The order of the registered endpoint will be int.MaxValue. + /// + /// + public static IEndpointConventionBuilder MapFallbackToFile( + this IEndpointRouteBuilder builder, + string filePath, + StaticFileOptions options) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (filePath == null) + { + throw new ArgumentNullException(nameof(filePath)); + } + + return builder.MapFallback(CreateRequestDelegate(builder, filePath, options)); + } + + /// + /// Adds a specialized to the that will match + /// requests for non-filenames with the lowest possible priority. The request will be routed to a + /// that attempts to serve the file specified by . + /// + /// The . + /// The route pattern. + /// The file path of the file to serve. + /// The + /// + /// + /// is intended to handle + /// cases where URL path of the request does not contain a filename, and no other endpoint has matched. This is + /// convenient for routing requests for dynamic content to a SPA framework, while also allowing requests for + /// non-existent files to result in an HTTP 404. + /// + /// + /// The default for the will be used. + /// + /// + /// The order of the registered endpoint will be int.MaxValue. + /// + /// + /// This overload will use the provided verbatim. Use the :nonfile route contraint + /// to exclude requests for static files. + /// + /// + public static IEndpointConventionBuilder MapFallbackToFile( + this IEndpointRouteBuilder builder, + string pattern, + string filePath) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (pattern == null) + { + throw new ArgumentNullException(nameof(filePath)); + } + + if (filePath == null) + { + throw new ArgumentNullException(nameof(filePath)); + } + + return builder.MapFallback(pattern, CreateRequestDelegate(builder, filePath)); + } + + /// + /// Adds a specialized to the that will match + /// requests for non-filenames with the lowest possible priority. The request will be routed to a + /// that attempts to serve the file specified by . + /// + /// The .\ + /// The route pattern. + /// The file path of the file to serve. + /// for the . + /// The + /// + /// + /// is intended to handle + /// cases where URL path of the request does not contain a filename, and no other endpoint has matched. This is + /// convenient for routing requests for dynamic content to a SPA framework, while also allowing requests for + /// non-existent files to result in an HTTP 404. + /// + /// + /// The order of the registered endpoint will be int.MaxValue. + /// + /// + /// This overload will use the provided verbatim. Use the :nonfile route contraint + /// to exclude requests for static files. + /// + /// + public static IEndpointConventionBuilder MapFallbackToFile( + this IEndpointRouteBuilder builder, + string pattern, + string filePath, + StaticFileOptions options) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (pattern == null) + { + throw new ArgumentNullException(nameof(filePath)); + } + + if (filePath == null) + { + throw new ArgumentNullException(nameof(filePath)); + } + + return builder.MapFallback(pattern, CreateRequestDelegate(builder, filePath, options)); + } + + private static RequestDelegate CreateRequestDelegate( + IEndpointRouteBuilder builder, + string filePath, + StaticFileOptions options = null) + { + var app = builder.CreateApplicationBuilder(); + app.Use(next => context => + { + context.Request.Path = "/" + filePath; + + // Set endpoint to null so the static files middleware will handle the request. + context.SetEndpoint(null); + + return next(context); + }); + + if (options == null) + { + app.UseStaticFiles(); + } + else + { + app.UseStaticFiles(options); + } + + return app.Build(); + } + } +} diff --git a/src/Middleware/StaticFiles/test/FunctionalTests/FallbackStaticFileTest.cs b/src/Middleware/StaticFiles/test/FunctionalTests/FallbackStaticFileTest.cs new file mode 100644 index 0000000000..00dc48ae08 --- /dev/null +++ b/src/Middleware/StaticFiles/test/FunctionalTests/FallbackStaticFileTest.cs @@ -0,0 +1,131 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.IntegrationTesting; +using Microsoft.AspNetCore.Server.IntegrationTesting.Common; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.AspNetCore.StaticFiles +{ + public class FallbackStaticFileTest : LoggedTest + { + [Fact] + public async Task ReturnsFileForDefaultPattern() + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddRouting(); + services.AddSingleton(LoggerFactory); + }) + .UseKestrel() + .UseWebRoot(AppContext.BaseDirectory) + .Configure(app => + { + var environment = app.ApplicationServices.GetRequiredService(); + + app.UseRouting(routes => + { + routes.Map("/hello", context => + { + return context.Response.WriteAsync("Hello, world!"); + }); + + routes.MapFallbackToFile("default.html", new StaticFileOptions() + { + FileProvider = new PhysicalFileProvider(Path.Combine(environment.WebRootPath, "SubFolder")), + }); + }); + }); + + using (var server = builder.Start(TestUrlHelper.GetTestUrl(ServerType.Kestrel))) + { + var environment = server.Services.GetRequiredService(); + using (var client = new HttpClient { BaseAddress = new Uri(server.GetAddress()) }) + { + var response = await client.GetAsync("hello"); + var responseText = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Hello, world!", responseText); + + response = await client.GetAsync("/"); + var responseContent = await response.Content.ReadAsByteArrayAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertFileEquals(environment, "SubFolder/default.html", responseContent); + } + } + } + + [Fact] + public async Task ReturnsFileForCustomPattern() + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddRouting(); + services.AddSingleton(LoggerFactory); + }) + .UseKestrel() + .UseWebRoot(AppContext.BaseDirectory) + .Configure(app => + { + app.UseRouting(routes => + { + routes.Map("/hello", context => + { + return context.Response.WriteAsync("Hello, world!"); + }); + + routes.MapFallbackToFile("/prefix/{*path:nonfile}", "TestDocument.txt"); + }); + }); + + using (var server = builder.Start(TestUrlHelper.GetTestUrl(ServerType.Kestrel))) + { + var environment = server.Services.GetRequiredService(); + using (var client = new HttpClient { BaseAddress = new Uri(server.GetAddress()) }) + { + var response = await client.GetAsync("hello"); + var responseText = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Hello, world!", responseText); + + response = await client.GetAsync("prefix/Some-Path"); + var responseContent = await response.Content.ReadAsByteArrayAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertFileEquals(environment, "TestDocument.txt", responseContent); + } + } + } + + private static void AssertFileEquals(IWebHostEnvironment environment, string filePath, byte[] responseContent) + { + var fileInfo = environment.WebRootFileProvider.GetFileInfo(filePath); + Assert.NotNull(fileInfo); + Assert.True(fileInfo.Exists); + + using (var stream = fileInfo.CreateReadStream()) + { + var fileContents = new byte[stream.Length]; + stream.Read(fileContents, 0, (int)stream.Length); + Assert.True(responseContent.SequenceEqual(fileContents)); + } + } + } +}