File: src\Shared\ResultsHelpers\FileResultHelper.cs
Web Access
Project: src\src\Http\Http.Results\src\Microsoft.AspNetCore.Http.Results.csproj (Microsoft.AspNetCore.Http.Results)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Http.Headers;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
 
namespace Microsoft.AspNetCore.Internal;
 
internal static partial class FileResultHelper
{
    private const string AcceptRangeHeaderValue = "bytes";
 
    internal enum PreconditionState
    {
        Unspecified,
        NotModified,
        ShouldProcess,
        PreconditionFailed
    }
 
    internal static async Task WriteFileAsync(HttpContext context, Stream fileStream, RangeItemHeaderValue? range, long rangeLength)
    {
        const int BufferSize = 64 * 1024;
        var outputStream = context.Response.Body;
        await using (fileStream)
        {
            try
            {
                if (range == null)
                {
                    await StreamCopyOperation.CopyToAsync(fileStream, outputStream, count: null, bufferSize: 64 * 1024, cancel: context.RequestAborted);
                }
                else
                {
                    fileStream.Seek(range.From!.Value, SeekOrigin.Begin);
                    await StreamCopyOperation.CopyToAsync(fileStream, outputStream, rangeLength, BufferSize, context.RequestAborted);
                }
            }
            catch (OperationCanceledException)
            {
                // Don't throw this exception, it's most likely caused by the client disconnecting.
                // However, if it was cancelled for any other reason we need to prevent empty responses.
                context.Abort();
            }
        }
    }
 
    internal static async Task WriteFileAsync(HttpContext context, ReadOnlyMemory<byte> buffer, RangeItemHeaderValue? range, long rangeLength)
    {
        var outputStream = context.Response.Body;
 
        try
        {
            if (range is null)
            {
                await outputStream.WriteAsync(buffer, context.RequestAborted);
            }
            else
            {
                var from = 0;
                var length = 0;
 
                checked
                {
                    // Overflow should throw
                    from = (int)range.From!.Value;
                    length = (int)rangeLength;
                }
 
                await outputStream.WriteAsync(buffer.Slice(from, length), context.RequestAborted);
 
            }
        }
        catch (OperationCanceledException)
        {
            // Don't throw this exception, it's most likely caused by the client disconnecting.
            // However, if it was cancelled for any other reason we need to prevent empty responses.
            context.Abort();
        }
    }
 
    internal static (RangeItemHeaderValue? range, long rangeLength, bool serveBody) SetHeadersAndLog(
        HttpContext httpContext,
        in FileResultInfo result,
        long? fileLength,
        bool enableRangeProcessing,
        DateTimeOffset? lastModified,
        EntityTagHeaderValue? etag,
        ILogger logger)
    {
        var request = httpContext.Request;
        var httpRequestHeaders = request.GetTypedHeaders();
 
        // Since the 'Last-Modified' and other similar http date headers are rounded down to whole seconds,
        // round down current file's last modified to whole seconds for correct comparison.
        if (lastModified.HasValue)
        {
            lastModified = RoundDownToWholeSeconds(lastModified.Value);
        }
 
        var preconditionState = GetPreconditionState(httpRequestHeaders, lastModified, etag, logger);
 
        var response = httpContext.Response;
        SetLastModifiedAndEtagHeaders(response, lastModified, etag);
 
        // Short circuit if the preconditional headers process to 304 (NotModified) or 412 (PreconditionFailed)
        if (preconditionState == PreconditionState.NotModified)
        {
            response.StatusCode = StatusCodes.Status304NotModified;
            return (range: null, rangeLength: 0, serveBody: false);
        }
        else if (preconditionState == PreconditionState.PreconditionFailed)
        {
            response.StatusCode = StatusCodes.Status412PreconditionFailed;
            return (range: null, rangeLength: 0, serveBody: false);
        }
 
        response.ContentType = result.ContentType;
        SetContentDispositionHeader(httpContext, in result);
 
        if (fileLength.HasValue)
        {
            // Assuming the request is not a range request, and the response body is not empty, the Content-Length header is set to
            // the length of the entire file.
            // If the request is a valid range request, this header is overwritten with the length of the range as part of the
            // range processing (see method SetContentLength).
 
            response.ContentLength = fileLength.Value;
 
            // Handle range request
            if (enableRangeProcessing)
            {
                SetAcceptRangeHeader(response);
 
                // If the request method is HEAD or GET, PreconditionState is Unspecified or ShouldProcess, and IfRange header is valid,
                // range should be processed and Range headers should be set
                if ((HttpMethods.IsHead(request.Method) || HttpMethods.IsGet(request.Method))
                    && (preconditionState == PreconditionState.Unspecified || preconditionState == PreconditionState.ShouldProcess)
                    && (IfRangeValid(httpRequestHeaders, lastModified, etag, logger)))
                {
                    return SetRangeHeaders(httpContext, httpRequestHeaders, fileLength.Value, logger);
                }
            }
            else
            {
                Log.NotEnabledForRangeProcessing(logger);
            }
        }
 
        return (range: null, rangeLength: 0, serveBody: !HttpMethods.IsHead(request.Method));
    }
 
