File: StaticFileContext.cs
Web Access
Project: src\src\Middleware\StaticFiles\src\Microsoft.AspNetCore.StaticFiles.csproj (Microsoft.AspNetCore.StaticFiles)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Http.Headers;
using Microsoft.AspNetCore.Internal;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
 
namespace Microsoft.AspNetCore.StaticFiles;
 
internal struct StaticFileContext
{
    private readonly HttpContext _context;
    private readonly StaticFileOptions _options;
    private readonly HttpRequest _request;
    private readonly HttpResponse _response;
    private readonly ILogger _logger;
    private readonly IFileProvider _fileProvider;
    private readonly string _method;
    private readonly string? _contentType;
 
    private IFileInfo _fileInfo;
    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 RequestType _requestType;
 
    public StaticFileContext(HttpContext context, StaticFileOptions options, ILogger logger, IFileProvider fileProvider, string? contentType, PathString subPath)
    {
        if (subPath.Value == null)
        {
            throw new ArgumentException($"{nameof(subPath)} cannot wrap a null value.", nameof(subPath));
        }
 
        _context = context;
        _options = options;
        _request = context.Request;
        _response = context.Response;
        _logger = logger;
        _fileProvider = fileProvider;
        _method = _request.Method;
        _contentType = contentType;
        _fileInfo = default!;
        _etag = null;
        _requestHeaders = null;
        _responseHeaders = null;
        _range = null;
 
        _length = 0;
        _subPath = subPath;
        _lastModified = new DateTimeOffset();
        _ifMatchState = PreconditionState.Unspecified;
        _ifNoneMatchState = PreconditionState.Unspecified;
        _ifModifiedSinceState = PreconditionState.Unspecified;
        _ifUnmodifiedSinceState = PreconditionState.Unspecified;
 
        if (HttpMethods.IsGet(_method))
        {
            _requestType = RequestType.IsGet;
        }
        else if (HttpMethods.IsHead(_method))
        {
            _requestType = RequestType.IsHead;
        }
        else
        {
            _requestType = RequestType.Unspecified;
        }
    }
 
    private RequestHeaders RequestHeaders => _requestHeaders ??= _request.GetTypedHeaders();
 
    private ResponseHeaders ResponseHeaders => _responseHeaders ??= _response.GetTypedHeaders();
 
    public bool IsHeadMethod => _requestType.HasFlag(RequestType.IsHead);
 
    public bool IsGetMethod => _requestType.HasFlag(RequestType.IsGet);
 
    public bool IsRangeRequest
    {
        get => _requestType.HasFlag(RequestType.IsRange);
        private set
        {
            if (value)
            {
                _requestType |= RequestType.IsRange;
            }
            else
            {
                _requestType &= ~RequestType.IsRange;
            }
        }
    }
 
    public string SubPath => _subPath.Value!;
 
    public string PhysicalPath => _fileInfo.PhysicalPath ?? string.Empty;
 
    public bool LookupFileInfo()
    {
        _fileInfo = _fileProvider.GetFileInfo(SubPath);
        if (_fileInfo.Exists)
        {
            _length = _fileInfo.Length;
 
            DateTimeOffset last = _fileInfo.LastModified;
            // Truncate to the second.
            _lastModified = new DateTimeOffset(last.Year, last.Month, last.Day, last.Hour, last.Minute, last.Second, last.Offset).ToUniversalTime();
 
            long etagHash = _lastModified.ToFileTime() ^ _length;
            _etag = new EntityTagHeaderValue('\"' + Convert.ToString(etagHash, 16) + '\"');
        }
        return _fileInfo.Exists;
    }
 
    public void ComprehendRequestHeaders()
    {
        ComputeIfMatch();
 
        ComputeIfModifiedSince();
 
        ComputeRange();
 
        ComputeIfRange();
    }
 
