public async Task ServeResource(HttpResponse response) { if (response == null) { return; } // Read all the file properties needed --------------------------------------------------- // the file-name and last modified date // and the content-type (mime mapping) if (!File.Exists(filePath)) { Log.Error("FileInfo doesn't exist at URI : {0}", filePath); response.StatusCode = (int)HttpStatusCode.NotFound; return; } long length = new FileInfo(filePath).Length; string fileName = StringUtils.RemoveNonAsciiCharactersFast(Path.GetFileName(filePath)); DateTime lastModifiedDateTime = File.GetLastWriteTimeUtc(filePath); if (string.IsNullOrEmpty(fileName) || lastModifiedDateTime == null) { response.StatusCode = (int)HttpStatusCode.InternalServerError; return; } // convert the datetime to date time offset DateTimeOffset lastModifiedDateTimeOffset = DateTime.SpecifyKind(lastModifiedDateTime, DateTimeKind.Utc); // 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. lastModifiedDateTimeOffset = RoundDownToWholeSeconds(lastModifiedDateTimeOffset); // compare date times using milliseconds since UNIX epoch (January 1, 1970 00:00:00 UTC) long lastModified = lastModifiedDateTimeOffset.ToUnixTimeMilliseconds(); // read in stored Content-Type string contentType = ContentType; // Validate request headers for caching --------------------------------------------------- // If-None-Match header should contain "*" or ETag. If so, then return 304. string ifNoneMatch = response.HttpContext.Request.Headers[HeaderNames.IfNoneMatch]; if (ifNoneMatch != null && HttpUtils.Matches(ifNoneMatch, fileName)) { response.Headers.Add(HeaderNames.ETag, fileName); // Required in 304. response.StatusCode = (int)HttpStatusCode.NotModified; return; } // If-Modified-Since header should be greater than LastModified. If so, then return 304. // This header is ignored if any If-None-Match header is specified. long ifModifiedSince = GetDateHeader(response, HeaderNames.IfModifiedSince); if (ifNoneMatch == null && ifModifiedSince != -1 && ifModifiedSince + 1000 > lastModified) { response.Headers.Add(HeaderNames.ETag, fileName); // Required in 304. response.StatusCode = (int)HttpStatusCode.NotModified; return; } // Validate request headers for resume ---------------------------------------------------- // If-Match header should contain "*" or ETag. If not, then return 412. string ifMatch = response.HttpContext.Request.Headers[HeaderNames.IfMatch]; if (ifMatch != null && !HttpUtils.Matches(ifMatch, fileName)) { response.StatusCode = (int)HttpStatusCode.PreconditionFailed; return; } // If-Unmodified-Since header should be greater than LastModified. If not, then return 412. long ifUnmodifiedSince = GetDateHeader(response, HeaderNames.IfUnmodifiedSince); if (ifUnmodifiedSince != -1 && ifUnmodifiedSince + 1000 <= lastModified) { response.StatusCode = (int)HttpStatusCode.PreconditionFailed; return; } // Validate and process range ------------------------------------------------------------- // Prepare some variables. The full Range represents the complete file. Range full = new Range(0, length - 1, length); List <Range> ranges = new List <Range>(); // Validate and process Range and If-Range headers. string range = response.HttpContext.Request.Headers["Range"]; if (range != null) { // Range header should match format "bytes=n-n,n-n,n-n...". If not, then return 416. if (!RangeRegex.IsMatch(range)) { response.Headers.Add(HeaderNames.ContentRange, $"bytes */{length}"); // Required in 416. response.StatusCode = (int)HttpStatusCode.RequestedRangeNotSatisfiable; return; } string ifRange = response.HttpContext.Request.Headers[HeaderNames.IfRange]; if (ifRange != null && !ifRange.Equals(fileName)) { long ifRangeTime = GetDateHeader(response, HeaderNames.IfRange); if (ifRangeTime != -1) { ranges.Add(full); } } // If any valid If-Range header, then process each part of byte range. if (ranges.Count == 0) { // Remove "Ranges" and break up the ranges string[] rangeArray = range.Replace("bytes=", string.Empty) .Split(",".ToCharArray()); foreach (string part in rangeArray) { // Assuming a file with length of 100, the following examples returns bytes at: // 50-80 (50 to 80), 40- (40 to length=100), -20 (length-20=80 to length=100). long start = Range.SubstringLong(part, 0, part.IndexOf("-")); long end = Range.SubstringLong(part, part.IndexOf("-") + 1, part.Length); if (start == -1) { start = length - end; end = length - 1; } else if (end == -1 || end > length - 1) { end = length - 1; } // Check if Range is syntactically valid. If not, then return 416. if (start > end) { // check https://github.com/dotnet/corefx/blob/master/src/System.Net.Http/src/System/Net/Http/Headers/ContentRangeHeaderValue.cs // 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.Headers.Add(HeaderNames.ContentRange, $"bytes */{length}"); // Required in 416. response.StatusCode = (int)HttpStatusCode.RequestedRangeNotSatisfiable; return; } // Add range. ranges.Add(new Range(start, end, length)); } } } // Prepare and Initialize response -------------------------------------------------------- // disable response buffering var bufferingFeature = response.HttpContext.Features.Get <IHttpResponseBodyFeature>(); bufferingFeature?.DisableBuffering(); // Get content type by file name and Set content disposition. string disposition = "inline"; // If content type is unknown, then Set the default value. // For all content types, see: http://www.w3schools.com/media/media_mimeref.asp // To add new content types, add new mime-mapping entry in web.xml. if (contentType == null) { contentType = "application/octet-stream"; } else if (!contentType.StartsWith("image")) { // Else, expect for images, determine content disposition. If content type is supported by // the browser, then Set to inline, else attachment which will pop a 'save as' dialogue. string accept = response.HttpContext.Request.Headers[HeaderNames.Accept]; disposition = accept != null && HttpUtils.Accepts(accept, contentType) ? "inline" : "attachment"; } // Initialize response. try { response.Headers.Add(HeaderNames.ContentType, contentType); response.Headers.Add(HeaderNames.ContentDisposition, disposition + $";filename=\"{fileName}\""); Log.Debug($"{HeaderNames.ContentType} : {contentType}"); Log.Debug($"{HeaderNames.ContentDisposition} : {disposition}"); response.Headers.Add(HeaderNames.AcceptRanges, "bytes"); // Check SetLastModifiedAndEtagHeaders() in FileResultExecutorBase.cs for info about adding headers response.Headers.Add(HeaderNames.ETag, fileName); SetDateHeader(response, HeaderNames.LastModified, lastModifiedDateTimeOffset); // set expiration header (remove milliseconds) var expires = DateTimeOffset .UtcNow .AddSeconds(DEFAULT_EXPIRE_TIME_SECONDS); SetDateHeader(response, HeaderNames.Expires, expires); } catch (System.Exception e) { Log.Error("Failed adding response headers: {0}", e.Message); } // Send requested file (part(s)) to client ------------------------------------------------ // Prepare streams. Stream input = FileStream; Stream output = response.Body; if (ranges.Count == 0 || ranges[0] == full) { // Return full file. Log.Information("Return full file : from ({0}) to ({1}) of ({2})", full.Start, full.End, full.Total); response.ContentType = contentType; response.Headers.Add(HeaderNames.ContentRange, $"bytes {full.Start}-{full.End}/{full.Total}"); response.Headers.Add(HeaderNames.ContentLength, full.Length.ToString()); await Range.CopyStream(input, output, length, full.Start, full.Length); } else if (ranges.Count == 1) { // Return single part of file. Range r = ranges[0]; Log.Information("Return 1 part of file : from ({0}) to ({1}) of ({2})", r.Start, r.End, r.Total); response.ContentType = contentType; response.Headers.Add(HeaderNames.ContentRange, $"bytes {r.Start}-{r.End}/{r.Total}"); response.Headers.Add(HeaderNames.ContentLength, r.Length.ToString()); response.StatusCode = (int)HttpStatusCode.PartialContent; // 206 // Copy single part range. await Range.CopyStream(input, output, length, r.Start, r.Length); } else { // Return multiple parts of file. // check https://stackoverflow.com/questions/38069730/how-to-create-a-multipart-http-response-with-asp-net-core // and https://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html response.ContentType = $"multipart/byteranges; boundary={MULTIPART_BOUNDARY}"; response.StatusCode = (int)HttpStatusCode.PartialContent; // 206 // Copy multi part range. foreach (Range r in ranges) { Log.Information("Return multi part of file : from ({0}) to ({1}) of ({2})", r.Start, r.End, r.Total); // Add multipart boundary and header fields for every range. await response.WriteAsync(CrLf); await response.WriteAsync($"--{MULTIPART_BOUNDARY}"); await response.WriteAsync(CrLf); await response.WriteAsync($"{HeaderNames.ContentType}: {contentType}"); await response.WriteAsync(CrLf); await response.WriteAsync($"{HeaderNames.ContentRange}: bytes {r.Start}-{r.End}/{r.Total}"); await response.WriteAsync(CrLf); // Copy single part range of multi part range. await Range.CopyStream(input, output, length, r.Start, r.Length); } // End with multipart boundary. await response.WriteAsync(CrLf); await response.WriteAsync($"--{MULTIPART_BOUNDARY}"); await response.WriteAsync(CrLf); } }