    internal static bool IfRangeValid(
        RequestHeaders httpRequestHeaders,
        DateTimeOffset? lastModified,
        EntityTagHeaderValue? etag,
        ILogger logger)
    {
        // 14.27 If-Range
        var ifRange = httpRequestHeaders.IfRange;
        if (ifRange != 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 (ifRange.LastModified.HasValue)
            {
                if (lastModified.HasValue && lastModified > ifRange.LastModified)
                {
                    Log.IfRangeLastModifiedPreconditionFailed(logger, lastModified, ifRange.LastModified);
                    return false;
                }
            }
            else if (etag != null && ifRange.EntityTag != null && !ifRange.EntityTag.Compare(etag, useStrongComparison: true))
            {
                Log.IfRangeETagPreconditionFailed(logger, etag, ifRange.EntityTag);
                return false;
            }
        }
 
        return true;
    }
 
    internal static PreconditionState GetPreconditionState(
        RequestHeaders httpRequestHeaders,
        DateTimeOffset? lastModified,
        EntityTagHeaderValue? etag,
        ILogger logger)
    {
        var ifMatchState = PreconditionState.Unspecified;
        var ifNoneMatchState = PreconditionState.Unspecified;
        var ifModifiedSinceState = PreconditionState.Unspecified;
        var ifUnmodifiedSinceState = PreconditionState.Unspecified;
 
        // 14.24 If-Match
        var ifMatch = httpRequestHeaders.IfMatch;
        if (etag != null)
        {
            ifMatchState = GetEtagMatchState(
                useStrongComparison: true,
                etagHeader: ifMatch,
                etag: etag,
                matchFoundState: PreconditionState.ShouldProcess,
                matchNotFoundState: PreconditionState.PreconditionFailed);
 
            if (ifMatchState == PreconditionState.PreconditionFailed)
            {
                Log.IfMatchPreconditionFailed(logger, etag);
            }
        }
 
        // 14.26 If-None-Match
        var ifNoneMatch = httpRequestHeaders.IfNoneMatch;
        if (etag != null)
        {
            ifNoneMatchState = GetEtagMatchState(
                useStrongComparison: false,
                etagHeader: ifNoneMatch,
                etag: etag,
                matchFoundState: PreconditionState.NotModified,
                matchNotFoundState: PreconditionState.ShouldProcess);
        }
 
        var now = RoundDownToWholeSeconds(DateTimeOffset.UtcNow);
 
        // 14.25 If-Modified-Since
        var ifModifiedSince = httpRequestHeaders.IfModifiedSince;
        if (lastModified.HasValue && ifModifiedSince.HasValue && ifModifiedSince <= now)
        {
            var modified = ifModifiedSince < lastModified;
            ifModifiedSinceState = modified ? PreconditionState.ShouldProcess : PreconditionState.NotModified;
        }
 
        // 14.28 If-Unmodified-Since
        var ifUnmodifiedSince = httpRequestHeaders.IfUnmodifiedSince;
        if (lastModified.HasValue && ifUnmodifiedSince.HasValue && ifUnmodifiedSince <= now)
        {
            var unmodified = ifUnmodifiedSince >= lastModified;
            ifUnmodifiedSinceState = unmodified ? PreconditionState.ShouldProcess : PreconditionState.PreconditionFailed;
 
            if (ifUnmodifiedSinceState == PreconditionState.PreconditionFailed)
            {
                Log.IfUnmodifiedSincePreconditionFailed(logger, lastModified, ifUnmodifiedSince);
            }
        }
 
        var state = GetMaxPreconditionState(ifMatchState, ifNoneMatchState, ifModifiedSinceState, ifUnmodifiedSinceState);
        return state;
    }
 
    private static PreconditionState GetEtagMatchState(
        bool useStrongComparison,
        IList<EntityTagHeaderValue> etagHeader,
        EntityTagHeaderValue etag,
        PreconditionState matchFoundState,
        PreconditionState matchNotFoundState)
    {
        if (etagHeader?.Count > 0)
        {
            var state = matchNotFoundState;
            foreach (var entityTag in etagHeader)
            {
                if (entityTag.Equals(EntityTagHeaderValue.Any) || entityTag.Compare(etag, useStrongComparison))
                {
                    state = matchFoundState;
                    break;
                }
            }
 
            return state;
        }
 
        return PreconditionState.Unspecified;
    }
 
    private static (RangeItemHeaderValue? range, long rangeLength, bool serveBody) SetRangeHeaders(
        HttpContext httpContext,
        RequestHeaders httpRequestHeaders,
        long fileLength,
        ILogger logger)
    {
        var response = httpContext.Response;
        var httpResponseHeaders = response.GetTypedHeaders();
        var serveBody = !HttpMethods.IsHead(httpContext.Request.Method);
 
        // Range may be null for empty range header, invalid ranges, parsing errors, multiple ranges
        // and when the file length is zero.
        var (isRangeRequest, range) = RangeHelper.ParseRange(
            httpContext,
            httpRequestHeaders,
            fileLength,
            logger);
 
        if (!isRangeRequest)
        {
            return (range: null, rangeLength: 0, serveBody);
        }
 
        // Requested range is not satisfiable
        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
            response.StatusCode = StatusCodes.Status416RangeNotSatisfiable;
            httpResponseHeaders.ContentRange = new ContentRangeHeaderValue(fileLength);
            response.ContentLength = 0;
 
            return (range: null, rangeLength: 0, serveBody: false);
        }
 