    private void ComputeIfMatch()
    {
        var requestHeaders = RequestHeaders;
 
        // 14.24 If-Match
        var ifMatch = requestHeaders.IfMatch;
        if (ifMatch?.Count > 0)
        {
            _ifMatchState = PreconditionState.PreconditionFailed;
            foreach (var etag in ifMatch)
            {
                if (etag.Equals(EntityTagHeaderValue.Any) || etag.Compare(_etag, useStrongComparison: true))
                {
                    _ifMatchState = PreconditionState.ShouldProcess;
                    break;
                }
            }
        }
 
        // 14.26 If-None-Match
        var ifNoneMatch = requestHeaders.IfNoneMatch;
        if (ifNoneMatch?.Count > 0)
        {
            _ifNoneMatchState = PreconditionState.ShouldProcess;
            foreach (var etag in ifNoneMatch)
            {
                if (etag.Equals(EntityTagHeaderValue.Any) || etag.Compare(_etag, useStrongComparison: true))
                {
                    _ifNoneMatchState = PreconditionState.NotModified;
                    break;
                }
            }
        }
    }
 
    private void ComputeIfModifiedSince()
    {
        var requestHeaders = RequestHeaders;
        var now = DateTimeOffset.UtcNow;
 
        // 14.25 If-Modified-Since
        var ifModifiedSince = requestHeaders.IfModifiedSince;
        if (ifModifiedSince.HasValue && ifModifiedSince <= now)
        {
            bool modified = ifModifiedSince < _lastModified;
            _ifModifiedSinceState = modified ? PreconditionState.ShouldProcess : PreconditionState.NotModified;
        }
 
        // 14.28 If-Unmodified-Since
        var ifUnmodifiedSince = requestHeaders.IfUnmodifiedSince;
        if (ifUnmodifiedSince.HasValue && ifUnmodifiedSince <= now)
        {
            bool unmodified = ifUnmodifiedSince >= _lastModified;
            _ifUnmodifiedSinceState = unmodified ? PreconditionState.ShouldProcess : PreconditionState.PreconditionFailed;
        }
    }
 
    private void ComputeIfRange()
    {
        // 14.27 If-Range
        var ifRangeHeader = RequestHeaders.IfRange;
        if (ifRangeHeader != null)
        {
            // If the validator given in the If-Range header field matches the
            // current validator for the selected representation of the target
            // resource, then the server SHOULD process the Range header field as
            // requested.  If the validator does not match, the server MUST ignore
            // the Range header field.
            if (ifRangeHeader.LastModified.HasValue)
            {
                if (_lastModified > ifRangeHeader.LastModified)
                {
                    IsRangeRequest = false;
                }
            }
            else if (_etag != null && ifRangeHeader.EntityTag != null && !ifRangeHeader.EntityTag.Compare(_etag, useStrongComparison: true))
            {
                IsRangeRequest = false;
            }
        }
    }
 
    private void ComputeRange()
    {
        // 14.35 Range
        // http://tools.ietf.org/html/draft-ietf-httpbis-p5-range-24
 
        // A server MUST ignore a Range header field received with a request method other
        // than GET.
        if (!IsGetMethod)
        {
            return;
        }
 
        (var isRangeRequest, var range) = RangeHelper.ParseRange(_context, RequestHeaders, _length, _logger);
 
        _range = range;
        IsRangeRequest = isRangeRequest;
    }
 
    public Task ApplyResponseHeadersAsync(int statusCode)
    {
        // Only clobber the default status (e.g. in cases this a status code pages retry)
        if (_response.StatusCode == StatusCodes.Status200OK)
        {
            _response.StatusCode = statusCode;
        }
        if (statusCode < 400)
        {
            // these headers are returned for 200, 206, and 304
            // they are not returned for 412 and 416
            if (!string.IsNullOrEmpty(_contentType))
            {
                _response.ContentType = _contentType;
            }
 
            var responseHeaders = ResponseHeaders;
            responseHeaders.LastModified = _lastModified;
            responseHeaders.ETag = _etag;
            responseHeaders.Headers.AcceptRanges = "bytes";
        }
        if (statusCode == StatusCodes.Status200OK)
        {
            // this header is only returned here for 200
            // it already set to the returned range for 206
            // it is not returned for 304, 412, and 416
            _response.ContentLength = _length;
        }
 
        if (_options.OnPrepareResponse != StaticFileOptions._defaultOnPrepareResponse || _options.OnPrepareResponseAsync != StaticFileOptions._defaultOnPrepareResponseAsync)
        {
            var context = new StaticFileResponseContext(_context, _fileInfo);
            _options.OnPrepareResponse(context);
            return _options.OnPrepareResponseAsync(context);
        }
        return Task.CompletedTask;
    }
 
