From e2dcddc5ac43846baea0f9b1a0fc62844dd9ee1d Mon Sep 17 00:00:00 2001 From: Luke Pulverenti Date: Sat, 23 Mar 2013 22:45:00 -0400 Subject: made compression and caching available to plugin api endpoints --- .../HttpServer/HttpResultFactory.cs | 581 ++++++++++++++++++++- 1 file changed, 578 insertions(+), 3 deletions(-) (limited to 'MediaBrowser.Server.Implementations/HttpServer/HttpResultFactory.cs') diff --git a/MediaBrowser.Server.Implementations/HttpServer/HttpResultFactory.cs b/MediaBrowser.Server.Implementations/HttpServer/HttpResultFactory.cs index 2dd968988..78b883d34 100644 --- a/MediaBrowser.Server.Implementations/HttpServer/HttpResultFactory.cs +++ b/MediaBrowser.Server.Implementations/HttpServer/HttpResultFactory.cs @@ -1,14 +1,589 @@ -using MediaBrowser.Common.Net; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Net; +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.Threading.Tasks; +using MimeTypes = MediaBrowser.Common.Net.MimeTypes; namespace MediaBrowser.Server.Implementations.HttpServer { + /// + /// Class HttpResultFactory + /// public class HttpResultFactory : IHttpResultFactory { - public object GetResult(Stream stream, string contentType) + /// + /// The _logger + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The log manager. + public HttpResultFactory(ILogManager logManager) + { + _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) + { + var 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(IRequestContext 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(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 response headers. + /// if set to true [is head request]. + /// System.Object. + /// path + public object GetStaticFileResult(IRequestContext requestContext, string path, IDictionary responseHeaders = null, bool isHeadRequest = false) { - return new HttpResult(stream, contentType); + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentNullException("path"); + } + + var dateModified = File.GetLastWriteTimeUtc(path); + + var cacheKey = path + dateModified.Ticks; + + return GetStaticResult(requestContext, cacheKey.GetMD5(), dateModified, null, MimeTypes.GetMimeType(path), () => Task.FromResult(GetFileStream(path)), responseHeaders, isHeadRequest); + } + + /// + /// Gets the file stream. + /// + /// The path. + /// Stream. + private Stream GetFileStream(string path) + { + return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous); + } + + /// + /// 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)) + { + 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 new HttpResult(new byte[] { }, contentType); + } + + return new StreamWriter(stream, contentType, _logger); + } + + if (isHeadRequest) + { + return new HttpResult(new byte[] { }, contentType); + } + + 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(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, IDictionary 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; } } } -- cgit v1.2.3