        response.StatusCode = StatusCodes.Status206PartialContent;
        httpResponseHeaders.ContentRange = new ContentRangeHeaderValue(
            range.From!.Value,
            range.To!.Value,
            fileLength);
 
        // Overwrite the Content-Length header for valid range requests with the range length.
        var rangeLength = SetContentLength(response, range);
 
        return (range, rangeLength, serveBody);
    }
 
    private static long SetContentLength(HttpResponse response, RangeItemHeaderValue range)
    {
        var start = range.From!.Value;
        var end = range.To!.Value;
        var length = end - start + 1;
        response.ContentLength = length;
        return length;
    }
 
    private static void SetContentDispositionHeader(HttpContext httpContext, in FileResultInfo result)
    {
        if (!string.IsNullOrEmpty(result.FileDownloadName))
        {
            // From RFC 2183, Sec. 2.3:
            // The sender may want to suggest a filename to be used if the entity is
            // detached and stored in a separate file. If the receiving MUA writes
            // the entity to a file, the suggested filename should be used as a
            // basis for the actual filename, where possible.
            var contentDisposition = new ContentDispositionHeaderValue("attachment");
            contentDisposition.SetHttpFileName(result.FileDownloadName);
            httpContext.Response.Headers.ContentDisposition = contentDisposition.ToString();
        }
    }
 
    private static void SetLastModifiedAndEtagHeaders(HttpResponse response, DateTimeOffset? lastModified, EntityTagHeaderValue? etag)
    {
        var httpResponseHeaders = response.GetTypedHeaders();
        if (lastModified.HasValue)
        {
            httpResponseHeaders.LastModified = lastModified;
        }
        if (etag != null)
        {
            httpResponseHeaders.ETag = etag;
        }
    }
 
    private static void SetAcceptRangeHeader(HttpResponse response)
    {
        response.Headers.AcceptRanges = AcceptRangeHeaderValue;
    }
 
    private static PreconditionState GetMaxPreconditionState(params PreconditionState[] states)
    {
        var max = PreconditionState.Unspecified;
        for (var i = 0; i < states.Length; i++)
        {
            if (states[i] > max)
            {
                max = states[i];
            }
        }
 
        return max;
    }
 
    private static DateTimeOffset RoundDownToWholeSeconds(DateTimeOffset dateTimeOffset)
    {
        var ticksToRemove = dateTimeOffset.Ticks % TimeSpan.TicksPerSecond;
        return dateTimeOffset.Subtract(TimeSpan.FromTicks(ticksToRemove));
    }
 
    internal static partial class Log
    {
        [LoggerMessage(17, LogLevel.Debug, "Writing the requested range of bytes to the body.", EventName = "WritingRangeToBody")]
        public static partial void WritingRangeToBody(ILogger logger);
 
        [LoggerMessage(34, LogLevel.Debug,
            "Current request's If-Match header check failed as the file's current etag '{CurrentETag}' does not match with any of the supplied etags.",
            EventName = "IfMatchPreconditionFailed")]
        public static partial void IfMatchPreconditionFailed(ILogger logger, EntityTagHeaderValue currentETag);
 
        [LoggerMessage(35, LogLevel.Debug,
            "Current request's If-Unmodified-Since header check failed as the file was modified (at '{lastModified}') after the If-Unmodified-Since date '{IfUnmodifiedSinceDate}'.",
            EventName = "IfUnmodifiedSincePreconditionFailed")]
        public static partial void IfUnmodifiedSincePreconditionFailed(
            ILogger logger,
            DateTimeOffset? lastModified,
            DateTimeOffset? ifUnmodifiedSinceDate);
 
        [LoggerMessage(36, LogLevel.Debug,
            "Could not serve range as the file was modified (at {LastModified}) after the if-Range's last modified date '{IfRangeLastModified}'.",
            EventName = "IfRangeLastModifiedPreconditionFailed")]
        public static partial void IfRangeLastModifiedPreconditionFailed(
            ILogger logger,
            DateTimeOffset? lastModified,
            DateTimeOffset? IfRangeLastModified);
 
        [LoggerMessage(37, LogLevel.Debug,
            "Could not serve range as the file's current etag '{CurrentETag}' does not match the If-Range etag '{IfRangeETag}'.",
            EventName = "IfRangeETagPreconditionFailed")]
        public static partial void IfRangeETagPreconditionFailed(
            ILogger logger,
            EntityTagHeaderValue currentETag,
            EntityTagHeaderValue IfRangeETag);
 
        [LoggerMessage(38, LogLevel.Debug,
            "The file result has not been enabled for processing range requests. To enable it, set the EnableRangeProcessing property on the result to 'true'.",
            EventName = "NotEnabledForRangeProcessing")]
        public static partial void NotEnabledForRangeProcessing(ILogger logger);
    }
}