    public PreconditionState GetPreconditionState()
        => GetMaxPreconditionState(_ifMatchState, _ifNoneMatchState, _ifModifiedSinceState, _ifUnmodifiedSinceState);
 
    private static PreconditionState GetMaxPreconditionState(params PreconditionState[] states)
    {
        PreconditionState max = PreconditionState.Unspecified;
        for (int i = 0; i < states.Length; i++)
        {
            if (states[i] > max)
            {
                max = states[i];
            }
        }
        return max;
    }
 
    public Task SendStatusAsync(int statusCode)
    {
        _logger.Handled(statusCode, SubPath);
 
        return ApplyResponseHeadersAsync(statusCode);
    }
 
    public async Task ServeStaticFile(HttpContext context, RequestDelegate next)
    {
        ComprehendRequestHeaders();
        switch (GetPreconditionState())
        {
            case PreconditionState.Unspecified:
            case PreconditionState.ShouldProcess:
                if (IsHeadMethod)
                {
                    await SendStatusAsync(StatusCodes.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(StatusCodes.Status304NotModified);
                return;
            case PreconditionState.PreconditionFailed:
                _logger.PreconditionFailed(SubPath);
                await SendStatusAsync(StatusCodes.Status412PreconditionFailed);
                return;
            default:
                var exception = new NotImplementedException(GetPreconditionState().ToString());
                Debug.Fail(exception.ToString());
                throw exception;
        }
    }
 
    public async Task SendAsync()
    {
        SetCompressionMode();
        await ApplyResponseHeadersAsync(StatusCodes.Status200OK);
        try
        {
            await _context.Response.SendFileAsync(_fileInfo, 0, _length, _context.RequestAborted);
        }
        catch (OperationCanceledException ex)
        {
            // Don't throw this exception, it's most likely caused by the client disconnecting.
            _logger.WriteCancelled(ex);
        }
    }
 
    // When there is only a single range the bytes are sent directly in the body.
    internal async Task SendRangeAsync()
    {
        if (_range == null)
        {
            // 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);
            await ApplyResponseHeadersAsync(StatusCodes.Status416RangeNotSatisfiable);
 
            _logger.RangeNotSatisfiable(SubPath);
            return;
        }
 
        ResponseHeaders.ContentRange = ComputeContentRange(_range, out var start, out var length);
        _response.ContentLength = length;
        SetCompressionMode();
        await ApplyResponseHeadersAsync(StatusCodes.Status206PartialContent);
 
        try
        {
            var logPath = !string.IsNullOrEmpty(_fileInfo.PhysicalPath) ? _fileInfo.PhysicalPath : SubPath;
            _logger.SendingFileRange(_response.Headers.ContentRange, logPath);
            await _context.Response.SendFileAsync(_fileInfo, start, length, _context.RequestAborted);
        }
        catch (OperationCanceledException ex)
        {
            // Don't throw this exception, it's most likely caused by the client disconnecting.
            _logger.WriteCancelled(ex);
        }
    }
 
    // Note: This assumes ranges have been normalized to absolute byte offsets.
    private ContentRangeHeaderValue ComputeContentRange(RangeItemHeaderValue range, out long start, out long length)
    {
        start = range.From!.Value;
        var end = range.To!.Value;
        length = end - start + 1;
        return new ContentRangeHeaderValue(start, end, _length);
    }
 
    // Only called when we expect to serve the body.
    private void SetCompressionMode()
    {
        var responseCompressionFeature = _context.Features.Get<IHttpsCompressionFeature>();
        if (responseCompressionFeature != null)
        {
            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,
    }
}