using MediaBrowser.Common.Extensions; using MediaBrowser.Common.IO; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Logging; using MediaBrowser.Model.Serialization; using ServiceStack; using ServiceStack.Web; 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.Model.Net.MimeTypes; namespace MediaBrowser.Server.Implementations.HttpServer { /// /// Class HttpResultFactory /// public class HttpResultFactory : IHttpResultFactory { /// /// The _logger /// private readonly ILogger _logger; private readonly IFileSystem _fileSystem; private readonly IJsonSerializer _jsonSerializer; /// /// Initializes a new instance of the class. /// /// The log manager. /// The file system. /// The json serializer. public HttpResultFactory(ILogManager logManager, IFileSystem fileSystem, IJsonSerializer jsonSerializer) { _fileSystem = fileSystem; _jsonSerializer = jsonSerializer; _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; } /// /// Gets the optimized result. /// /// /// The request context. /// The result. /// The response headers. /// System.Object. /// result public object GetOptimizedResult(IRequest requestContext, T result, IDictionary responseHeaders = null) where T : class { if (result == null) { throw new ArgumentNullException("result"); } var optimizedResult = requestContext.ToOptimizedResult(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(IRequest 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(IRequest 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; } IHasOptions httpResult; var stream = result as Stream; if (stream != null) { httpResult = new StreamWriter(stream, contentType, _logger); } else { // Otherwise wrap into an HttpResult 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(IRequest 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; } public object GetStaticFileResult(IRequest requestContext, string path, FileShare fileShare = FileShare.Read) { if (string.IsNullOrEmpty(path)) { throw new ArgumentNullException("path"); } return GetStaticFileResult(requestContext, new StaticFileResultOptions { Path = path, FileShare = fileShare }); } public object GetStaticFileResult(IRequest requestContext, StaticFileResultOptions options) { var path = options.Path; var fileShare = options.FileShare; 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"); } if (string.IsNullOrWhiteSpace(options.ContentType)) { options.ContentType = MimeTypes.GetMimeType(path); } options.DateLastModified = _fileSystem.GetLastWriteTimeUtc(path); var cacheKey = path + options.DateLastModified.Value.Ticks; options.CacheKey = cacheKey.GetMD5(); options.ContentFactory = () => Task.FromResult(GetFileStream(path, fileShare)); return GetStaticResult(requestContext, options); } /// /// 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); } public object GetStaticResult(IRequest requestContext, Guid cacheKey, DateTime? lastDateModified, TimeSpan? cacheDuration, string contentType, Func> factoryFn, IDictionary responseHeaders = null, bool isHeadRequest = false) { return GetStaticResult(requestContext, new StaticResultOptions { CacheDuration = cacheDuration, CacheKey = cacheKey, ContentFactory = factoryFn, ContentType = contentType, DateLastModified = lastDateModified, IsHeadRequest = isHeadRequest, ResponseHeaders = responseHeaders }); } public object GetStaticResult(IRequest requestContext, StaticResultOptions options) { var cacheKey = options.CacheKey; options.ResponseHeaders = options.ResponseHeaders ?? new Dictionary(); var contentType = options.ContentType; if (cacheKey == Guid.Empty) { throw new ArgumentNullException("cacheKey"); } if (options.ContentFactory == null) { throw new ArgumentNullException("factoryFn"); } var key = cacheKey.ToString("N"); // See if the result is already cached in the browser var result = GetCachedResult(requestContext, options.ResponseHeaders, cacheKey, key, options.DateLastModified, options.CacheDuration, contentType); if (result != null) { return result; } var compress = ShouldCompressResponse(requestContext, contentType); var hasOptions = GetStaticResult(requestContext, options, compress).Result; AddResponseHeaders(hasOptions, options.ResponseHeaders); return hasOptions; } /// /// Shoulds the compress response. /// /// The request context. /// Type of the content. /// true if XXXX, false otherwise private bool ShouldCompressResponse(IRequest 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"); private async Task GetStaticResult(IRequest requestContext, StaticResultOptions options, bool compress) { var isHeadRequest = options.IsHeadRequest; var factoryFn = options.ContentFactory; var contentType = options.ContentType; var responseHeaders = options.ResponseHeaders; var requestedCompressionType = requestContext.GetCompressionType(); if (!compress || string.IsNullOrEmpty(requestedCompressionType)) { var rangeHeader = requestContext.GetHeader("Range"); var stream = await factoryFn().ConfigureAwait(false); if (!string.IsNullOrEmpty(rangeHeader)) { return new RangeRequestWriter(rangeHeader, stream, contentType, isHeadRequest, _logger) { OnComplete = options.OnComplete }; } responseHeaders["Content-Length"] = stream.Length.ToString(UsCulture); if (isHeadRequest) { stream.Dispose(); return GetHttpResult(new byte[] { }, contentType); } return new StreamWriter(stream, contentType, _logger) { OnComplete = options.OnComplete }; } string content; using (var stream = await factoryFn().ConfigureAwait(false)) { using (var reader = new StreamReader(stream)) { content = await reader.ReadToEndAsync().ConfigureAwait(false); } } var contents = content.Compress(requestedCompressionType); responseHeaders["Content-Length"] = contents.Length.ToString(UsCulture); if (isHeadRequest) { return GetHttpResult(new byte[] { }, contentType); } return new CompressedResult(contents, requestedCompressionType, 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(IRequest 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; } } }