diff --git a/src/Middleware/StaticFiles/src/StaticFileContext.cs b/src/Middleware/StaticFiles/src/StaticFileContext.cs index c21e3df7fe2b..3d5f7aa9e6ba 100644 --- a/src/Middleware/StaticFiles/src/StaticFileContext.cs +++ b/src/Middleware/StaticFiles/src/StaticFileContext.cs @@ -2,13 +2,13 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Endpoints; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Headers; @@ -22,128 +22,98 @@ namespace Microsoft.AspNetCore.StaticFiles internal struct StaticFileContext { private const int StreamCopyBufferSize = 64 * 1024; + private readonly HttpContext _context; private readonly StaticFileOptions _options; - private readonly PathString _matchUrl; private readonly HttpRequest _request; private readonly HttpResponse _response; private readonly ILogger _logger; private readonly IFileProvider _fileProvider; - private readonly IContentTypeProvider _contentTypeProvider; - private string _method; - private bool _isGet; - private bool _isHead; - private PathString _subPath; - private string _contentType; + private readonly string _method; + private readonly string _contentType; + private IFileInfo _fileInfo; - private long _length; - private DateTimeOffset _lastModified; private EntityTagHeaderValue _etag; - private RequestHeaders _requestHeaders; private ResponseHeaders _responseHeaders; + private RangeItemHeaderValue _range; + + private long _length; + private readonly PathString _subPath; + private DateTimeOffset _lastModified; private PreconditionState _ifMatchState; private PreconditionState _ifNoneMatchState; private PreconditionState _ifModifiedSinceState; private PreconditionState _ifUnmodifiedSinceState; - private RangeItemHeaderValue _range; - private bool _isRangeRequest; + private RequestType _requestType; - public StaticFileContext(HttpContext context, StaticFileOptions options, PathString matchUrl, ILogger logger, IFileProvider fileProvider, IContentTypeProvider contentTypeProvider) + public StaticFileContext(HttpContext context, StaticFileOptions options, ILogger logger, IFileProvider fileProvider, string contentType, PathString subPath) { _context = context; _options = options; - _matchUrl = matchUrl; _request = context.Request; _response = context.Response; _logger = logger; - _requestHeaders = _request.GetTypedHeaders(); - _responseHeaders = _response.GetTypedHeaders(); _fileProvider = fileProvider; - _contentTypeProvider = contentTypeProvider; - - _method = null; - _isGet = false; - _isHead = false; - _subPath = PathString.Empty; - _contentType = null; + _method = _request.Method; + _contentType = contentType; _fileInfo = null; + _etag = null; + _requestHeaders = null; + _responseHeaders = null; + _range = null; + _length = 0; + _subPath = subPath; _lastModified = new DateTimeOffset(); - _etag = null; _ifMatchState = PreconditionState.Unspecified; _ifNoneMatchState = PreconditionState.Unspecified; _ifModifiedSinceState = PreconditionState.Unspecified; _ifUnmodifiedSinceState = PreconditionState.Unspecified; - _range = null; - _isRangeRequest = false; - } - - internal enum PreconditionState - { - Unspecified, - NotModified, - ShouldProcess, - PreconditionFailed - } - - public bool IsHeadMethod - { - get { return _isHead; } - } - - public bool IsRangeRequest - { - get { return _isRangeRequest; } - } - public string SubPath - { - get { return _subPath.Value; } + if (HttpMethods.IsGet(_method)) + { + _requestType = RequestType.IsGet; + } + else if (HttpMethods.IsHead(_method)) + { + _requestType = RequestType.IsHead; + } + else + { + _requestType = RequestType.Unspecified; + } } - public string PhysicalPath - { - get { return _fileInfo?.PhysicalPath; } - } + private RequestHeaders RequestHeaders => (_requestHeaders ??= _request.GetTypedHeaders()); - public bool ValidateNoEndpoint() - { - // Return true because we only want to run if there is no endpoint. - return _context.GetEndpoint() == null; - } + private ResponseHeaders ResponseHeaders => (_responseHeaders ??= _response.GetTypedHeaders()); - public bool ValidateMethod() - { - _method = _request.Method; - _isGet = HttpMethods.IsGet(_method); - _isHead = HttpMethods.IsHead(_method); - return _isGet || _isHead; - } + public bool IsHeadMethod => _requestType.HasFlag(RequestType.IsHead); - // Check if the URL matches any expected paths - public bool ValidatePath() - { - return Helpers.TryMatchPath(_context, _matchUrl, forDirectory: false, subpath: out _subPath); - } + public bool IsGetMethod => _requestType.HasFlag(RequestType.IsGet); - public bool LookupContentType() + public bool IsRangeRequest { - if (_contentTypeProvider.TryGetContentType(_subPath.Value, out _contentType)) + get => _requestType.HasFlag(RequestType.IsRange); + private set { - return true; + if (value) + { + _requestType |= RequestType.IsRange; + } + else + { + _requestType &= ~RequestType.IsRange; + } } + } - if (_options.ServeUnknownFileTypes) - { - _contentType = _options.DefaultContentType; - return true; - } + public string SubPath => _subPath.Value; - return false; - } + public string PhysicalPath => _fileInfo?.PhysicalPath; public bool LookupFileInfo() { @@ -175,8 +145,10 @@ public void ComprehendRequestHeaders() private void ComputeIfMatch() { + var requestHeaders = RequestHeaders; + // 14.24 If-Match - var ifMatch = _requestHeaders.IfMatch; + var ifMatch = requestHeaders.IfMatch; if (ifMatch != null && ifMatch.Any()) { _ifMatchState = PreconditionState.PreconditionFailed; @@ -191,7 +163,7 @@ private void ComputeIfMatch() } // 14.26 If-None-Match - var ifNoneMatch = _requestHeaders.IfNoneMatch; + var ifNoneMatch = requestHeaders.IfNoneMatch; if (ifNoneMatch != null && ifNoneMatch.Any()) { _ifNoneMatchState = PreconditionState.ShouldProcess; @@ -208,10 +180,11 @@ private void ComputeIfMatch() private void ComputeIfModifiedSince() { + var requestHeaders = RequestHeaders; var now = DateTimeOffset.UtcNow; // 14.25 If-Modified-Since - var ifModifiedSince = _requestHeaders.IfModifiedSince; + var ifModifiedSince = requestHeaders.IfModifiedSince; if (ifModifiedSince.HasValue && ifModifiedSince <= now) { bool modified = ifModifiedSince < _lastModified; @@ -219,7 +192,7 @@ private void ComputeIfModifiedSince() } // 14.28 If-Unmodified-Since - var ifUnmodifiedSince = _requestHeaders.IfUnmodifiedSince; + var ifUnmodifiedSince = requestHeaders.IfUnmodifiedSince; if (ifUnmodifiedSince.HasValue && ifUnmodifiedSince <= now) { bool unmodified = ifUnmodifiedSince >= _lastModified; @@ -230,7 +203,7 @@ private void ComputeIfModifiedSince() private void ComputeIfRange() { // 14.27 If-Range - var ifRangeHeader = _requestHeaders.IfRange; + var ifRangeHeader = RequestHeaders.IfRange; if (ifRangeHeader != null) { // If the validator given in the If-Range header field matches the @@ -242,12 +215,12 @@ private void ComputeIfRange() { if (_lastModified > ifRangeHeader.LastModified) { - _isRangeRequest = false; + IsRangeRequest = false; } } else if (_etag != null && ifRangeHeader.EntityTag != null && !ifRangeHeader.EntityTag.Compare(_etag, useStrongComparison: true)) { - _isRangeRequest = false; + IsRangeRequest = false; } } } @@ -259,12 +232,15 @@ private void ComputeRange() // A server MUST ignore a Range header field received with a request method other // than GET. - if (!_isGet) + if (!IsGetMethod) { return; } - (_isRangeRequest, _range) = RangeHelper.ParseRange(_context, _requestHeaders, _length, _logger); + (var isRangeRequest, var range) = RangeHelper.ParseRange(_context, RequestHeaders, _length, _logger); + + _range = range; + IsRangeRequest = isRangeRequest; } public void ApplyResponseHeaders(int statusCode) @@ -278,9 +254,11 @@ public void ApplyResponseHeaders(int statusCode) { _response.ContentType = _contentType; } - _responseHeaders.LastModified = _lastModified; - _responseHeaders.ETag = _etag; - _responseHeaders.Headers[HeaderNames.AcceptRanges] = "bytes"; + + var responseHeaders = ResponseHeaders; + responseHeaders.LastModified = _lastModified; + responseHeaders.ETag = _etag; + responseHeaders.Headers[HeaderNames.AcceptRanges] = "bytes"; } if (statusCode == Constants.Status200Ok) { @@ -294,10 +272,7 @@ public void ApplyResponseHeaders(int statusCode) } public PreconditionState GetPreconditionState() - { - return GetMaxPreconditionState(_ifMatchState, _ifNoneMatchState, - _ifModifiedSinceState, _ifUnmodifiedSinceState); - } + => GetMaxPreconditionState(_ifMatchState, _ifNoneMatchState, _ifModifiedSinceState, _ifUnmodifiedSinceState); private static PreconditionState GetMaxPreconditionState(params PreconditionState[] states) { @@ -320,6 +295,52 @@ public Task SendStatusAsync(int statusCode) return Task.CompletedTask; } + public async Task ServeStaticFile(HttpContext context, RequestDelegate next) + { + ComprehendRequestHeaders(); + switch (GetPreconditionState()) + { + case PreconditionState.Unspecified: + case PreconditionState.ShouldProcess: + if (IsHeadMethod) + { + await SendStatusAsync(Constants.Status200Ok); + return; + } + + try + { + if (IsRangeRequest) + { + await SendRangeAsync(); + return; + } + + await SendAsync(); + _logger.FileServed(SubPath, PhysicalPath); + return; + } + catch (FileNotFoundException) + { + context.Response.Clear(); + } + await next(context); + return; + case PreconditionState.NotModified: + _logger.FileNotModified(SubPath); + await SendStatusAsync(Constants.Status304NotModified); + return; + case PreconditionState.PreconditionFailed: + _logger.PreconditionFailed(SubPath); + await SendStatusAsync(Constants.Status412PreconditionFailed); + return; + default: + var exception = new NotImplementedException(GetPreconditionState().ToString()); + Debug.Fail(exception.ToString()); + throw exception; + } + } + public async Task SendAsync() { SetCompressionMode(); @@ -358,14 +379,14 @@ internal async Task SendRangeAsync() // 14.16 Content-Range - A server sending a response with status code 416 (Requested range not satisfiable) // SHOULD include a Content-Range field with a byte-range-resp-spec of "*". The instance-length specifies // the current length of the selected resource. e.g. */length - _responseHeaders.ContentRange = new ContentRangeHeaderValue(_length); + ResponseHeaders.ContentRange = new ContentRangeHeaderValue(_length); ApplyResponseHeaders(Constants.Status416RangeNotSatisfiable); _logger.RangeNotSatisfiable(SubPath); return; } - _responseHeaders.ContentRange = ComputeContentRange(_range, out var start, out var length); + ResponseHeaders.ContentRange = ComputeContentRange(_range, out var start, out var length); _response.ContentLength = length; SetCompressionMode(); ApplyResponseHeaders(Constants.Status206PartialContent); @@ -416,5 +437,22 @@ private void SetCompressionMode() responseCompressionFeature.Mode = _options.HttpsCompression; } } + + internal enum PreconditionState : byte + { + Unspecified, + NotModified, + ShouldProcess, + PreconditionFailed + } + + [Flags] + private enum RequestType : byte + { + Unspecified = 0b_000, + IsHead = 0b_001, + IsGet = 0b_010, + IsRange = 0b_100, + } } } diff --git a/src/Middleware/StaticFiles/src/StaticFileMiddleware.cs b/src/Middleware/StaticFiles/src/StaticFileMiddleware.cs index 5f1dbb4e0f73..b05c58f6e057 100644 --- a/src/Middleware/StaticFiles/src/StaticFileMiddleware.cs +++ b/src/Middleware/StaticFiles/src/StaticFileMiddleware.cs @@ -2,12 +2,11 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Diagnostics; -using System.IO; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Endpoints; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -70,83 +69,83 @@ public StaticFileMiddleware(RequestDelegate next, IWebHostEnvironment hostingEnv /// public Task Invoke(HttpContext context) { - var fileContext = new StaticFileContext(context, _options, _matchUrl, _logger, _fileProvider, _contentTypeProvider); - - if (!fileContext.ValidateNoEndpoint()) + if (!ValidateNoEndpoint(context)) { _logger.EndpointMatched(); } - else if (!fileContext.ValidateMethod()) + else if (!ValidateMethod(context)) { _logger.RequestMethodNotSupported(context.Request.Method); } - else if (!fileContext.ValidatePath()) - { - _logger.PathMismatch(fileContext.SubPath); - } - else if (!fileContext.LookupContentType()) + else if (!ValidatePath(context, _matchUrl, out var subPath)) { - _logger.FileTypeNotSupported(fileContext.SubPath); + _logger.PathMismatch(subPath); } - else if (!fileContext.LookupFileInfo()) + else if (!LookupContentType(_contentTypeProvider, _options, subPath, out var contentType)) { - _logger.FileNotFound(fileContext.SubPath); + _logger.FileTypeNotSupported(subPath); } else { // If we get here, we can try to serve the file - return ServeStaticFile(context, fileContext); + return TryServeStaticFile(context, contentType, subPath); } return _next(context); } - private async Task ServeStaticFile(HttpContext context, StaticFileContext fileContext) + // Return true because we only want to run if there is no endpoint. + private static bool ValidateNoEndpoint(HttpContext context) => context.GetEndpoint() == null; + + private static bool ValidateMethod(HttpContext context) + { + var method = context.Request.Method; + var isValid = false; + if (HttpMethods.IsGet(method)) + { + isValid = true; + } + else if (HttpMethods.IsHead(method)) + { + isValid = true; + } + + return isValid; + } + + internal static bool ValidatePath(HttpContext context, PathString matchUrl, out PathString subPath) => Helpers.TryMatchPath(context, matchUrl, forDirectory: false, out subPath); + + internal static bool LookupContentType(IContentTypeProvider contentTypeProvider, StaticFileOptions options, PathString subPath, out string contentType) { - fileContext.ComprehendRequestHeaders(); - switch (fileContext.GetPreconditionState()) + if (contentTypeProvider.TryGetContentType(subPath.Value, out contentType)) { - case StaticFileContext.PreconditionState.Unspecified: - case StaticFileContext.PreconditionState.ShouldProcess: - if (fileContext.IsHeadMethod) - { - await fileContext.SendStatusAsync(Constants.Status200Ok); - return; - } - - try - { - if (fileContext.IsRangeRequest) - { - await fileContext.SendRangeAsync(); - return; - } - - await fileContext.SendAsync(); - _logger.FileServed(fileContext.SubPath, fileContext.PhysicalPath); - return; - } - catch (FileNotFoundException) - { - context.Response.Clear(); - } - await _next(context); - return; - case StaticFileContext.PreconditionState.NotModified: - _logger.FileNotModified(fileContext.SubPath); - await fileContext.SendStatusAsync(Constants.Status304NotModified); - return; - - case StaticFileContext.PreconditionState.PreconditionFailed: - _logger.PreconditionFailed(fileContext.SubPath); - await fileContext.SendStatusAsync(Constants.Status412PreconditionFailed); - return; - - default: - var exception = new NotImplementedException(fileContext.GetPreconditionState().ToString()); - Debug.Fail(exception.ToString()); - throw exception; + return true; } + + if (options.ServeUnknownFileTypes) + { + contentType = options.DefaultContentType; + return true; + } + + return false; + } + + private Task TryServeStaticFile(HttpContext context, string contentType, PathString subPath) + { + var fileContext = new StaticFileContext(context, _options, _logger, _fileProvider, contentType, subPath); + + if (!fileContext.LookupFileInfo()) + { + _logger.FileNotFound(fileContext.SubPath); + } + else + { + // If we get here, we can try to serve the file + return fileContext.ServeStaticFile(context, _next); + } + + return _next(context); } } } diff --git a/src/Middleware/StaticFiles/test/UnitTests/StaticFileContextTest.cs b/src/Middleware/StaticFiles/test/UnitTests/StaticFileContextTest.cs index d61609657a26..efea2a08e56d 100644 --- a/src/Middleware/StaticFiles/test/UnitTests/StaticFileContextTest.cs +++ b/src/Middleware/StaticFiles/test/UnitTests/StaticFileContextTest.cs @@ -22,14 +22,18 @@ public void LookupFileInfo_ReturnsFalse_IfFileDoesNotExist() { // Arrange var options = new StaticFileOptions(); - var context = new StaticFileContext(new DefaultHttpContext(), options, PathString.Empty, NullLogger.Instance, new TestFileProvider(), new FileExtensionContentTypeProvider()); + var httpContext = new DefaultHttpContext(); + var pathString = PathString.Empty; + var validateResult = StaticFileMiddleware.ValidatePath(httpContext, pathString, out var subPath); + var contentTypeResult = StaticFileMiddleware.LookupContentType(new FileExtensionContentTypeProvider(), options, subPath, out var contentType); + var context = new StaticFileContext(httpContext, options, NullLogger.Instance, new TestFileProvider(), contentType, subPath); // Act - var validateResult = context.ValidatePath(); var lookupResult = context.LookupFileInfo(); // Assert Assert.True(validateResult); + Assert.False(contentTypeResult); Assert.False(lookupResult); } @@ -46,13 +50,17 @@ public void LookupFileInfo_ReturnsTrue_IfFileExists() var pathString = new PathString("/test"); var httpContext = new DefaultHttpContext(); httpContext.Request.Path = new PathString("/test/foo.txt"); - var context = new StaticFileContext(httpContext, options, pathString, NullLogger.Instance, fileProvider, new FileExtensionContentTypeProvider()); + var validateResult = StaticFileMiddleware.ValidatePath(httpContext, pathString, out var subPath); + var contentTypeResult = StaticFileMiddleware.LookupContentType(new FileExtensionContentTypeProvider(), options, subPath, out var contentType); + + var context = new StaticFileContext(httpContext, options, NullLogger.Instance, fileProvider, contentType, subPath); // Act - context.ValidatePath(); var result = context.LookupFileInfo(); // Assert + Assert.True(validateResult); + Assert.True(contentTypeResult); Assert.True(result); } @@ -70,10 +78,14 @@ public async Task EnablesHttpsCompression_IfMatched() var httpsCompressionFeature = new TestHttpsCompressionFeature(); httpContext.Features.Set(httpsCompressionFeature); httpContext.Request.Path = new PathString("/test/foo.txt"); - var context = new StaticFileContext(httpContext, options, pathString, NullLogger.Instance, fileProvider, new FileExtensionContentTypeProvider()); + var validateResult = StaticFileMiddleware.ValidatePath(httpContext, pathString, out var subPath); + var contentTypeResult = StaticFileMiddleware.LookupContentType(new FileExtensionContentTypeProvider(), options, subPath, out var contentType); + + var context = new StaticFileContext(httpContext, options, NullLogger.Instance, fileProvider, contentType, subPath); - context.ValidatePath(); var result = context.LookupFileInfo(); + Assert.True(validateResult); + Assert.True(contentTypeResult); Assert.True(result); await context.SendAsync(); @@ -95,10 +107,14 @@ public void SkipsHttpsCompression_IfNotMatched() var httpsCompressionFeature = new TestHttpsCompressionFeature(); httpContext.Features.Set(httpsCompressionFeature); httpContext.Request.Path = new PathString("/test/bar.txt"); - var context = new StaticFileContext(httpContext, options, pathString, NullLogger.Instance, fileProvider, new FileExtensionContentTypeProvider()); + var validateResult = StaticFileMiddleware.ValidatePath(httpContext, pathString, out var subPath); + var contentTypeResult = StaticFileMiddleware.LookupContentType(new FileExtensionContentTypeProvider(), options, subPath, out var contentType); + + var context = new StaticFileContext(httpContext, options, NullLogger.Instance, fileProvider, contentType, subPath); - context.ValidatePath(); var result = context.LookupFileInfo(); + Assert.True(validateResult); + Assert.True(contentTypeResult); Assert.False(result); Assert.Equal(HttpsCompressionMode.Default, httpsCompressionFeature.Mode);