diff --git a/src/Presentation/SmartStore.Web.Framework/Modelling/Results/CachedFileResult.cs b/src/Presentation/SmartStore.Web.Framework/Modelling/Results/CachedFileResult.cs index 517c7d7b93..0967a4d685 100644 --- a/src/Presentation/SmartStore.Web.Framework/Modelling/Results/CachedFileResult.cs +++ b/src/Presentation/SmartStore.Web.Framework/Modelling/Results/CachedFileResult.cs @@ -1,8 +1,6 @@ using System; -using System.Diagnostics; using System.Globalization; using System.IO; -using System.Net; using System.Web; using System.Web.Hosting; using System.Web.Mvc; @@ -11,31 +9,8 @@ namespace SmartStore.Web.Framework.Modelling { - public class CachedFileResult : ActionResult + public class CachedFileResult : ActionResult, IFileResponse { - internal struct ByteRange - { - internal long Offset; - internal long Length; - } - - private const string RANGE_BOUNDARY = ""; - private const string MULTIPART_CONTENT_TYPE = "multipart/byteranges; boundary="; - private const string MULTIPART_RANGE_DELIMITER = "--\r\n"; - private const string MULTIPART_RANGE_END = "----\r\n\r\n"; - private const string CONTENT_RANGE_FORMAT = "bytes {0}-{1}/{2}"; - private const int MAX_RANGE_ALLOWED = 5; - - private const int ERROR_ACCESS_DENIED = 5; - - // Default buffer size as defined in BufferedStream type - const int DefaultWriteBufferSize = 81920; - - private static readonly string[] _httpDateFormats = new string[] { "r", "dddd, dd-MMM-yy HH':'mm':'ss 'GMT'", "ddd MMM d HH':'mm':'ss yyyy" }; - - private readonly Func _streamReader; - private readonly Func _bufferReader; - #region Ctor public CachedFileResult(string path, string contentType = null) @@ -52,8 +27,7 @@ public CachedFileResult(FileInfo file, string contentType = null) throw new FileNotFoundException(file.FullName); } - _streamReader = file.OpenRead; - + Transmitter = new FileStreamTransmitter(file.OpenRead); ContentType = contentType.NullEmpty() ?? MimeTypes.MapNameToMimeType(file.Name); FileLength = file.Length; LastModifiedUtc = file.LastWriteTimeUtc; @@ -68,8 +42,7 @@ public CachedFileResult(IFile file, string contentType = null) throw new FileNotFoundException(file.Path); } - _streamReader = file.OpenRead; - + Transmitter = new FileStreamTransmitter(file.OpenRead); ContentType = contentType.NullEmpty() ?? MimeTypes.MapNameToMimeType(file.Name); FileLength = file.Size; LastModifiedUtc = file.LastUpdated; @@ -96,7 +69,7 @@ public CachedFileResult(VirtualFile file, DateTime? lastModifiedUtc = null, stri throw new ArgumentNullException(nameof(lastModifiedUtc), "A modification date must be provided if the VirtualFile cannot be mapped to a physical path."); } - if (FileLength == 0) + if (FileLength == null) { ContentType = contentType.NullEmpty() ?? MimeTypes.MapNameToMimeType(file.Name); using (var stream = file.Open()) @@ -106,32 +79,30 @@ public CachedFileResult(VirtualFile file, DateTime? lastModifiedUtc = null, stri } } + Transmitter = new FileStreamTransmitter(file.Open); LastModifiedUtc = lastModifiedUtc.Value; - _streamReader = file.Open; } - public CachedFileResult(string contentType, DateTime lastModifiedUtc, long fileLength, Func reader) + public CachedFileResult(string contentType, DateTime lastModifiedUtc, Func factory, long? fileLength = null) { - Guard.NotNull(reader, nameof(reader)); + Guard.NotNull(factory, nameof(factory)); Guard.NotEmpty(contentType, nameof(contentType)); - Guard.IsPositive(fileLength, nameof(fileLength)); + Transmitter = new FileStreamTransmitter(factory); ContentType = contentType; FileLength = fileLength; LastModifiedUtc = lastModifiedUtc; - _streamReader = reader; } - public CachedFileResult(string contentType, DateTime lastModifiedUtc, long fileLength, Func reader) + public CachedFileResult(string contentType, DateTime lastModifiedUtc, Func factory, long? fileLength = null) { - Guard.NotNull(reader, nameof(reader)); + Guard.NotNull(factory, nameof(factory)); Guard.NotEmpty(contentType, nameof(contentType)); - Guard.IsPositive(fileLength, nameof(fileLength)); + Transmitter = new FileBufferTransmitter(factory); ContentType = contentType; FileLength = fileLength; LastModifiedUtc = lastModifiedUtc; - _bufferReader = reader; } private static FileInfo GetFileInfo(string path) @@ -152,17 +123,19 @@ private static FileInfo GetFileInfo(string path) public string ContentType { get; private set; } - public long FileLength { get; set; } + public long? FileLength { get; set; } public DateTime LastModifiedUtc { get; set; } - public DateTime ExpiresOnUtc { get; set; } = DateTime.UtcNow.AddDays(1); + public TimeSpan MaxAge { get; set; } = TimeSpan.FromDays(1); /// /// If not set, will be auto-generated based on property. /// public string ETag { get; set; } + public FileTransmitter Transmitter { get; private set; } + /// /// A callback that will be invoked on successful result execution /// @@ -170,271 +143,16 @@ private static FileInfo GetFileInfo(string path) #endregion - #region Utils - - // Most of the helpers here were copied over from the internal StaticFileHandler.cs - - private static DateTime UtcParse(string time) - { - Guard.NotNull(time, nameof(time)); - - var dtStyles = DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal; - if (DateTime.TryParseExact(time, _httpDateFormats, null, dtStyles, out var utcDate)) - { - return utcDate; - } - - throw new FormatException($"{time} is an invalid date expression."); - } - - private static bool IsOutDated(string ifRangeHeader, DateTime lastModified) - { - try - { - var utcLastModified = lastModified.ToUniversalTime(); - var utc = UtcParse(ifRangeHeader); - return (utc < utcLastModified); - } - catch - { - return true; - } - } - - private static string GenerateETag(HttpContextBase context, DateTime lastModified, DateTime now) - { - // Get 64-bit FILETIME stamp - var lastModFileTime = lastModified.ToFileTime(); - var nowFileTime = now.ToFileTime(); - var hexFileTime = lastModFileTime.ToString("X8", CultureInfo.InvariantCulture); - - //// Do what IIS does to determine if this is a weak ETag. - //// Compare the last modified time to now and if the difference is - //// less than or equal to 3 seconds, then it is weak - //if ((nowFileTime - lastModFileTime) <= 30000000) - //{ - // return "W/\"" + hexFileTime + "\""; - //} - - return "\"" + hexFileTime + "\""; - } - - // initial space characters are skipped, and the string of digits up until the first non-digit - // are converted to a long. If digits are found the method returns true; otherwise false. - private static bool GetLongFromSubstring(string s, ref int startIndex, out long result) - { - result = 0; - - // get index of first digit - MovePastSpaceCharacters(s, ref startIndex); - int beginIndex = startIndex; - - // get index of last digit - MovePastDigits(s, ref startIndex); - int endIndex = startIndex - 1; - - // are there any digits? - if (endIndex < beginIndex) - { - return false; - } - - long multipleOfTen = 1; - for (int i = endIndex; i >= beginIndex; i--) - { - int digit = s[i] - '0'; - result += digit * multipleOfTen; - multipleOfTen *= 10; - // check for overflow - if (result < 0) - { - return false; - } - } - - return true; - } - - // The Range header consists of one or more byte range specifiers. E.g, "Range: bytes=0-1024,-1024" is a request - // for the first and last 1024 bytes of a file. Before this method is called, startIndex points to the beginning - // of a byte range specifier; and afterwards it points to the beginning of the next byte range specifier. - // If the current byte range specifier is syntactially inavlid, this function will return false indicating that the - // Range header must be ignored. If the function returns true, then the byte range specifier will be converted to - // an offset and length, and the startIndex will be incremented to the next byte range specifier. The byte range - // specifier (offset and length) returned by this function is satisfiable if and only if isSatisfiable is true. - private static bool GetNextRange(string rangeHeader, ref int startIndex, long fileLength, out long offset, out long length, out bool isSatisfiable) - { - // startIndex is first char after '=', or first char after ',' - Debug.Assert(startIndex < rangeHeader.Length, "startIndex < rangeHeader.Length"); - - offset = 0; - length = 0; - isSatisfiable = false; - - // A Range request to an empty file is never satisfiable, and will always receive a 416 status. - if (fileLength <= 0) - { - // put startIndex at end of string so we don't try to call GetNextRange again - startIndex = rangeHeader.Length; - return true; - } - - MovePastSpaceCharacters(rangeHeader, ref startIndex); - - if (startIndex < rangeHeader.Length && rangeHeader[startIndex] == '-') - { - // this range is of the form "-mmm" - startIndex++; - if (!GetLongFromSubstring(rangeHeader, ref startIndex, out length)) - { - return false; - } - - if (length > fileLength) - { - // send entire file - offset = 0; - length = fileLength; - } - else - { - // send last N bytes - offset = fileLength - length; - } - - isSatisfiable = IsRangeSatisfiable(offset, length, fileLength); - // we parsed the current range, but need to successfully move the startIndex to the next range - return IncrementToNextRange(rangeHeader, ref startIndex); - } - else - { - // this range is of the form "nnn-[mmm]" - if (!GetLongFromSubstring(rangeHeader, ref startIndex, out offset)) - { - return false; - } - - // increment startIndex past '-' - if (startIndex < rangeHeader.Length && rangeHeader[startIndex] == '-') - { - startIndex++; - } - else - { - return false; - } - - if (!GetLongFromSubstring(rangeHeader, ref startIndex, out long endPos)) - { - // assume range is of form "nnn-". If it isn't, - // the call to IncrementToNextRange will return false - length = fileLength - offset; - } - else - { - // if...greater than or equal to the current length of the entity-body, last-byte-pos - // is taken to be equal to one less than the current length of the entity- body in bytes. - if (endPos > fileLength - 1) - { - endPos = fileLength - 1; - } - - length = endPos - offset + 1; - - if (length < 1) - { - // the byte range specifier is syntactially invalid - // because the last-byte-pos < first-byte-pos - return false; - } - } - - isSatisfiable = IsRangeSatisfiable(offset, length, fileLength); - - // we parsed the current range, but need to successfully move the startIndex to the next range - return IncrementToNextRange(rangeHeader, ref startIndex); - } - } - - private static bool IncrementToNextRange(string s, ref int startIndex) - { - // increment startIndex until next token and return true, unless the syntax is invalid - MovePastSpaceCharacters(s, ref startIndex); - - if (startIndex < s.Length) - { - if (s[startIndex] != ',') - { - return false; - } - // move to first char after ',' - startIndex++; - } - - return true; - } - - private static bool IsRangeSatisfiable(long offset, long length, long fileLength) - { - return (offset < fileLength && length > 0); - } - - private static bool IsSecurityError(int ErrorCode) - { - return (ErrorCode == ERROR_ACCESS_DENIED); - } - - private static void MovePastSpaceCharacters(string s, ref int startIndex) - { - while (startIndex < s.Length && s[startIndex] == ' ') - { - startIndex++; - } - } - - private static void MovePastDigits(string s, ref int startIndex) - { - while (startIndex < s.Length && s[startIndex] <= '9' && s[startIndex] >= '0') - { - startIndex++; - } - } - - private static void SendNotModified(HttpResponseBase response) - { - response.StatusCode = (int)HttpStatusCode.NotModified; - response.StatusDescription = "Not Modified"; - - // Explicitly set the Content-Length header so the client doesn't wait for - // content but keeps the connection open for other requests - response.AddHeader("Content-Length", "0"); - } - - private static void SendBadRequest(HttpResponseBase response) - { - response.StatusCode = (int)HttpStatusCode.BadRequest; - response.Write("Bad Request"); - } - - private static void SendRangeNotSatisfiable(HttpResponseBase response, long fileLength) - { - response.StatusCode = (int)HttpStatusCode.RequestedRangeNotSatisfiable; - response.ContentType = null; - response.AppendHeader("Content-Range", "bytes */" + fileLength.ToString(NumberFormatInfo.InvariantInfo)); - } - - #endregion - public override void ExecuteResult(ControllerContext context) { var httpContext = context.HttpContext; var request = httpContext.Request; var response = httpContext.Response; - // Determine Last Modified Time. We might need it soon + // Fix Last Modified Time. We might need it soon // if we encounter a Range: and If-Range header // Using UTC time to avoid daylight savings time bug 83230 - var lastModifiedInUtc = new DateTime(LastModifiedUtc.Year, + var lastModified = new DateTime(LastModifiedUtc.Year, LastModifiedUtc.Month, LastModifiedUtc.Day, LastModifiedUtc.Hour, @@ -448,291 +166,69 @@ public override void ExecuteResult(ControllerContext context) // DateTime.Now if it's in the future. // This is to fix VSWhidbey #402323 var utcNow = DateTime.UtcNow; - if (lastModifiedInUtc > utcNow) + if (lastModified > utcNow) { // use 1 second resolution - lastModifiedInUtc = new DateTime(utcNow.Ticks - (utcNow.Ticks % TimeSpan.TicksPerSecond), DateTimeKind.Utc); + lastModified = new DateTime(utcNow.Ticks - (utcNow.Ticks % TimeSpan.TicksPerSecond), DateTimeKind.Utc); } - string etag = ETag.NullEmpty() ?? GenerateETag(httpContext, lastModifiedInUtc, utcNow); - var fileLength = FileLength; + LastModifiedUtc = lastModified; - // is this a Range request? - var rangeHeader = request.Headers["Range"]; - if (rangeHeader.HasValue() && rangeHeader.StartsWith("bytes", StringComparison.OrdinalIgnoreCase)) + // Generate ETag if empty + if (ETag.IsEmpty()) { - if (ExecuteRangeRangeRequest(httpContext, fileLength, rangeHeader, etag, lastModifiedInUtc)) - { - return; - } + ETag = GenerateETag(httpContext, lastModified, utcNow); } - bool isNotModified = false; - - var ifNoneMatch = request.Headers["If-None-Match"]; - if (ifNoneMatch.HasValue() && etag == ifNoneMatch) - { - // File hasn't changed, so return HTTP 304 without retrieving the data - SendNotModified(response); - isNotModified = true; - } - else - { - // if we get this far, we're sending the entire file - SendFile(0, fileLength, fileLength, httpContext); - } + // Determine applicable file responder + var responder = ResolveResponder(request); - // Specify content type - response.ContentType = ContentType; - // We support byte ranges - response.AppendHeader("Accept-Ranges", "bytes"); - // Set an expires in the future (INFO: Chrome will not revalidate until expiration, but that's ok) - response.Cache.SetExpires(ExpiresOnUtc); - // always set ETag - response.Cache.SetETag(etag); - // always set Cache-Control to public - response.Cache.SetCacheability(HttpCacheability.Public); - // always set must-revalidate - response.Cache.SetRevalidation(HttpCacheRevalidation.AllCaches); - - if (!isNotModified) + // Execute response (send file) + if (responder.TrySendHeaders(httpContext)) { - // always set Last-Modified - response.Cache.SetLastModified(lastModifiedInUtc); + responder.SendFile(httpContext); } // Finish: invoke the optional callback OnExecuted?.Invoke(); } - private bool ExecuteRangeRangeRequest( - HttpContextBase context, - long fileLength, - string rangeHeader, - string etag, - DateTime lastModified) + private FileResponder ResolveResponder(HttpRequestBase request) { - var handled = false; - var request = context.Request; - var response = context.Response; - - // return "416 Requested range not satisfiable" if the file length is zero. - if (fileLength <= 0) - { - SendRangeNotSatisfiable(response, fileLength); - handled = true; - return handled; - } - - var ifRangeHeader = request.Headers["If-Range"]; - if (ifRangeHeader != null && ifRangeHeader.Length > 1) - { - // Is this an ETag or a Date? We only need to check two - // characters; an ETag either begins with W/ or it is quoted. - if (ifRangeHeader[0] == '"') - { - // it's a strong ETag - if (ifRangeHeader != etag) - { - // the etags do not match, and we will therefore return the entire response - return handled; - } - } - else if (ifRangeHeader[0] == 'W' && ifRangeHeader[1] == '/') - { - // it's a weak ETag, and is therefore not usable for sub-range retrieval and - // we will return the entire response - return handled; - } - else - { - // It's a date. If it is greater than or equal to the last-write time of the file, we can send the range. - if (IsOutDated(ifRangeHeader, lastModified)) - { - return handled; - } - } - } - - // the expected format is "bytes = [, , ...]" - // where is "-[]" or "-". - int indexOfEquals = rangeHeader.IndexOf('='); - if (indexOfEquals == -1 || indexOfEquals == rangeHeader.Length - 1) - { - // invalid syntax - return handled; - } - - int startIndex = indexOfEquals + 1; - bool isRangeHeaderSyntacticallyValid = true; - long offset; - long length; - bool isSatisfiable; - bool exceededMax = false; - ByteRange[] byteRanges = null; - int byteRangesCount = 0; - long totalBytes = 0; - while (startIndex < rangeHeader.Length && isRangeHeaderSyntacticallyValid) - { - isRangeHeaderSyntacticallyValid = GetNextRange(rangeHeader, ref startIndex, fileLength, out offset, out length, out isSatisfiable); - - if (!isRangeHeaderSyntacticallyValid) - { - break; - } - - if (!isSatisfiable) - { - continue; - } - - if (byteRanges == null) - { - byteRanges = new ByteRange[16]; - } - - if (byteRangesCount >= byteRanges.Length) - { - // grow byteRanges array - ByteRange[] buffer = new ByteRange[byteRanges.Length * 2]; - //int byteCount = byteRanges.Length * Marshal.SizeOf(byteRanges[0]); - //unsafe - //{ - // fixed (ByteRange* src = byteRanges, dst = buffer) - // { - // StringUtil.memcpyimpl((byte*)src, (byte*)dst, byteCount); - // } - //} - - Array.Copy(byteRanges, buffer, byteRanges.Length); - byteRanges = buffer; - } - - byteRanges[byteRangesCount].Offset = offset; - byteRanges[byteRangesCount].Length = length; - byteRangesCount++; - - // IIS imposes this limitation too, and sends "400 Bad Request" if exceeded - totalBytes += length; - if (totalBytes > fileLength * MAX_RANGE_ALLOWED) - { - exceededMax = true; - break; - } - } - - if (!isRangeHeaderSyntacticallyValid) - { - return handled; - } - - if (exceededMax) - { - SendBadRequest(response); - handled = true; - return handled; - } - - if (byteRangesCount == 0) + // is this a request for an unmodified file? + var ifNoneMatch = request.Headers["If-None-Match"]; + if (ifNoneMatch.HasValue() && ETag == ifNoneMatch) { - // we parsed the Range header and found no satisfiable byte ranges, so return "416 Requested Range Not Satisfiable" - SendRangeNotSatisfiable(response, fileLength); - handled = true; - return handled; + return new UnmodifiedFileResponder(this); } - if (byteRangesCount == 1) + // is this a Range request? + var rangeHeader = request.Headers["Range"]; + if (rangeHeader.HasValue() && rangeHeader.StartsWith("bytes", StringComparison.OrdinalIgnoreCase)) { - offset = byteRanges[0].Offset; - length = byteRanges[0].Length; - response.ContentType = ContentType; - string contentRange = String.Format(CultureInfo.InvariantCulture, CONTENT_RANGE_FORMAT, offset, offset + length - 1, fileLength); - response.AppendHeader("Content-Range", contentRange); - - SendFile(offset, length, fileLength, context); + return new RangeFileResponder(this, rangeHeader); } - else - { - response.ContentType = MULTIPART_CONTENT_TYPE; - string contentRange; - string partialContentType = "Content-Type: " + ContentType + "\r\n"; - for (int i = 0; i < byteRangesCount; i++) - { - offset = byteRanges[i].Offset; - length = byteRanges[i].Length; - response.Write(MULTIPART_RANGE_DELIMITER); - response.Write(partialContentType); - response.Write("Content-Range: "); - contentRange = String.Format(CultureInfo.InvariantCulture, CONTENT_RANGE_FORMAT, offset, offset + length - 1, fileLength); - response.Write(contentRange); - response.Write("\r\n\r\n"); - SendFile(offset, length, fileLength, context); - response.Write("\r\n"); - } - response.Write(MULTIPART_RANGE_END); - } - - // if we make it here, we're sending a "206 Partial Content" status - response.StatusCode = (int)HttpStatusCode.PartialContent; - response.AppendHeader("Accept-Ranges", "bytes"); - response.Cache.SetLastModified(lastModified); - response.Cache.SetETag(etag); - response.Cache.SetCacheability(HttpCacheability.Public); - handled = true; - return handled; + // Responder for sending the whole file + return new FullFileResponder(this); } - private void SendFile(long offset, long length, long fileLength, HttpContextBase context) + private static string GenerateETag(HttpContextBase context, DateTime lastModified, DateTime now) { - var response = context.Response; - bool bufferOutput = response.BufferOutput; - - try - { - response.BufferOutput = true; - - if (_streamReader != null) - { - var stream = _streamReader(); - if (stream == null) - { - throw new NullReferenceException("File stream cannot be NULL."); - } - - using (stream) - { - int bufferSize = (int)Math.Min(DefaultWriteBufferSize, length); - byte[] buffer = new byte[bufferSize]; - - int read; - long remaining = length; - - stream.Seek(offset, SeekOrigin.Begin); - while ((remaining > 0) && (read = stream.Read(buffer, 0, buffer.Length)) != 0) - { - response.OutputStream.Write(buffer, 0, read); - //response.Flush(); + // Get 64-bit FILETIME stamp + var lastModFileTime = lastModified.ToFileTime(); + var nowFileTime = now.ToFileTime(); + var hexFileTime = lastModFileTime.ToString("X8", CultureInfo.InvariantCulture); - remaining -= read; - } - } - } - else if (_bufferReader != null) - { - var buffer = _bufferReader(); - if (buffer == null) - { - throw new NullReferenceException("File buffer cannot be NULL."); - } + //// Do what IIS does to determine if this is a weak ETag. + //// Compare the last modified time to now and if the difference is + //// less than or equal to 3 seconds, then it is weak + //if ((nowFileTime - lastModFileTime) <= 30000000) + //{ + // return "W/\"" + hexFileTime + "\""; + //} - response.OutputStream.Write(buffer, (int)offset, (int)length); - } - } - finally - { - response.BufferOutput = bufferOutput; - } + return "\"" + hexFileTime + "\""; } } } diff --git a/src/Presentation/SmartStore.Web.Framework/Modelling/Results/FileResponder.cs b/src/Presentation/SmartStore.Web.Framework/Modelling/Results/FileResponder.cs new file mode 100644 index 0000000000..7453750a39 --- /dev/null +++ b/src/Presentation/SmartStore.Web.Framework/Modelling/Results/FileResponder.cs @@ -0,0 +1,560 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Web; + +namespace SmartStore.Web.Framework.Modelling +{ + internal abstract class FileResponder + { + protected FileResponder(IFileResponse fileResponse) + { + Guard.NotNull(fileResponse, nameof(fileResponse)); + FileResponse = fileResponse; + } + + protected IFileResponse FileResponse { get; private set; } + + public virtual bool TrySendHeaders(HttpContextBase context) + { + var response = context.Response; + + var utcNow = DateTime.UtcNow; + + // Specify content type + response.ContentType = FileResponse.ContentType; + // We support byte ranges + response.AppendHeader("Accept-Ranges", "bytes"); + // Set the expires header for HTTP 1.0 cliets + response.Cache.SetExpires(utcNow.Add(FileResponse.MaxAge)); + // How often the browser should check that it has the latest version + response.Cache.SetMaxAge(FileResponse.MaxAge); + // The unique identifier for the entity + response.Cache.SetETag(FileResponse.ETag); + // Proxy and browser can cache response + response.Cache.SetCacheability(HttpCacheability.Public); + // Proxy cache should check with orginal server once cache has expired + response.Cache.SetRevalidation(HttpCacheRevalidation.AllCaches); + //// The date the file was last modified + //context.Response.Cache.SetLastModified(FileResponse.LastModifiedUtc); + + return true; + } + + public abstract void SendFile(HttpContextBase context); + } + + + + internal sealed class UnmodifiedFileResponder : FileResponder + { + public UnmodifiedFileResponder(IFileResponse fileResponse) + : base(fileResponse) + { + } + + public override bool TrySendHeaders(HttpContextBase context) + { + var response = context.Response; + + base.TrySendHeaders(context); + + response.StatusCode = (int)HttpStatusCode.NotModified; + response.StatusDescription = "Not Modified"; + + // Explicitly set the Content-Length header so the client doesn't wait for + // content but keeps the connection open for other requests + response.AddHeader("Content-Length", "0"); + + return true; + } + + public override void SendFile(HttpContextBase context) + { + // Don't send file, it is unmodified. Let browser fetch from its cache. + } + } + + + + internal sealed class FullFileResponder : FileResponder + { + public FullFileResponder(IFileResponse fileResponse) + : base(fileResponse) + { + } + + public override bool TrySendHeaders(HttpContextBase context) + { + base.TrySendHeaders(context); + + // The date the file was last modified + context.Response.Cache.SetLastModified(FileResponse.LastModifiedUtc); + + return true; + } + + public override void SendFile(HttpContextBase context) + { + var fileLength = FileResponse.FileLength ?? FileResponse.Transmitter.GetFileLength(); + FileResponse.Transmitter.TransmitFile(0, fileLength, fileLength, context); + } + } + + + + internal sealed class RangeFileResponder : FileResponder + { + internal struct ByteRange + { + internal long Offset; + internal long Length; + } + + private const string RANGE_BOUNDARY = ""; + private const string MULTIPART_CONTENT_TYPE = "multipart/byteranges; boundary="; + private const string MULTIPART_RANGE_DELIMITER = "--\r\n"; + private const string MULTIPART_RANGE_END = "----\r\n\r\n"; + private const string CONTENT_RANGE_FORMAT = "bytes {0}-{1}/{2}"; + private const int MAX_RANGE_ALLOWED = 5; + + private const int ERROR_ACCESS_DENIED = 5; + + private static readonly string[] _httpDateFormats = new string[] { "r", "dddd, dd-MMM-yy HH':'mm':'ss 'GMT'", "ddd MMM d HH':'mm':'ss yyyy" }; + + private readonly FileResponder _defaultResponder; + private readonly string _rangeHeader; + + public RangeFileResponder(IFileResponse fileResponse, string rangeHeader) + : base(fileResponse) + { + _defaultResponder = new FullFileResponder(fileResponse); + _rangeHeader = rangeHeader; + } + + public override bool TrySendHeaders(HttpContextBase context) + { + var fileLength = FileResponse.FileLength ?? FileResponse.Transmitter.GetFileLength(); + var etag = FileResponse.ETag; + var lastModified = FileResponse.LastModifiedUtc; + + var handled = ExecuteRangeRequest(context, fileLength, _rangeHeader, etag, lastModified); + if (!handled && _defaultResponder.TrySendHeaders(context)) + { + _defaultResponder.SendFile(context); + handled = true; + } + + return handled; + } + + public override void SendFile(HttpContextBase context) + { + // Do nothing here, we have handled everything in 'TrySendHeaders()' already + } + + #region Range Utils + + // Most of the helpers here were copied over from the internal StaticFileHandler.cs + + private bool ExecuteRangeRequest( + HttpContextBase context, + long fileLength, + string rangeHeader, + string etag, + DateTime lastModified) + { + var handled = false; + var request = context.Request; + var response = context.Response; + + // return "416 Requested range not satisfiable" if the file length is zero. + if (fileLength <= 0) + { + SendRangeNotSatisfiable(response, fileLength); + handled = true; + return handled; + } + + var ifRangeHeader = request.Headers["If-Range"]; + if (ifRangeHeader != null && ifRangeHeader.Length > 1) + { + // Is this an ETag or a Date? We only need to check two + // characters; an ETag either begins with W/ or it is quoted. + if (ifRangeHeader[0] == '"') + { + // it's a strong ETag + if (ifRangeHeader != etag) + { + // the etags do not match, and we will therefore return the entire response + return handled; + } + } + else if (ifRangeHeader[0] == 'W' && ifRangeHeader[1] == '/') + { + // it's a weak ETag, and is therefore not usable for sub-range retrieval and + // we will return the entire response + return handled; + } + else + { + // It's a date. If it is greater than or equal to the last-write time of the file, we can send the range. + if (IsOutDated(ifRangeHeader, lastModified)) + { + return handled; + } + } + } + + // the expected format is "bytes = [, , ...]" + // where is "-[]" or "-". + int indexOfEquals = rangeHeader.IndexOf('='); + if (indexOfEquals == -1 || indexOfEquals == rangeHeader.Length - 1) + { + // invalid syntax + return handled; + } + + int startIndex = indexOfEquals + 1; + bool isRangeHeaderSyntacticallyValid = true; + long offset; + long length; + bool isSatisfiable; + bool exceededMax = false; + ByteRange[] byteRanges = null; + int byteRangesCount = 0; + long totalBytes = 0; + while (startIndex < rangeHeader.Length && isRangeHeaderSyntacticallyValid) + { + isRangeHeaderSyntacticallyValid = GetNextRange(rangeHeader, ref startIndex, fileLength, out offset, out length, out isSatisfiable); + + if (!isRangeHeaderSyntacticallyValid) + { + break; + } + + if (!isSatisfiable) + { + continue; + } + + if (byteRanges == null) + { + byteRanges = new ByteRange[16]; + } + + if (byteRangesCount >= byteRanges.Length) + { + // grow byteRanges array + ByteRange[] buffer = new ByteRange[byteRanges.Length * 2]; + Array.Copy(byteRanges, buffer, byteRanges.Length); + byteRanges = buffer; + } + + byteRanges[byteRangesCount].Offset = offset; + byteRanges[byteRangesCount].Length = length; + byteRangesCount++; + + // IIS imposes this limitation too, and sends "400 Bad Request" if exceeded + totalBytes += length; + if (totalBytes > fileLength * MAX_RANGE_ALLOWED) + { + exceededMax = true; + break; + } + } + + if (!isRangeHeaderSyntacticallyValid) + { + return handled; + } + + if (exceededMax) + { + SendBadRequest(response); + handled = true; + return handled; + } + + if (byteRangesCount == 0) + { + // we parsed the Range header and found no satisfiable byte ranges, so return "416 Requested Range Not Satisfiable" + SendRangeNotSatisfiable(response, fileLength); + handled = true; + return handled; + } + + if (byteRangesCount == 1) + { + offset = byteRanges[0].Offset; + length = byteRanges[0].Length; + response.ContentType = FileResponse.ContentType; + string contentRange = String.Format(CultureInfo.InvariantCulture, CONTENT_RANGE_FORMAT, offset, offset + length - 1, fileLength); + response.AppendHeader("Content-Range", contentRange); + + SendResponseHeaders(); + FileResponse.Transmitter.TransmitFile(offset, length, fileLength, context); + } + else + { + response.ContentType = MULTIPART_CONTENT_TYPE; + string contentRange; + string partialContentType = "Content-Type: " + FileResponse.ContentType + "\r\n"; + + SendResponseHeaders(); + + for (int i = 0; i < byteRangesCount; i++) + { + offset = byteRanges[i].Offset; + length = byteRanges[i].Length; + response.Write(MULTIPART_RANGE_DELIMITER); + response.Write(partialContentType); + response.Write("Content-Range: "); + contentRange = String.Format(CultureInfo.InvariantCulture, CONTENT_RANGE_FORMAT, offset, offset + length - 1, fileLength); + response.Write(contentRange); + response.Write("\r\n\r\n"); + FileResponse.Transmitter.TransmitFile(offset, length, fileLength, context); + response.Write("\r\n"); + } + response.Write(MULTIPART_RANGE_END); + } + + handled = true; + return handled; + + void SendResponseHeaders() + { + // Send a "206 Partial Content" status + response.StatusCode = (int)HttpStatusCode.PartialContent; + response.AppendHeader("Accept-Ranges", "bytes"); + response.Cache.SetLastModified(lastModified); + response.Cache.SetETag(etag); + response.Cache.SetCacheability(HttpCacheability.Public); + } + } + + private void SendBadRequest(HttpResponseBase response) + { + response.StatusCode = (int)HttpStatusCode.BadRequest; + response.Write("Bad Request"); + } + + private void SendRangeNotSatisfiable(HttpResponseBase response, long fileLength) + { + response.StatusCode = (int)HttpStatusCode.RequestedRangeNotSatisfiable; + response.ContentType = null; + response.AppendHeader("Content-Range", "bytes */" + fileLength.ToString(NumberFormatInfo.InvariantInfo)); + } + + private static bool IsOutDated(string ifRangeHeader, DateTime lastModified) + { + try + { + var utcLastModified = lastModified.ToUniversalTime(); + var utc = UtcParse(ifRangeHeader); + return (utc < utcLastModified); + } + catch + { + return true; + } + } + + private static DateTime UtcParse(string time) + { + Guard.NotNull(time, nameof(time)); + + var dtStyles = DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal; + if (DateTime.TryParseExact(time, _httpDateFormats, null, dtStyles, out var utcDate)) + { + return utcDate; + } + + throw new FormatException($"{time} is an invalid date expression."); + } + + // initial space characters are skipped, and the string of digits up until the first non-digit + // are converted to a long. If digits are found the method returns true; otherwise false. + private static bool GetLongFromSubstring(string s, ref int startIndex, out long result) + { + result = 0; + + // get index of first digit + MovePastSpaceCharacters(s, ref startIndex); + int beginIndex = startIndex; + + // get index of last digit + MovePastDigits(s, ref startIndex); + int endIndex = startIndex - 1; + + // are there any digits? + if (endIndex < beginIndex) + { + return false; + } + + long multipleOfTen = 1; + for (int i = endIndex; i >= beginIndex; i--) + { + int digit = s[i] - '0'; + result += digit * multipleOfTen; + multipleOfTen *= 10; + // check for overflow + if (result < 0) + { + return false; + } + } + + return true; + } + + // The Range header consists of one or more byte range specifiers. E.g, "Range: bytes=0-1024,-1024" is a request + // for the first and last 1024 bytes of a file. Before this method is called, startIndex points to the beginning + // of a byte range specifier; and afterwards it points to the beginning of the next byte range specifier. + // If the current byte range specifier is syntactially inavlid, this function will return false indicating that the + // Range header must be ignored. If the function returns true, then the byte range specifier will be converted to + // an offset and length, and the startIndex will be incremented to the next byte range specifier. The byte range + // specifier (offset and length) returned by this function is satisfiable if and only if isSatisfiable is true. + private static bool GetNextRange(string rangeHeader, ref int startIndex, long fileLength, out long offset, out long length, out bool isSatisfiable) + { + // startIndex is first char after '=', or first char after ',' + Debug.Assert(startIndex < rangeHeader.Length, "startIndex < rangeHeader.Length"); + + offset = 0; + length = 0; + isSatisfiable = false; + + // A Range request to an empty file is never satisfiable, and will always receive a 416 status. + if (fileLength <= 0) + { + // put startIndex at end of string so we don't try to call GetNextRange again + startIndex = rangeHeader.Length; + return true; + } + + MovePastSpaceCharacters(rangeHeader, ref startIndex); + + if (startIndex < rangeHeader.Length && rangeHeader[startIndex] == '-') + { + // this range is of the form "-mmm" + startIndex++; + if (!GetLongFromSubstring(rangeHeader, ref startIndex, out length)) + { + return false; + } + + if (length > fileLength) + { + // send entire file + offset = 0; + length = fileLength; + } + else + { + // send last N bytes + offset = fileLength - length; + } + + isSatisfiable = IsRangeSatisfiable(offset, length, fileLength); + // we parsed the current range, but need to successfully move the startIndex to the next range + return IncrementToNextRange(rangeHeader, ref startIndex); + } + else + { + // this range is of the form "nnn-[mmm]" + if (!GetLongFromSubstring(rangeHeader, ref startIndex, out offset)) + { + return false; + } + + // increment startIndex past '-' + if (startIndex < rangeHeader.Length && rangeHeader[startIndex] == '-') + { + startIndex++; + } + else + { + return false; + } + + if (!GetLongFromSubstring(rangeHeader, ref startIndex, out long endPos)) + { + // assume range is of form "nnn-". If it isn't, + // the call to IncrementToNextRange will return false + length = fileLength - offset; + } + else + { + // if...greater than or equal to the current length of the entity-body, last-byte-pos + // is taken to be equal to one less than the current length of the entity- body in bytes. + if (endPos > fileLength - 1) + { + endPos = fileLength - 1; + } + + length = endPos - offset + 1; + + if (length < 1) + { + // the byte range specifier is syntactially invalid + // because the last-byte-pos < first-byte-pos + return false; + } + } + + isSatisfiable = IsRangeSatisfiable(offset, length, fileLength); + + // we parsed the current range, but need to successfully move the startIndex to the next range + return IncrementToNextRange(rangeHeader, ref startIndex); + } + } + + private static bool IncrementToNextRange(string s, ref int startIndex) + { + // increment startIndex until next token and return true, unless the syntax is invalid + MovePastSpaceCharacters(s, ref startIndex); + + if (startIndex < s.Length) + { + if (s[startIndex] != ',') + { + return false; + } + // move to first char after ',' + startIndex++; + } + + return true; + } + + private static bool IsRangeSatisfiable(long offset, long length, long fileLength) + { + return (offset < fileLength && length > 0); + } + + private static bool IsSecurityError(int ErrorCode) + { + return (ErrorCode == ERROR_ACCESS_DENIED); + } + + private static void MovePastSpaceCharacters(string s, ref int startIndex) + { + while (startIndex < s.Length && s[startIndex] == ' ') + { + startIndex++; + } + } + + private static void MovePastDigits(string s, ref int startIndex) + { + while (startIndex < s.Length && s[startIndex] <= '9' && s[startIndex] >= '0') + { + startIndex++; + } + } + + #endregion + } +} diff --git a/src/Presentation/SmartStore.Web.Framework/Modelling/Results/FileTransmitter.cs b/src/Presentation/SmartStore.Web.Framework/Modelling/Results/FileTransmitter.cs new file mode 100644 index 0000000000..82b2399294 --- /dev/null +++ b/src/Presentation/SmartStore.Web.Framework/Modelling/Results/FileTransmitter.cs @@ -0,0 +1,116 @@ +using System; +using System.IO; +using System.Web; + +namespace SmartStore.Web.Framework.Modelling +{ + public abstract class FileTransmitter + { + public abstract long GetFileLength(); + public abstract void TransmitFile(long offset, long length, long fileLength, HttpContextBase context); + } + + public sealed class FileBufferTransmitter : FileTransmitter + { + private readonly Func _bufferFactory; + private byte[] _buffer; + + public FileBufferTransmitter(Func bufferFactory) + { + Guard.NotNull(bufferFactory, nameof(bufferFactory)); + _bufferFactory = bufferFactory; + } + + public override long GetFileLength() + { + return GetBuffer().LongLength; + } + + public override void TransmitFile(long offset, long length, long fileLength, HttpContextBase context) + { + context.Response.OutputStream.Write(GetBuffer(), (int)offset, (int)length); + } + + private byte[] GetBuffer() + { + if (_buffer == null) + { + _buffer = _bufferFactory(); + if (_buffer == null) + { + throw new NullReferenceException("File buffer cannot be NULL."); + } + } + + return _buffer; + } + } + + public sealed class FileStreamTransmitter : FileTransmitter + { + // Default buffer size as defined in BufferedStream type + const int DefaultBufferSize = 81920; + + private readonly Func _streamFactory; + private Stream _stream; + + public FileStreamTransmitter(Func streamFactory) + { + Guard.NotNull(streamFactory, nameof(streamFactory)); + _streamFactory = streamFactory; + } + + public override long GetFileLength() + { + return GetStream().Length; + } + + public override void TransmitFile(long offset, long length, long fileLength, HttpContextBase context) + { + var response = context.Response; + var stream = GetStream(); + + response.BufferOutput = false; + + using (stream) + { + int bufferSize = (int)Math.Min(DefaultBufferSize, length); + byte[] buffer = new byte[bufferSize]; + + int read; + long remaining = length; + + stream.Seek(offset, SeekOrigin.Begin); + while ((remaining > 0) && (read = stream.Read(buffer, 0, buffer.Length)) != 0) + { + if (response.IsClientConnected) + { + response.OutputStream.Write(buffer, 0, read); + //response.Flush(); + + remaining -= read; + } + else + { + remaining = 0; + break; + } + } + } + } + + private Stream GetStream() + { + if (_stream == null) + { + _stream = _streamFactory(); + if (_stream == null) + { + throw new NullReferenceException("File stream cannot be NULL."); + } + } + + return _stream; + } + } +} diff --git a/src/Presentation/SmartStore.Web.Framework/Modelling/Results/IFileResponse.cs b/src/Presentation/SmartStore.Web.Framework/Modelling/Results/IFileResponse.cs new file mode 100644 index 0000000000..f490fe8dac --- /dev/null +++ b/src/Presentation/SmartStore.Web.Framework/Modelling/Results/IFileResponse.cs @@ -0,0 +1,14 @@ +using System; + +namespace SmartStore.Web.Framework.Modelling +{ + public interface IFileResponse + { + string ContentType { get; } + long? FileLength { get; } + DateTime LastModifiedUtc { get; } + TimeSpan MaxAge { get; } + string ETag { get; } + FileTransmitter Transmitter { get; } + } +} diff --git a/src/Presentation/SmartStore.Web.Framework/SmartStore.Web.Framework.csproj b/src/Presentation/SmartStore.Web.Framework/SmartStore.Web.Framework.csproj index 67dc045297..4af5211677 100644 --- a/src/Presentation/SmartStore.Web.Framework/SmartStore.Web.Framework.csproj +++ b/src/Presentation/SmartStore.Web.Framework/SmartStore.Web.Framework.csproj @@ -227,6 +227,9 @@ + + + diff --git a/src/Presentation/SmartStore.Web/Controllers/MediaController.cs b/src/Presentation/SmartStore.Web/Controllers/MediaController.cs index c633e1e179..000007fa75 100644 --- a/src/Presentation/SmartStore.Web/Controllers/MediaController.cs +++ b/src/Presentation/SmartStore.Web/Controllers/MediaController.cs @@ -300,7 +300,7 @@ private async Task HandleImageAsync( } source = await ProcessAndPutToCacheAsync(cachedImage, source, query); - return new CachedFileResult(mime, cachedImage.LastModifiedUtc.GetValueOrDefault(), source.LongLength, () => source); + return new CachedFileResult(mime, cachedImage.LastModifiedUtc.GetValueOrDefault(), () => source, source.LongLength); } } }