using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Logging; using MediaBrowser.Model.Serialization; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Net; using System.Runtime.Serialization; using System.Text; using System.Threading.Tasks; using System.Xml; using Emby.Server.Implementations.Services; using MediaBrowser.Model.IO; using MediaBrowser.Model.Services; using IRequest = MediaBrowser.Model.Services.IRequest; using MimeTypes = MediaBrowser.Model.Net.MimeTypes; namespace Emby.Server.Implementations.HttpServer { /// /// Class HttpResultFactory /// public class HttpResultFactory : IHttpResultFactory { /// /// The _logger /// private readonly ILogger _logger; private readonly IFileSystem _fileSystem; private readonly IJsonSerializer _jsonSerializer; private readonly IMemoryStreamFactory _memoryStreamFactory; /// /// Initializes a new instance of the class. /// public HttpResultFactory(ILogManager logManager, IFileSystem fileSystem, IJsonSerializer jsonSerializer, IMemoryStreamFactory memoryStreamFactory) { _fileSystem = fileSystem; _jsonSerializer = jsonSerializer; _memoryStreamFactory = memoryStreamFactory; _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, true, responseHeaders); } public object GetRedirectResult(string url) { var responseHeaders = new Dictionary(); responseHeaders["Location"] = url; var result = new HttpResult(new byte[] { }, "text/plain", HttpStatusCode.Redirect); AddResponseHeaders(result, responseHeaders); return result; } /// /// Gets the HTTP result. /// private IHasHeaders GetHttpResult(object content, string contentType, bool addCachePrevention, IDictionary responseHeaders = null) { IHasHeaders 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, HttpStatusCode.OK); } } } if (responseHeaders == null) { responseHeaders = new Dictionary(); } string expires; if (addCachePrevention && !responseHeaders.TryGetValue("Expires", out expires)) { responseHeaders["Expires"] = "-1"; } 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 { return GetOptimizedResultInternal(requestContext, result, true, responseHeaders); } private object GetOptimizedResultInternal(IRequest requestContext, T result, bool addCachePrevention, IDictionary responseHeaders = null) where T : class { if (result == null) { throw new ArgumentNullException("result"); } var optimizedResult = ToOptimizedResult(requestContext, result); if (responseHeaders == null) { responseHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); } if (addCachePrevention) { responseHeaders["Expires"] = "-1"; } // Apply headers var hasHeaders = optimizedResult as IHasHeaders; if (hasHeaders != null) { AddResponseHeaders(hasHeaders, responseHeaders); } return optimizedResult; } public static string GetCompressionType(IRequest request) { var acceptEncoding = request.Headers["Accept-Encoding"]; if (!string.IsNullOrWhiteSpace(acceptEncoding)) { if (acceptEncoding.Contains("deflate")) return "deflate"; if (acceptEncoding.Contains("gzip")) return "gzip"; } return null; } /// /// Returns the optimized result for the IRequestContext. /// Does not use or store results in any cache. /// /// /// /// public object ToOptimizedResult(IRequest request, T dto) { var contentType = request.ResponseContentType; switch (GetRealContentType(contentType)) { case "application/xml": case "text/xml": case "text/xml; charset=utf-8": //"text/xml; charset=utf-8" also matches xml return SerializeToXmlString(dto); case "application/json": case "text/json": return _jsonSerializer.SerializeToString(dto); default: { var ms = new MemoryStream(); var writerFn = RequestHelper.GetResponseWriter(HttpListenerHost.Instance, contentType); writerFn(dto, ms); ms.Position = 0; if (string.Equals(request.Verb, "head", StringComparison.OrdinalIgnoreCase)) { return GetHttpResult(new byte[] { }, contentType, true); } return GetHttpResult(ms, contentType, true); } } } public static string GetRealContentType(string contentType) { return contentType == null ? null : contentType.Split(';')[0].ToLower().Trim(); } private string SerializeToXmlString(object from) { using (var ms = new MemoryStream()) { var xwSettings = new XmlWriterSettings(); xwSettings.Encoding = new UTF8Encoding(false); xwSettings.OmitXmlDeclaration = false; using (var xw = XmlWriter.Create(ms, xwSettings)) { var serializer = new DataContractSerializer(from.GetType()); serializer.WriteObject(xw, from); xw.Flush(); ms.Seek(0, SeekOrigin.Begin); var reader = new StreamReader(ms); return reader.ReadToEnd(); } } } /// /// 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(StringComparer.OrdinalIgnoreCase); } // 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 GetOptimizedResultInternal(requestContext, factoryFn(), false, 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(StringComparer.OrdinalIgnoreCase); } // 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 hasHeaders = result as IHasHeaders; if (hasHeaders != null) { AddResponseHeaders(hasHeaders, responseHeaders); return hasHeaders; } return GetHttpResult(result, contentType, false, responseHeaders); } /// /// Pres the process optimized result. /// private object GetCachedResult(IRequest requestContext, IDictionary responseHeaders, Guid cacheKey, string cacheKeyString, DateTime? lastDateModified, TimeSpan? cacheDuration, string contentType) { responseHeaders["ETag"] = string.Format("\"{0}\"", cacheKeyString); var noCache = (requestContext.Headers.Get("Cache-Control") ?? string.Empty).IndexOf("no-cache", StringComparison.OrdinalIgnoreCase) != -1; if (!noCache) { if (IsNotModified(requestContext, cacheKey, lastDateModified, cacheDuration)) { AddAgeHeader(responseHeaders, lastDateModified); AddExpiresHeader(responseHeaders, cacheKeyString, cacheDuration, noCache); var result = new HttpResult(new byte[] { }, contentType ?? "text/html", HttpStatusCode.NotModified); AddResponseHeaders(result, responseHeaders); return result; } } AddCachingHeaders(responseHeaders, cacheKeyString, lastDateModified, cacheDuration, noCache); return null; } public Task GetStaticFileResult(IRequest requestContext, string path, FileShareMode fileShare = FileShareMode.Read) { if (string.IsNullOrEmpty(path)) { throw new ArgumentNullException("path"); } return GetStaticFileResult(requestContext, new StaticFileResultOptions { Path = path, FileShare = fileShare }); } public Task GetStaticFileResult(IRequest requestContext, StaticFileResultOptions options) { var path = options.Path; var fileShare = options.FileShare; if (string.IsNullOrEmpty(path)) { throw new ArgumentNullException("path"); } if (fileShare != FileShareMode.Read && fileShare != FileShareMode.ReadWrite) { throw new ArgumentException("FileShare must be either Read or ReadWrite"); } if (string.IsNullOrWhiteSpace(options.ContentType)) { options.ContentType = MimeTypes.GetMimeType(path); } if (!options.DateLastModified.HasValue) { options.DateLastModified = _fileSystem.GetLastWriteTimeUtc(path); } var cacheKey = path + options.DateLastModified.Value.Ticks; options.CacheKey = cacheKey.GetMD5(); options.ContentFactory = () => Task.FromResult(GetFileStream(path, fileShare)); options.ResponseHeaders = options.ResponseHeaders ?? new Dictionary(StringComparer.OrdinalIgnoreCase); // Quotes are valid in linux. They'll possibly cause issues here var filename = (Path.GetFileName(path) ?? string.Empty).Replace("\"", string.Empty); if (!string.IsNullOrWhiteSpace(filename)) { options.ResponseHeaders["Content-Disposition"] = "inline; filename=\"" + filename + "\""; } return GetStaticResult(requestContext, options); } /// /// Gets the file stream. /// /// The path. /// The file share. /// Stream. private Stream GetFileStream(string path, FileShareMode fileShare) { return _fileSystem.GetFileStream(path, FileOpenMode.Open, FileAccessMode.Read, fileShare); } public Task 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 async Task GetStaticResult(IRequest requestContext, StaticResultOptions options) { var cacheKey = options.CacheKey; options.ResponseHeaders = options.ResponseHeaders ?? new Dictionary(StringComparer.OrdinalIgnoreCase); var contentType = options.ContentType; if (cacheKey == Guid.Empty) { throw new ArgumentNullException("cacheKey"); } 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 hasHeaders = await GetStaticResult(requestContext, options, compress).ConfigureAwait(false); AddResponseHeaders(hasHeaders, options.ResponseHeaders); return hasHeaders; } /// /// 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.IsNullOrWhiteSpace(requestContext.Headers.Get("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 = GetCompressionType(requestContext); var rangeHeader = requestContext.Headers.Get("Range"); if (!isHeadRequest && !string.IsNullOrWhiteSpace(options.Path)) { return new FileWriter(options.Path, contentType, rangeHeader, _logger, _fileSystem) { OnComplete = options.OnComplete, OnError = options.OnError, FileShare = options.FileShare }; } if (!string.IsNullOrWhiteSpace(rangeHeader)) { var stream = await factoryFn().ConfigureAwait(false); return new RangeRequestWriter(rangeHeader, stream, contentType, isHeadRequest, _logger) { OnComplete = options.OnComplete }; } else { var stream = await factoryFn().ConfigureAwait(false); responseHeaders["Content-Length"] = stream.Length.ToString(UsCulture); if (isHeadRequest) { stream.Dispose(); return GetHttpResult(new byte[] { }, contentType, true); } return new StreamWriter(stream, contentType, _logger) { OnComplete = options.OnComplete, OnError = options.OnError }; } } /// /// Adds the caching responseHeaders. /// private void AddCachingHeaders(IDictionary responseHeaders, string cacheKey, DateTime? lastDateModified, TimeSpan? cacheDuration, bool noCache) { // 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["Last-Modified"] = lastDateModified.Value.ToString("r"); } if (!noCache && cacheDuration.HasValue) { responseHeaders["Cache-Control"] = "public, max-age=" + Convert.ToInt32(cacheDuration.Value.TotalSeconds); } else if (!noCache && !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, noCache); } /// /// Adds the expires header. /// private void AddExpiresHeader(IDictionary responseHeaders, string cacheKey, TimeSpan? cacheDuration, bool noCache) { if (!noCache && 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.Headers.Get("If-Modified-Since"); if (!string.IsNullOrEmpty(ifModifiedSinceHeader)) { DateTime ifModifiedSince; if (DateTime.TryParse(ifModifiedSinceHeader, out ifModifiedSince)) { if (IsNotModified(ifModifiedSince.ToUniversalTime(), cacheDuration, lastDateModified)) { return true; } } } var ifNoneMatchHeader = requestContext.Headers.Get("If-None-Match"); // Validate If-None-Match if ((cacheKey.HasValue || !string.IsNullOrEmpty(ifNoneMatchHeader))) { Guid ifNoneMatch; ifNoneMatchHeader = (ifNoneMatchHeader ?? string.Empty).Trim('\"'); if (Guid.TryParse(ifNoneMatchHeader, 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(IHasHeaders hasHeaders, IEnumerable> responseHeaders) { foreach (var item in responseHeaders) { hasHeaders.Headers[item.Key] = item.Value; } } } }