using MediaBrowser.Common.Extensions; using MediaBrowser.Common.IO; using MediaBrowser.Common.Net; using MediaBrowser.Controller.IO; using MediaBrowser.Model.Logging; using ServiceStack.Common; using ServiceStack.Common.Web; using ServiceStack.ServiceHost; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Net; using System.Text; using System.Threading.Tasks; using MimeTypes = MediaBrowser.Common.Net.MimeTypes; namespace MediaBrowser.Server.Implementations.HttpServer { /// /// Class HttpResultFactory /// public class HttpResultFactory : IHttpResultFactory { /// /// The _logger /// private readonly ILogger _logger; private readonly IFileSystem _fileSystem; /// /// Initializes a new instance of the class. /// /// The log manager. public HttpResultFactory(ILogManager logManager, IFileSystem fileSystem) { _fileSystem = fileSystem; _logger = logManager.GetLogger("HttpResultFactory"); } /// /// Gets the result. /// /// The content. /// Type of the content. /// The response headers. /// System.Object. public object GetResult(object content, string contentType, IDictionary responseHeaders = null) { return GetHttpResult(content, contentType, responseHeaders); } /// /// Gets the HTTP result. /// /// The content. /// Type of the content. /// The response headers. /// IHasOptions. private IHasOptions GetHttpResult(object content, string contentType, IDictionary responseHeaders = null) { IHasOptions result; var stream = content as Stream; if (stream != null) { result = new StreamWriter(stream, contentType, _logger); } else { var bytes = content as byte[]; if (bytes != null) { result = new StreamWriter(bytes, contentType, _logger); } else { var text = content as string; if (text != null) { result = new StreamWriter(Encoding.UTF8.GetBytes(text), contentType, _logger); } else { result = new HttpResult(content, contentType); } } } if (responseHeaders != null) { AddResponseHeaders(result, responseHeaders); } return result; } private bool SupportsCompression { get { return true; } } /// /// Gets the optimized result. /// /// /// The request context. /// The result. /// The response headers. /// System.Object. /// result public object GetOptimizedResult(IRequestContext requestContext, T result, IDictionary responseHeaders = null) where T : class { if (result == null) { throw new ArgumentNullException("result"); } var optimizedResult = SupportsCompression ? requestContext.ToOptimizedResult(result) : result; if (responseHeaders != null) { // Apply headers var hasOptions = optimizedResult as IHasOptions; if (hasOptions != null) { AddResponseHeaders(hasOptions, responseHeaders); } } return optimizedResult; } /// /// Gets the optimized result using cache. /// /// /// The request context. /// The cache key. /// The last date modified. /// Duration of the cache. /// The factory fn. /// The response headers. /// System.Object. /// /// cacheKey /// or /// factoryFn /// public object GetOptimizedResultUsingCache(IRequestContext requestContext, Guid cacheKey, DateTime lastDateModified, TimeSpan? cacheDuration, Func factoryFn, IDictionary responseHeaders = null) where T : class { if (cacheKey == Guid.Empty) { throw new ArgumentNullException("cacheKey"); } if (factoryFn == null) { throw new ArgumentNullException("factoryFn"); } var key = cacheKey.ToString("N"); if (responseHeaders == null) { responseHeaders = new Dictionary(); } // See if the result is already cached in the browser var result = GetCachedResult(requestContext, responseHeaders, cacheKey, key, lastDateModified, cacheDuration, null); if (result != null) { return result; } return GetOptimizedResult(requestContext, factoryFn(), responseHeaders); } /// /// To the cached result. /// /// /// The request context. /// The cache key. /// The last date modified. /// Duration of the cache. /// The factory fn. /// Type of the content. /// The response headers. /// System.Object. /// cacheKey public object GetCachedResult(IRequestContext requestContext, Guid cacheKey, DateTime lastDateModified, TimeSpan? cacheDuration, Func factoryFn, string contentType, IDictionary responseHeaders = null) where T : class { if (cacheKey == Guid.Empty) { throw new ArgumentNullException("cacheKey"); } if (factoryFn == null) { throw new ArgumentNullException("factoryFn"); } var key = cacheKey.ToString("N"); if (responseHeaders == null) { responseHeaders = new Dictionary(); } // See if the result is already cached in the browser var result = GetCachedResult(requestContext, responseHeaders, cacheKey, key, lastDateModified, cacheDuration, contentType); if (result != null) { return result; } result = factoryFn(); // Apply caching headers var hasOptions = result as IHasOptions; if (hasOptions != null) { AddResponseHeaders(hasOptions, responseHeaders); return hasOptions; } // Otherwise wrap into an HttpResult var httpResult = new HttpResult(result, contentType ?? "text/html", HttpStatusCode.NotModified); AddResponseHeaders(httpResult, responseHeaders); return httpResult; } /// /// Pres the process optimized result. /// /// The request context. /// The responseHeaders. /// The cache key. /// The cache key string. /// The last date modified. /// Duration of the cache. /// Type of the content. /// System.Object. private object GetCachedResult(IRequestContext requestContext, IDictionary responseHeaders, Guid cacheKey, string cacheKeyString, DateTime? lastDateModified, TimeSpan? cacheDuration, string contentType) { responseHeaders["ETag"] = cacheKeyString; if (IsNotModified(requestContext, cacheKey, lastDateModified, cacheDuration)) { AddAgeHeader(responseHeaders, lastDateModified); AddExpiresHeader(responseHeaders, cacheKeyString, cacheDuration); var result = new HttpResult(new byte[] { }, contentType ?? "text/html", HttpStatusCode.NotModified); AddResponseHeaders(result, responseHeaders); return result; } AddCachingHeaders(responseHeaders, cacheKeyString, lastDateModified, cacheDuration); return null; } /// /// Gets the static file result. /// /// The request context. /// The path. /// The file share. /// The response headers. /// if set to true [is head request]. /// System.Object. /// path public object GetStaticFileResult(IRequestContext requestContext, string path, FileShare fileShare = FileShare.Read, IDictionary responseHeaders = null, bool isHeadRequest = false) { if (string.IsNullOrEmpty(path)) { throw new ArgumentNullException("path"); } if (fileShare != FileShare.Read && fileShare != FileShare.ReadWrite) { throw new ArgumentException("FileShare must be either Read or ReadWrite"); } var dateModified = _fileSystem.GetLastWriteTimeUtc(path); var cacheKey = path + dateModified.Ticks; return GetStaticResult(requestContext, cacheKey.GetMD5(), dateModified, null, MimeTypes.GetMimeType(path), () => Task.FromResult(GetFileStream(path, fileShare)), responseHeaders, isHeadRequest); } /// /// Gets the file stream. /// /// The path. /// The file share. /// Stream. private Stream GetFileStream(string path, FileShare fileShare) { return _fileSystem.GetFileStream(path, FileMode.Open, FileAccess.Read, fileShare, true); } /// /// Gets the static result. /// /// The request context. /// The cache key. /// The last date modified. /// Duration of the cache. /// Type of the content. /// The factory fn. /// The response headers. /// if set to true [is head request]. /// System.Object. /// cacheKey /// or /// factoryFn public object GetStaticResult(IRequestContext requestContext, Guid cacheKey, DateTime? lastDateModified, TimeSpan? cacheDuration, string contentType, Func> factoryFn, IDictionary responseHeaders = null, bool isHeadRequest = false) { if (cacheKey == Guid.Empty) { throw new ArgumentNullException("cacheKey"); } if (factoryFn == null) { throw new ArgumentNullException("factoryFn"); } var key = cacheKey.ToString("N"); if (responseHeaders == null) { responseHeaders = new Dictionary(); } // See if the result is already cached in the browser var result = GetCachedResult(requestContext, responseHeaders, cacheKey, key, lastDateModified, cacheDuration, contentType); if (result != null) { return result; } var compress = ShouldCompressResponse(requestContext, contentType); var hasOptions = GetStaticResult(requestContext, responseHeaders, contentType, factoryFn, compress, isHeadRequest).Result; AddResponseHeaders(hasOptions, responseHeaders); return hasOptions; } /// /// Shoulds the compress response. /// /// The request context. /// Type of the content. /// true if XXXX, false otherwise private bool ShouldCompressResponse(IRequestContext requestContext, string contentType) { // It will take some work to support compression with byte range requests if (!string.IsNullOrEmpty(requestContext.GetHeader("Range"))) { return false; } // Don't compress media if (contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) || contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase)) { return false; } // Don't compress images if (contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) { return false; } if (contentType.StartsWith("font/", StringComparison.OrdinalIgnoreCase)) { return false; } if (contentType.StartsWith("application/", StringComparison.OrdinalIgnoreCase)) { if (string.Equals(contentType, "application/x-javascript", StringComparison.OrdinalIgnoreCase)) { return true; } if (string.Equals(contentType, "application/xml", StringComparison.OrdinalIgnoreCase)) { return true; } return false; } return true; } /// /// The us culture /// private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); /// /// Gets the static result. /// /// The request context. /// The response headers. /// Type of the content. /// The factory fn. /// if set to true [compress]. /// if set to true [is head request]. /// Task{IHasOptions}. private async Task GetStaticResult(IRequestContext requestContext, IDictionary responseHeaders, string contentType, Func> factoryFn, bool compress, bool isHeadRequest) { if (!compress || string.IsNullOrEmpty(requestContext.CompressionType)) { var stream = await factoryFn().ConfigureAwait(false); var rangeHeader = requestContext.GetHeader("Range"); if (!string.IsNullOrEmpty(rangeHeader)) { return new RangeRequestWriter(rangeHeader, stream, contentType, isHeadRequest); } responseHeaders["Content-Length"] = stream.Length.ToString(UsCulture); if (isHeadRequest) { return GetHttpResult(new byte[] { }, contentType); } return new StreamWriter(stream, contentType, _logger); } if (isHeadRequest) { return GetHttpResult(new byte[] { }, contentType); } string content; using (var stream = await factoryFn().ConfigureAwait(false)) { using (var reader = new StreamReader(stream)) { content = await reader.ReadToEndAsync().ConfigureAwait(false); } } if (!SupportsCompression) { return new HttpResult(content, contentType); } var contents = content.Compress(requestContext.CompressionType); return new CompressedResult(contents, requestContext.CompressionType, contentType); } /// /// Adds the caching responseHeaders. /// /// The responseHeaders. /// The cache key. /// The last date modified. /// Duration of the cache. private void AddCachingHeaders(IDictionary responseHeaders, string cacheKey, DateTime? lastDateModified, TimeSpan? cacheDuration) { // Don't specify both last modified and Etag, unless caching unconditionally. They are redundant // https://developers.google.com/speed/docs/best-practices/caching#LeverageBrowserCaching if (lastDateModified.HasValue && (string.IsNullOrEmpty(cacheKey) || cacheDuration.HasValue)) { AddAgeHeader(responseHeaders, lastDateModified); responseHeaders["LastModified"] = lastDateModified.Value.ToString("r"); } if (cacheDuration.HasValue) { responseHeaders["Cache-Control"] = "public, max-age=" + Convert.ToInt32(cacheDuration.Value.TotalSeconds); } else if (!string.IsNullOrEmpty(cacheKey)) { responseHeaders["Cache-Control"] = "public"; } else { responseHeaders["Cache-Control"] = "no-cache, no-store, must-revalidate"; responseHeaders["pragma"] = "no-cache, no-store, must-revalidate"; } AddExpiresHeader(responseHeaders, cacheKey, cacheDuration); } /// /// Adds the expires header. /// /// The responseHeaders. /// The cache key. /// Duration of the cache. private void AddExpiresHeader(IDictionary responseHeaders, string cacheKey, TimeSpan? cacheDuration) { if (cacheDuration.HasValue) { responseHeaders["Expires"] = DateTime.UtcNow.Add(cacheDuration.Value).ToString("r"); } else if (string.IsNullOrEmpty(cacheKey)) { responseHeaders["Expires"] = "-1"; } } /// /// Adds the age header. /// /// The responseHeaders. /// The last date modified. private void AddAgeHeader(IDictionary responseHeaders, DateTime? lastDateModified) { if (lastDateModified.HasValue) { responseHeaders["Age"] = Convert.ToInt64((DateTime.UtcNow - lastDateModified.Value).TotalSeconds).ToString(CultureInfo.InvariantCulture); } } /// /// Determines whether [is not modified] [the specified cache key]. /// /// The request context. /// The cache key. /// The last date modified. /// Duration of the cache. /// true if [is not modified] [the specified cache key]; otherwise, false. private bool IsNotModified(IRequestContext requestContext, Guid? cacheKey, DateTime? lastDateModified, TimeSpan? cacheDuration) { var isNotModified = true; var ifModifiedSinceHeader = requestContext.GetHeader("If-Modified-Since"); if (!string.IsNullOrEmpty(ifModifiedSinceHeader)) { DateTime ifModifiedSince; if (DateTime.TryParse(ifModifiedSinceHeader, out ifModifiedSince)) { isNotModified = IsNotModified(ifModifiedSince.ToUniversalTime(), cacheDuration, lastDateModified); } } var ifNoneMatchHeader = requestContext.GetHeader("If-None-Match"); // Validate If-None-Match if (isNotModified && (cacheKey.HasValue || !string.IsNullOrEmpty(ifNoneMatchHeader))) { Guid ifNoneMatch; if (Guid.TryParse(ifNoneMatchHeader ?? string.Empty, out ifNoneMatch)) { if (cacheKey.HasValue && cacheKey.Value == ifNoneMatch) { return true; } } } return false; } /// /// Determines whether [is not modified] [the specified if modified since]. /// /// If modified since. /// Duration of the cache. /// The date modified. /// true if [is not modified] [the specified if modified since]; otherwise, false. private bool IsNotModified(DateTime ifModifiedSince, TimeSpan? cacheDuration, DateTime? dateModified) { if (dateModified.HasValue) { var lastModified = NormalizeDateForComparison(dateModified.Value); ifModifiedSince = NormalizeDateForComparison(ifModifiedSince); return lastModified <= ifModifiedSince; } if (cacheDuration.HasValue) { var cacheExpirationDate = ifModifiedSince.Add(cacheDuration.Value); if (DateTime.UtcNow < cacheExpirationDate) { return true; } } return false; } /// /// When the browser sends the IfModifiedDate, it's precision is limited to seconds, so this will account for that /// /// The date. /// DateTime. private DateTime NormalizeDateForComparison(DateTime date) { return new DateTime(date.Year, date.Month, date.Day, date.Hour, date.Minute, date.Second, date.Kind); } /// /// Adds the response headers. /// /// The has options. /// The response headers. private void AddResponseHeaders(IHasOptions hasOptions, IEnumerable> responseHeaders) { foreach (var item in responseHeaders) { hasOptions.Options[item.Key] = item.Value; } } /// /// Gets the error result. /// /// The status code. /// The error message. /// The response headers. /// System.Object. public void ThrowError(int statusCode, string errorMessage, IDictionary responseHeaders = null) { var error = new HttpError { Status = statusCode, ErrorCode = errorMessage }; if (responseHeaders != null) { AddResponseHeaders(error, responseHeaders); } throw error; } } }