diff options
Diffstat (limited to 'MediaBrowser.Common/Net')
20 files changed, 4418 insertions, 1008 deletions
diff --git a/MediaBrowser.Common/Net/AlchemyWebSocket.cs b/MediaBrowser.Common/Net/AlchemyWebSocket.cs new file mode 100644 index 0000000000..1971990db9 --- /dev/null +++ b/MediaBrowser.Common/Net/AlchemyWebSocket.cs @@ -0,0 +1,131 @@ +using Alchemy.Classes; +using MediaBrowser.Common.Logging; +using MediaBrowser.Common.Serialization; +using MediaBrowser.Model.Logging; +using System; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Class AlchemyWebSocket + /// </summary> + public class AlchemyWebSocket : IWebSocket + { + /// <summary> + /// The logger + /// </summary> + private static ILogger Logger = LogManager.GetLogger("AlchemyWebSocket"); + + /// <summary> + /// Gets or sets the web socket. + /// </summary> + /// <value>The web socket.</value> + private UserContext UserContext { get; set; } + + /// <summary> + /// Initializes a new instance of the <see cref="AlchemyWebSocket" /> class. + /// </summary> + /// <param name="context">The context.</param> + /// <exception cref="System.ArgumentNullException">context</exception> + public AlchemyWebSocket(UserContext context) + { + if (context == null) + { + throw new ArgumentNullException("context"); + } + + UserContext = context; + + context.SetOnDisconnect(OnDisconnected); + context.SetOnReceive(OnReceive); + + Logger.Info("Client connected from {0}", context.ClientAddress); + } + + /// <summary> + /// The _disconnected + /// </summary> + private bool _disconnected = false; + /// <summary> + /// Gets or sets the state. + /// </summary> + /// <value>The state.</value> + public WebSocketState State + { + get { return _disconnected ? WebSocketState.Closed : WebSocketState.Open; } + } + + /// <summary> + /// Called when [disconnected]. + /// </summary> + /// <param name="context">The context.</param> + private void OnDisconnected(UserContext context) + { + _disconnected = true; + } + + /// <summary> + /// Called when [receive]. + /// </summary> + /// <param name="context">The context.</param> + private void OnReceive(UserContext context) + { + if (OnReceiveDelegate != null) + { + var json = context.DataFrame.ToString(); + + if (!string.IsNullOrWhiteSpace(json)) + { + try + { + var messageResult = JsonSerializer.DeserializeFromString<WebSocketMessageInfo>(json); + + OnReceiveDelegate(messageResult); + } + catch (Exception ex) + { + Logger.ErrorException("Error processing web socket message", ex); + } + } + } + } + + /// <summary> + /// Sends the async. + /// </summary> + /// <param name="bytes">The bytes.</param> + /// <param name="type">The type.</param> + /// <param name="endOfMessage">if set to <c>true</c> [end of message].</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public Task SendAsync(byte[] bytes, WebSocketMessageType type, bool endOfMessage, CancellationToken cancellationToken) + { + return Task.Run(() => UserContext.Send(bytes)); + } + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + public void Dispose() + { + Dispose(true); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + } + + /// <summary> + /// Gets or sets the receive action. + /// </summary> + /// <value>The receive action.</value> + public Action<WebSocketMessageInfo> OnReceiveDelegate { get; set; } + } +} diff --git a/MediaBrowser.Common/Net/BaseRestService.cs b/MediaBrowser.Common/Net/BaseRestService.cs new file mode 100644 index 0000000000..cfec9398db --- /dev/null +++ b/MediaBrowser.Common/Net/BaseRestService.cs @@ -0,0 +1,451 @@ +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Kernel; +using MediaBrowser.Common.Logging; +using MediaBrowser.Model.Logging; +using ServiceStack.Common; +using ServiceStack.Common.Web; +using ServiceStack.ServiceHost; +using ServiceStack.ServiceInterface; +using ServiceStack.WebHost.Endpoints; +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading.Tasks; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Class BaseRestService + /// </summary> + public class BaseRestService : Service, IRestfulService + { + /// <summary> + /// The logger + /// </summary> + protected ILogger Logger = LogManager.GetLogger("RestService"); + + /// <summary> + /// Gets or sets the kernel. + /// </summary> + /// <value>The kernel.</value> + public IKernel Kernel { get; set; } + + /// <summary> + /// Gets a value indicating whether this instance is range request. + /// </summary> + /// <value><c>true</c> if this instance is range request; otherwise, <c>false</c>.</value> + protected bool IsRangeRequest + { + get + { + return Request.Headers.AllKeys.Contains("Range"); + } + } + + /// <summary> + /// Adds the routes. + /// </summary> + /// <param name="appHost">The app host.</param> + public virtual void Configure(IAppHost appHost) + { + } + + /// <summary> + /// To the optimized result. + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="result">The result.</param> + /// <returns>System.Object.</returns> + /// <exception cref="System.ArgumentNullException">result</exception> + protected object ToOptimizedResult<T>(T result) + where T : class + { + if (result == null) + { + throw new ArgumentNullException("result"); + } + + Response.AddHeader("Vary", "Accept-Encoding"); + + return RequestContext.ToOptimizedResult(result); + } + + /// <summary> + /// To the optimized result using cache. + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="cacheKey">The cache key.</param> + /// <param name="lastDateModified">The last date modified.</param> + /// <param name="cacheDuration">Duration of the cache.</param> + /// <param name="factoryFn">The factory fn.</param> + /// <returns>System.Object.</returns> + /// <exception cref="System.ArgumentNullException">cacheKey</exception> + protected object ToOptimizedResultUsingCache<T>(Guid cacheKey, DateTime lastDateModified, TimeSpan? cacheDuration, Func<T> factoryFn) + where T : class + { + if (cacheKey == Guid.Empty) + { + throw new ArgumentNullException("cacheKey"); + } + if (factoryFn == null) + { + throw new ArgumentNullException("factoryFn"); + } + + var key = cacheKey.ToString("N"); + + var result = PreProcessCachedResult(cacheKey, key, lastDateModified, cacheDuration, string.Empty); + + if (result != null) + { + return result; + } + + return ToOptimizedResult(factoryFn()); + } + + /// <summary> + /// To the cached result. + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="cacheKey">The cache key.</param> + /// <param name="lastDateModified">The last date modified.</param> + /// <param name="cacheDuration">Duration of the cache.</param> + /// <param name="factoryFn">The factory fn.</param> + /// <param name="contentType">Type of the content.</param> + /// <returns>System.Object.</returns> + /// <exception cref="System.ArgumentNullException">cacheKey</exception> + protected object ToCachedResult<T>(Guid cacheKey, DateTime lastDateModified, TimeSpan? cacheDuration, Func<T> factoryFn, string contentType) + where T : class + { + if (cacheKey == Guid.Empty) + { + throw new ArgumentNullException("cacheKey"); + } + if (factoryFn == null) + { + throw new ArgumentNullException("factoryFn"); + } + + var key = cacheKey.ToString("N"); + + var result = PreProcessCachedResult(cacheKey, key, lastDateModified, cacheDuration, contentType); + + if (result != null) + { + return result; + } + + return factoryFn(); + } + + /// <summary> + /// To the static file result. + /// </summary> + /// <param name="path">The path.</param> + /// <returns>System.Object.</returns> + /// <exception cref="System.ArgumentNullException">path</exception> + protected object ToStaticFileResult(string path) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentNullException("path"); + } + + var dateModified = File.GetLastWriteTimeUtc(path); + + var cacheKey = path + dateModified.Ticks; + + return ToStaticResult(cacheKey.GetMD5(), dateModified, null, MimeTypes.GetMimeType(path), () => Task.FromResult(GetFileStream(path))); + } + + /// <summary> + /// Gets the file stream. + /// </summary> + /// <param name="path">The path.</param> + /// <returns>Stream.</returns> + private Stream GetFileStream(string path) + { + return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous); + } + + /// <summary> + /// To the static result. + /// </summary> + /// <param name="cacheKey">The cache key.</param> + /// <param name="lastDateModified">The last date modified.</param> + /// <param name="cacheDuration">Duration of the cache.</param> + /// <param name="contentType">Type of the content.</param> + /// <param name="factoryFn">The factory fn.</param> + /// <returns>System.Object.</returns> + /// <exception cref="System.ArgumentNullException">cacheKey</exception> + protected object ToStaticResult(Guid cacheKey, DateTime? lastDateModified, TimeSpan? cacheDuration, string contentType, Func<Task<Stream>> factoryFn) + { + if (cacheKey == Guid.Empty) + { + throw new ArgumentNullException("cacheKey"); + } + if (factoryFn == null) + { + throw new ArgumentNullException("factoryFn"); + } + + var key = cacheKey.ToString("N"); + + var result = PreProcessCachedResult(cacheKey, key, lastDateModified, cacheDuration, contentType); + + if (result != null) + { + return result; + } + + var compress = ShouldCompressResponse(contentType); + + if (compress) + { + Response.AddHeader("Vary", "Accept-Encoding"); + } + + return ToStaticResult(contentType, factoryFn, compress).Result; + } + + /// <summary> + /// Shoulds the compress response. + /// </summary> + /// <param name="contentType">Type of the content.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + private bool ShouldCompressResponse(string contentType) + { + // It will take some work to support compression with byte range requests + if (IsRangeRequest) + { + 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; + } + + return true; + } + + /// <summary> + /// To the static result. + /// </summary> + /// <param name="contentType">Type of the content.</param> + /// <param name="factoryFn">The factory fn.</param> + /// <param name="compress">if set to <c>true</c> [compress].</param> + /// <returns>System.Object.</returns> + private async Task<object> ToStaticResult(string contentType, Func<Task<Stream>> factoryFn, bool compress) + { + if (!compress || string.IsNullOrEmpty(RequestContext.CompressionType)) + { + Response.ContentType = contentType; + return await factoryFn().ConfigureAwait(false); + } + + 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); + } + + /// <summary> + /// Pres the process optimized result. + /// </summary> + /// <param name="cacheKey">The cache key.</param> + /// <param name="cacheKeyString">The cache key string.</param> + /// <param name="lastDateModified">The last date modified.</param> + /// <param name="cacheDuration">Duration of the cache.</param> + /// <param name="contentType">Type of the content.</param> + /// <returns>System.Object.</returns> + private object PreProcessCachedResult(Guid cacheKey, string cacheKeyString, DateTime? lastDateModified, TimeSpan? cacheDuration, string contentType) + { + Response.AddHeader("ETag", cacheKeyString); + + if (IsNotModified(cacheKey, lastDateModified, cacheDuration)) + { + AddAgeHeader(lastDateModified); + AddExpiresHeader(cacheKeyString, cacheDuration); + //ctx.Response.SendChunked = false; + + if (!string.IsNullOrEmpty(contentType)) + { + Response.ContentType = contentType; + } + + return new HttpResult(new byte[] { }, HttpStatusCode.NotModified); + } + + SetCachingHeaders(cacheKeyString, lastDateModified, cacheDuration); + + return null; + } + + /// <summary> + /// Determines whether [is not modified] [the specified cache key]. + /// </summary> + /// <param name="cacheKey">The cache key.</param> + /// <param name="lastDateModified">The last date modified.</param> + /// <param name="cacheDuration">Duration of the cache.</param> + /// <returns><c>true</c> if [is not modified] [the specified cache key]; otherwise, <c>false</c>.</returns> + private bool IsNotModified(Guid? cacheKey, DateTime? lastDateModified, TimeSpan? cacheDuration) + { + var isNotModified = true; + + if (Request.Headers.AllKeys.Contains("If-Modified-Since")) + { + DateTime ifModifiedSince; + + if (DateTime.TryParse(Request.Headers["If-Modified-Since"], out ifModifiedSince)) + { + isNotModified = IsNotModified(ifModifiedSince.ToUniversalTime(), cacheDuration, lastDateModified); + } + } + + // Validate If-None-Match + if (isNotModified && (cacheKey.HasValue || !string.IsNullOrEmpty(Request.Headers["If-None-Match"]))) + { + Guid ifNoneMatch; + + if (Guid.TryParse(Request.Headers["If-None-Match"] ?? string.Empty, out ifNoneMatch)) + { + if (cacheKey.HasValue && cacheKey.Value == ifNoneMatch) + { + return true; + } + } + } + + return false; + } + + /// <summary> + /// Determines whether [is not modified] [the specified if modified since]. + /// </summary> + /// <param name="ifModifiedSince">If modified since.</param> + /// <param name="cacheDuration">Duration of the cache.</param> + /// <param name="dateModified">The date modified.</param> + /// <returns><c>true</c> if [is not modified] [the specified if modified since]; otherwise, <c>false</c>.</returns> + 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; + } + + + /// <summary> + /// When the browser sends the IfModifiedDate, it's precision is limited to seconds, so this will account for that + /// </summary> + /// <param name="date">The date.</param> + /// <returns>DateTime.</returns> + private DateTime NormalizeDateForComparison(DateTime date) + { + return new DateTime(date.Year, date.Month, date.Day, date.Hour, date.Minute, date.Second, date.Kind); + } + + /// <summary> + /// Sets the caching headers. + /// </summary> + /// <param name="cacheKey">The cache key.</param> + /// <param name="lastDateModified">The last date modified.</param> + /// <param name="cacheDuration">Duration of the cache.</param> + private void SetCachingHeaders(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(lastDateModified); + Response.AddHeader("LastModified", lastDateModified.Value.ToString("r")); + } + + if (cacheDuration.HasValue) + { + Response.AddHeader("Cache-Control", "public, max-age=" + Convert.ToInt32(cacheDuration.Value.TotalSeconds)); + } + else if (!string.IsNullOrEmpty(cacheKey)) + { + Response.AddHeader("Cache-Control", "public"); + } + else + { + Response.AddHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + Response.AddHeader("pragma", "no-cache, no-store, must-revalidate"); + } + + AddExpiresHeader(cacheKey, cacheDuration); + } + + /// <summary> + /// Adds the expires header. + /// </summary> + /// <param name="cacheKey">The cache key.</param> + /// <param name="cacheDuration">Duration of the cache.</param> + private void AddExpiresHeader(string cacheKey, TimeSpan? cacheDuration) + { + if (cacheDuration.HasValue) + { + Response.AddHeader("Expires", DateTime.UtcNow.Add(cacheDuration.Value).ToString("r")); + } + else if (string.IsNullOrEmpty(cacheKey)) + { + Response.AddHeader("Expires", "-1"); + } + } + + /// <summary> + /// Adds the age header. + /// </summary> + /// <param name="lastDateModified">The last date modified.</param> + private void AddAgeHeader(DateTime? lastDateModified) + { + if (lastDateModified.HasValue) + { + Response.AddHeader("Age", Convert.ToInt64((DateTime.UtcNow - lastDateModified.Value).TotalSeconds).ToString(CultureInfo.InvariantCulture)); + } + } + } +} diff --git a/MediaBrowser.Common/Net/Handlers/BaseActionHandler.cs b/MediaBrowser.Common/Net/Handlers/BaseActionHandler.cs new file mode 100644 index 0000000000..72df88519b --- /dev/null +++ b/MediaBrowser.Common/Net/Handlers/BaseActionHandler.cs @@ -0,0 +1,31 @@ +using MediaBrowser.Common.Kernel; +using MediaBrowser.Model.Entities; +using System.Threading.Tasks; + +namespace MediaBrowser.Common.Net.Handlers +{ + /// <summary> + /// Class BaseActionHandler + /// </summary> + /// <typeparam name="TKernelType">The type of the T kernel type.</typeparam> + public abstract class BaseActionHandler<TKernelType> : BaseSerializationHandler<TKernelType, EmptyRequestResult> + where TKernelType : IKernel + { + /// <summary> + /// Gets the object to serialize. + /// </summary> + /// <returns>Task{EmptyRequestResult}.</returns> + protected override async Task<EmptyRequestResult> GetObjectToSerialize() + { + await ExecuteAction(); + + return new EmptyRequestResult(); + } + + /// <summary> + /// Performs the action. + /// </summary> + /// <returns>Task.</returns> + protected abstract Task ExecuteAction(); + } +} diff --git a/MediaBrowser.Common/Net/Handlers/BaseEmbeddedResourceHandler.cs b/MediaBrowser.Common/Net/Handlers/BaseEmbeddedResourceHandler.cs deleted file mode 100644 index 579e341fec..0000000000 --- a/MediaBrowser.Common/Net/Handlers/BaseEmbeddedResourceHandler.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.IO;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Common.Net.Handlers
-{
- public abstract class BaseEmbeddedResourceHandler : BaseHandler
- {
- protected BaseEmbeddedResourceHandler(string resourcePath)
- : base()
- {
- ResourcePath = resourcePath;
- }
-
- protected string ResourcePath { get; set; }
-
- protected override Task WriteResponseToOutputStream(Stream stream)
- {
- return GetEmbeddedResourceStream().CopyToAsync(stream);
- }
-
- protected abstract Stream GetEmbeddedResourceStream();
- }
-}
diff --git a/MediaBrowser.Common/Net/Handlers/BaseHandler.cs b/MediaBrowser.Common/Net/Handlers/BaseHandler.cs index a5058e6caf..95c86e6f7a 100644 --- a/MediaBrowser.Common/Net/Handlers/BaseHandler.cs +++ b/MediaBrowser.Common/Net/Handlers/BaseHandler.cs @@ -1,430 +1,826 @@ -using MediaBrowser.Common.Logging;
-using System;
-using System.Collections;
-using System.Collections.Generic;
-using System.Collections.Specialized;
-using System.IO;
-using System.IO.Compression;
-using System.Linq;
-using System.Net;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Common.Net.Handlers
-{
- public abstract class BaseHandler
- {
- public abstract bool HandlesRequest(HttpListenerRequest request);
-
- private Stream CompressedStream { get; set; }
-
- public virtual bool? UseChunkedEncoding
- {
- get
- {
- return null;
- }
- }
-
- private bool _totalContentLengthDiscovered;
- private long? _totalContentLength;
- public long? TotalContentLength
- {
- get
- {
- if (!_totalContentLengthDiscovered)
- {
- _totalContentLength = GetTotalContentLength();
- _totalContentLengthDiscovered = true;
- }
-
- return _totalContentLength;
- }
- }
-
- protected virtual bool SupportsByteRangeRequests
- {
- get
- {
- return false;
- }
- }
-
- /// <summary>
- /// The original HttpListenerContext
- /// </summary>
- protected HttpListenerContext HttpListenerContext { get; set; }
-
- /// <summary>
- /// The original QueryString
- /// </summary>
- protected NameValueCollection QueryString
- {
- get
- {
- return HttpListenerContext.Request.QueryString;
- }
- }
-
- private List<KeyValuePair<long, long?>> _requestedRanges;
- protected IEnumerable<KeyValuePair<long, long?>> RequestedRanges
- {
- get
- {
- if (_requestedRanges == null)
- {
- _requestedRanges = new List<KeyValuePair<long, long?>>();
-
- if (IsRangeRequest)
- {
- // Example: bytes=0-,32-63
- string[] ranges = HttpListenerContext.Request.Headers["Range"].Split('=')[1].Split(',');
-
- foreach (string range in ranges)
- {
- string[] vals = range.Split('-');
-
- long start = 0;
- long? end = null;
-
- if (!string.IsNullOrEmpty(vals[0]))
- {
- start = long.Parse(vals[0]);
- }
- if (!string.IsNullOrEmpty(vals[1]))
- {
- end = long.Parse(vals[1]);
- }
-
- _requestedRanges.Add(new KeyValuePair<long, long?>(start, end));
- }
- }
- }
-
- return _requestedRanges;
- }
- }
-
- protected bool IsRangeRequest
- {
- get
- {
- return HttpListenerContext.Request.Headers.AllKeys.Contains("Range");
- }
- }
-
- private bool ClientSupportsCompression
- {
- get
- {
- string enc = HttpListenerContext.Request.Headers["Accept-Encoding"] ?? string.Empty;
-
- return enc.IndexOf("deflate", StringComparison.OrdinalIgnoreCase) != -1 || enc.IndexOf("gzip", StringComparison.OrdinalIgnoreCase) != -1;
- }
- }
-
- private string CompressionMethod
- {
- get
- {
- string enc = HttpListenerContext.Request.Headers["Accept-Encoding"] ?? string.Empty;
-
- if (enc.IndexOf("deflate", StringComparison.OrdinalIgnoreCase) != -1)
- {
- return "deflate";
- }
- if (enc.IndexOf("gzip", StringComparison.OrdinalIgnoreCase) != -1)
- {
- return "gzip";
- }
-
- return null;
- }
- }
-
- public virtual async Task ProcessRequest(HttpListenerContext ctx)
- {
- HttpListenerContext = ctx;
-
- string url = ctx.Request.Url.ToString();
- Logger.LogInfo("Http Server received request at: " + url);
- Logger.LogInfo("Http Headers: " + string.Join(",", ctx.Request.Headers.AllKeys.Select(k => k + "=" + ctx.Request.Headers[k])));
-
- ctx.Response.AddHeader("Access-Control-Allow-Origin", "*");
-
- ctx.Response.KeepAlive = true;
-
- try
- {
- if (SupportsByteRangeRequests && IsRangeRequest)
- {
- ctx.Response.Headers["Accept-Ranges"] = "bytes";
- }
-
- ResponseInfo responseInfo = await GetResponseInfo().ConfigureAwait(false);
-
- if (responseInfo.IsResponseValid)
- {
- // Set the initial status code
- // When serving a range request, we need to return status code 206 to indicate a partial response body
- responseInfo.StatusCode = SupportsByteRangeRequests && IsRangeRequest ? 206 : 200;
- }
-
- ctx.Response.ContentType = responseInfo.ContentType;
-
- if (!string.IsNullOrEmpty(responseInfo.Etag))
- {
- ctx.Response.Headers["ETag"] = responseInfo.Etag;
- }
-
- if (ctx.Request.Headers.AllKeys.Contains("If-Modified-Since"))
- {
- DateTime ifModifiedSince;
-
- if (DateTime.TryParse(ctx.Request.Headers["If-Modified-Since"], out ifModifiedSince))
- {
- // If the cache hasn't expired yet just return a 304
- if (IsCacheValid(ifModifiedSince.ToUniversalTime(), responseInfo.CacheDuration, responseInfo.DateLastModified))
- {
- // ETag must also match (if supplied)
- if ((responseInfo.Etag ?? string.Empty).Equals(ctx.Request.Headers["If-None-Match"] ?? string.Empty))
- {
- responseInfo.StatusCode = 304;
- }
- }
- }
- }
-
- Logger.LogInfo("Responding with status code {0} for url {1}", responseInfo.StatusCode, url);
-
- if (responseInfo.IsResponseValid)
- {
- await ProcessUncachedRequest(ctx, responseInfo).ConfigureAwait(false);
- }
- else
- {
- ctx.Response.StatusCode = responseInfo.StatusCode;
- ctx.Response.SendChunked = false;
- }
- }
- catch (Exception ex)
- {
- // It might be too late if some response data has already been transmitted, but try to set this
- ctx.Response.StatusCode = 500;
-
- Logger.LogException(ex);
- }
- finally
- {
- DisposeResponseStream();
- }
- }
-
- private async Task ProcessUncachedRequest(HttpListenerContext ctx, ResponseInfo responseInfo)
- {
- long? totalContentLength = TotalContentLength;
-
- // By default, use chunked encoding if we don't know the content length
- bool useChunkedEncoding = UseChunkedEncoding == null ? (totalContentLength == null) : UseChunkedEncoding.Value;
-
- // Don't force this to true. HttpListener will default it to true if supported by the client.
- if (!useChunkedEncoding)
- {
- ctx.Response.SendChunked = false;
- }
-
- // Set the content length, if we know it
- if (totalContentLength.HasValue)
- {
- ctx.Response.ContentLength64 = totalContentLength.Value;
- }
-
- var compressResponse = responseInfo.CompressResponse && ClientSupportsCompression;
-
- // Add the compression header
- if (compressResponse)
- {
- ctx.Response.AddHeader("Content-Encoding", CompressionMethod);
- }
-
- if (responseInfo.DateLastModified.HasValue)
- {
- ctx.Response.Headers[HttpResponseHeader.LastModified] = responseInfo.DateLastModified.Value.ToString("r");
- }
-
- // Add caching headers
- if (responseInfo.CacheDuration.Ticks > 0)
- {
- CacheResponse(ctx.Response, responseInfo.CacheDuration);
- }
-
- // Set the status code
- ctx.Response.StatusCode = responseInfo.StatusCode;
-
- if (responseInfo.IsResponseValid)
- {
- // Finally, write the response data
- Stream outputStream = ctx.Response.OutputStream;
-
- if (compressResponse)
- {
- if (CompressionMethod.Equals("deflate", StringComparison.OrdinalIgnoreCase))
- {
- CompressedStream = new DeflateStream(outputStream, CompressionLevel.Fastest, false);
- }
- else
- {
- CompressedStream = new GZipStream(outputStream, CompressionLevel.Fastest, false);
- }
-
- outputStream = CompressedStream;
- }
-
- await WriteResponseToOutputStream(outputStream).ConfigureAwait(false);
- }
- else
- {
- ctx.Response.SendChunked = false;
- }
- }
-
- private void CacheResponse(HttpListenerResponse response, TimeSpan duration)
- {
- response.Headers[HttpResponseHeader.CacheControl] = "public, max-age=" + Convert.ToInt32(duration.TotalSeconds);
- response.Headers[HttpResponseHeader.Expires] = DateTime.UtcNow.Add(duration).ToString("r");
- }
-
- protected abstract Task WriteResponseToOutputStream(Stream stream);
-
- protected virtual void DisposeResponseStream()
- {
- if (CompressedStream != null)
- {
- CompressedStream.Dispose();
- }
-
- HttpListenerContext.Response.OutputStream.Dispose();
- }
-
- private bool IsCacheValid(DateTime ifModifiedSince, TimeSpan cacheDuration, DateTime? dateModified)
- {
- if (dateModified.HasValue)
- {
- DateTime lastModified = NormalizeDateForComparison(dateModified.Value);
- ifModifiedSince = NormalizeDateForComparison(ifModifiedSince);
-
- return lastModified <= ifModifiedSince;
- }
-
- DateTime cacheExpirationDate = ifModifiedSince.Add(cacheDuration);
-
- if (DateTime.UtcNow < cacheExpirationDate)
- {
- return true;
- }
-
- return false;
- }
-
- /// <summary>
- /// When the browser sends the IfModifiedDate, it's precision is limited to seconds, so this will account for that
- /// </summary>
- private DateTime NormalizeDateForComparison(DateTime date)
- {
- return new DateTime(date.Year, date.Month, date.Day, date.Hour, date.Minute, date.Second, date.Kind);
- }
-
- protected virtual long? GetTotalContentLength()
- {
- return null;
- }
-
- protected abstract Task<ResponseInfo> GetResponseInfo();
-
- private Hashtable _formValues;
-
- /// <summary>
- /// Gets a value from form POST data
- /// </summary>
- protected async Task<string> GetFormValue(string name)
- {
- if (_formValues == null)
- {
- _formValues = await GetFormValues(HttpListenerContext.Request).ConfigureAwait(false);
- }
-
- if (_formValues.ContainsKey(name))
- {
- return _formValues[name].ToString();
- }
-
- return null;
- }
-
- /// <summary>
- /// Extracts form POST data from a request
- /// </summary>
- private async Task<Hashtable> GetFormValues(HttpListenerRequest request)
- {
- var formVars = new Hashtable();
-
- if (request.HasEntityBody)
- {
- if (request.ContentType.IndexOf("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) != -1)
- {
- using (Stream requestBody = request.InputStream)
- {
- using (var reader = new StreamReader(requestBody, request.ContentEncoding))
- {
- string s = await reader.ReadToEndAsync().ConfigureAwait(false);
-
- string[] pairs = s.Split('&');
-
- for (int x = 0; x < pairs.Length; x++)
- {
- string pair = pairs[x];
-
- int index = pair.IndexOf('=');
-
- if (index != -1)
- {
- string name = pair.Substring(0, index);
- string value = pair.Substring(index + 1);
- formVars.Add(name, value);
- }
- }
- }
- }
- }
- }
-
- return formVars;
- }
- }
-
- public class ResponseInfo
- {
- public string ContentType { get; set; }
- public string Etag { get; set; }
- public DateTime? DateLastModified { get; set; }
- public TimeSpan CacheDuration { get; set; }
- public bool CompressResponse { get; set; }
- public int StatusCode { get; set; }
-
- public ResponseInfo()
- {
- CacheDuration = TimeSpan.FromTicks(0);
-
- CompressResponse = true;
-
- StatusCode = 200;
- }
-
- public bool IsResponseValid
- {
- get
- {
- return StatusCode == 200 || StatusCode == 206;
- }
- }
- }
+using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Kernel; +using MediaBrowser.Common.Logging; +using MediaBrowser.Model.Logging; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using System.Web; + +namespace MediaBrowser.Common.Net.Handlers +{ + /// <summary> + /// Class BaseHandler + /// </summary> + public abstract class BaseHandler<TKernelType> : IHttpServerHandler + where TKernelType : IKernel + { + /// <summary> + /// Gets the logger. + /// </summary> + /// <value>The logger.</value> + protected ILogger Logger { get; private set; } + + /// <summary> + /// Initializes the specified kernel. + /// </summary> + /// <param name="kernel">The kernel.</param> + public void Initialize(IKernel kernel) + { + Kernel = (TKernelType)kernel; + Logger = SharedLogger.Logger; + } + + /// <summary> + /// Gets or sets the kernel. + /// </summary> + /// <value>The kernel.</value> + protected TKernelType Kernel { get; private set; } + + /// <summary> + /// Gets the URL suffix used to determine if this handler can process a request. + /// </summary> + /// <value>The URL suffix.</value> + protected virtual string UrlSuffix + { + get + { + var name = GetType().Name; + + const string srch = "Handler"; + + if (name.EndsWith(srch, StringComparison.OrdinalIgnoreCase)) + { + name = name.Substring(0, name.Length - srch.Length); + } + + return "api/" + name; + } + } + + /// <summary> + /// Handleses the request. + /// </summary> + /// <param name="request">The request.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + public virtual bool HandlesRequest(HttpListenerRequest request) + { + var name = '/' + UrlSuffix.TrimStart('/'); + + var url = Kernel.WebApplicationName + name; + + return request.Url.LocalPath.EndsWith(url, StringComparison.OrdinalIgnoreCase); + } + + /// <summary> + /// Gets or sets the compressed stream. + /// </summary> + /// <value>The compressed stream.</value> + private Stream CompressedStream { get; set; } + + /// <summary> + /// Gets a value indicating whether [use chunked encoding]. + /// </summary> + /// <value><c>null</c> if [use chunked encoding] contains no value, <c>true</c> if [use chunked encoding]; otherwise, <c>false</c>.</value> + public virtual bool? UseChunkedEncoding + { + get + { + return null; + } + } + + /// <summary> + /// The original HttpListenerContext + /// </summary> + /// <value>The HTTP listener context.</value> + protected HttpListenerContext HttpListenerContext { get; set; } + + /// <summary> + /// The _query string + /// </summary> + private NameValueCollection _queryString; + /// <summary> + /// The original QueryString + /// </summary> + /// <value>The query string.</value> + public NameValueCollection QueryString + { + get + { + // HttpListenerContext.Request.QueryString is not decoded properly + return _queryString ?? (_queryString = HttpUtility.ParseQueryString(HttpListenerContext.Request.Url.Query)); + } + } + + /// <summary> + /// The _requested ranges + /// </summary> + private List<KeyValuePair<long, long?>> _requestedRanges; + /// <summary> + /// Gets the requested ranges. + /// </summary> + /// <value>The requested ranges.</value> + protected IEnumerable<KeyValuePair<long, long?>> RequestedRanges + { + get + { + if (_requestedRanges == null) + { + _requestedRanges = new List<KeyValuePair<long, long?>>(); + + if (IsRangeRequest) + { + // Example: bytes=0-,32-63 + var ranges = HttpListenerContext.Request.Headers["Range"].Split('=')[1].Split(','); + + foreach (var range in ranges) + { + var vals = range.Split('-'); + + long start = 0; + long? end = null; + + if (!string.IsNullOrEmpty(vals[0])) + { + start = long.Parse(vals[0]); + } + if (!string.IsNullOrEmpty(vals[1])) + { + end = long.Parse(vals[1]); + } + + _requestedRanges.Add(new KeyValuePair<long, long?>(start, end)); + } + } + } + + return _requestedRanges; + } + } + + /// <summary> + /// Gets a value indicating whether this instance is range request. + /// </summary> + /// <value><c>true</c> if this instance is range request; otherwise, <c>false</c>.</value> + protected bool IsRangeRequest + { + get + { + return HttpListenerContext.Request.Headers.AllKeys.Contains("Range"); + } + } + + /// <summary> + /// Gets a value indicating whether [client supports compression]. + /// </summary> + /// <value><c>true</c> if [client supports compression]; otherwise, <c>false</c>.</value> + protected bool ClientSupportsCompression + { + get + { + var enc = HttpListenerContext.Request.Headers["Accept-Encoding"] ?? string.Empty; + + return enc.Equals("*", StringComparison.OrdinalIgnoreCase) || + enc.IndexOf("deflate", StringComparison.OrdinalIgnoreCase) != -1 || + enc.IndexOf("gzip", StringComparison.OrdinalIgnoreCase) != -1; + } + } + + /// <summary> + /// Gets the compression method. + /// </summary> + /// <value>The compression method.</value> + private string CompressionMethod + { + get + { + var enc = HttpListenerContext.Request.Headers["Accept-Encoding"] ?? string.Empty; + + if (enc.IndexOf("deflate", StringComparison.OrdinalIgnoreCase) != -1 || enc.Equals("*", StringComparison.OrdinalIgnoreCase)) + { + return "deflate"; + } + if (enc.IndexOf("gzip", StringComparison.OrdinalIgnoreCase) != -1) + { + return "gzip"; + } + + return null; + } + } + + /// <summary> + /// Processes the request. + /// </summary> + /// <param name="ctx">The CTX.</param> + /// <returns>Task.</returns> + public virtual async Task ProcessRequest(HttpListenerContext ctx) + { + HttpListenerContext = ctx; + + ctx.Response.AddHeader("Access-Control-Allow-Origin", "*"); + + ctx.Response.KeepAlive = true; + + try + { + await ProcessRequestInternal(ctx).ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + HandleException(ctx.Response, ex, 422); + + throw; + } + catch (ResourceNotFoundException ex) + { + HandleException(ctx.Response, ex, 404); + + throw; + } + catch (FileNotFoundException ex) + { + HandleException(ctx.Response, ex, 404); + + throw; + } + catch (DirectoryNotFoundException ex) + { + HandleException(ctx.Response, ex, 404); + + throw; + } + catch (UnauthorizedAccessException ex) + { + HandleException(ctx.Response, ex, 401); + + throw; + } + catch (ArgumentException ex) + { + HandleException(ctx.Response, ex, 400); + + throw; + } + catch (Exception ex) + { + HandleException(ctx.Response, ex, 500); + + throw; + } + finally + { + DisposeResponseStream(); + } + } + + /// <summary> + /// Appends the error message. + /// </summary> + /// <param name="response">The response.</param> + /// <param name="ex">The ex.</param> + /// <param name="statusCode">The status code.</param> + private void HandleException(HttpListenerResponse response, Exception ex, int statusCode) + { + response.StatusCode = statusCode; + + response.Headers.Add("Status", statusCode.ToString(new CultureInfo("en-US"))); + + response.Headers.Remove("Age"); + response.Headers.Remove("Expires"); + response.Headers.Remove("Cache-Control"); + response.Headers.Remove("Etag"); + response.Headers.Remove("Last-Modified"); + + response.ContentType = "text/plain"; + + Logger.ErrorException("Error processing request", ex); + + if (!string.IsNullOrEmpty(ex.Message)) + { + response.AddHeader("X-Application-Error-Code", ex.Message); + } + + var bytes = Encoding.UTF8.GetBytes(ex.Message); + + var stream = CompressedStream ?? response.OutputStream; + + // This could fail, but try to add the stack trace as the body content + try + { + stream.Write(bytes, 0, bytes.Length); + } + catch (Exception ex1) + { + Logger.ErrorException("Error dumping stack trace", ex1); + } + } + + /// <summary> + /// Processes the request internal. + /// </summary> + /// <param name="ctx">The CTX.</param> + /// <returns>Task.</returns> + private async Task ProcessRequestInternal(HttpListenerContext ctx) + { + var responseInfo = await GetResponseInfo().ConfigureAwait(false); + + // Let the client know if byte range requests are supported or not + if (responseInfo.SupportsByteRangeRequests) + { + ctx.Response.Headers["Accept-Ranges"] = "bytes"; + } + else if (!responseInfo.SupportsByteRangeRequests) + { + ctx.Response.Headers["Accept-Ranges"] = "none"; + } + + if (responseInfo.IsResponseValid && responseInfo.SupportsByteRangeRequests && IsRangeRequest) + { + // Set the initial status code + // When serving a range request, we need to return status code 206 to indicate a partial response body + responseInfo.StatusCode = 206; + } + + ctx.Response.ContentType = responseInfo.ContentType; + + if (responseInfo.Etag.HasValue) + { + ctx.Response.Headers["ETag"] = responseInfo.Etag.Value.ToString("N"); + } + + var isCacheValid = true; + + // Validate If-Modified-Since + if (ctx.Request.Headers.AllKeys.Contains("If-Modified-Since")) + { + DateTime ifModifiedSince; + + if (DateTime.TryParse(ctx.Request.Headers["If-Modified-Since"], out ifModifiedSince)) + { + isCacheValid = IsCacheValid(ifModifiedSince.ToUniversalTime(), responseInfo.CacheDuration, + responseInfo.DateLastModified); + } + } + + // Validate If-None-Match + if (isCacheValid && + (responseInfo.Etag.HasValue || !string.IsNullOrEmpty(ctx.Request.Headers["If-None-Match"]))) + { + Guid ifNoneMatch; + + if (Guid.TryParse(ctx.Request.Headers["If-None-Match"] ?? string.Empty, out ifNoneMatch)) + { + if (responseInfo.Etag.HasValue && responseInfo.Etag.Value == ifNoneMatch) + { + responseInfo.StatusCode = 304; + } + } + } + + LogResponse(ctx, responseInfo); + + if (responseInfo.IsResponseValid) + { + await OnProcessingRequest(responseInfo).ConfigureAwait(false); + } + + if (responseInfo.IsResponseValid) + { + await ProcessUncachedRequest(ctx, responseInfo).ConfigureAwait(false); + } + else + { + if (responseInfo.StatusCode == 304) + { + AddAgeHeader(ctx.Response, responseInfo); + AddExpiresHeader(ctx.Response, responseInfo); + } + + ctx.Response.StatusCode = responseInfo.StatusCode; + ctx.Response.SendChunked = false; + } + } + + /// <summary> + /// The _null task result + /// </summary> + private readonly Task<bool> _nullTaskResult = Task.FromResult(true); + + /// <summary> + /// Called when [processing request]. + /// </summary> + /// <param name="responseInfo">The response info.</param> + /// <returns>Task.</returns> + protected virtual Task OnProcessingRequest(ResponseInfo responseInfo) + { + return _nullTaskResult; + } + + /// <summary> + /// Logs the response. + /// </summary> + /// <param name="ctx">The CTX.</param> + /// <param name="responseInfo">The response info.</param> + private void LogResponse(HttpListenerContext ctx, ResponseInfo responseInfo) + { + // Don't log normal 200's + if (responseInfo.StatusCode == 200) + { + return; + } + + var log = new StringBuilder(); + + log.AppendLine(string.Format("Url: {0}", ctx.Request.Url)); + + log.AppendLine("Headers: " + string.Join(",", ctx.Response.Headers.AllKeys.Select(k => k + "=" + ctx.Response.Headers[k]))); + + var msg = "Http Response Sent (" + responseInfo.StatusCode + ") to " + ctx.Request.RemoteEndPoint; + + if (Kernel.Configuration.EnableHttpLevelLogging) + { + Logger.LogMultiline(msg, LogSeverity.Debug, log); + } + } + + /// <summary> + /// Processes the uncached request. + /// </summary> + /// <param name="ctx">The CTX.</param> + /// <param name="responseInfo">The response info.</param> + /// <returns>Task.</returns> + private async Task ProcessUncachedRequest(HttpListenerContext ctx, ResponseInfo responseInfo) + { + var totalContentLength = GetTotalContentLength(responseInfo); + + // By default, use chunked encoding if we don't know the content length + var useChunkedEncoding = UseChunkedEncoding == null ? (totalContentLength == null) : UseChunkedEncoding.Value; + + // Don't force this to true. HttpListener will default it to true if supported by the client. + if (!useChunkedEncoding) + { + ctx.Response.SendChunked = false; + } + + // Set the content length, if we know it + if (totalContentLength.HasValue) + { + ctx.Response.ContentLength64 = totalContentLength.Value; + } + + var compressResponse = responseInfo.CompressResponse && ClientSupportsCompression; + + // Add the compression header + if (compressResponse) + { + ctx.Response.AddHeader("Content-Encoding", CompressionMethod); + ctx.Response.AddHeader("Vary", "Accept-Encoding"); + } + + // 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 (responseInfo.DateLastModified.HasValue && (!responseInfo.Etag.HasValue || responseInfo.CacheDuration.Ticks > 0)) + { + ctx.Response.Headers[HttpResponseHeader.LastModified] = responseInfo.DateLastModified.Value.ToString("r"); + AddAgeHeader(ctx.Response, responseInfo); + } + + // Add caching headers + ConfigureCaching(ctx.Response, responseInfo); + + // Set the status code + ctx.Response.StatusCode = responseInfo.StatusCode; + + if (responseInfo.IsResponseValid) + { + // Finally, write the response data + var outputStream = ctx.Response.OutputStream; + + if (compressResponse) + { + if (CompressionMethod.Equals("deflate", StringComparison.OrdinalIgnoreCase)) + { + CompressedStream = new DeflateStream(outputStream, CompressionLevel.Fastest, true); + } + else + { + CompressedStream = new GZipStream(outputStream, CompressionLevel.Fastest, true); + } + + outputStream = CompressedStream; + } + + await WriteResponseToOutputStream(outputStream, responseInfo, totalContentLength).ConfigureAwait(false); + } + else + { + ctx.Response.SendChunked = false; + } + } + + /// <summary> + /// Configures the caching. + /// </summary> + /// <param name="response">The response.</param> + /// <param name="responseInfo">The response info.</param> + private void ConfigureCaching(HttpListenerResponse response, ResponseInfo responseInfo) + { + if (responseInfo.CacheDuration.Ticks > 0) + { + response.Headers[HttpResponseHeader.CacheControl] = "public, max-age=" + Convert.ToInt32(responseInfo.CacheDuration.TotalSeconds); + } + else if (responseInfo.Etag.HasValue) + { + response.Headers[HttpResponseHeader.CacheControl] = "public"; + } + else + { + response.Headers[HttpResponseHeader.CacheControl] = "no-cache, no-store, must-revalidate"; + response.Headers[HttpResponseHeader.Pragma] = "no-cache, no-store, must-revalidate"; + } + + AddExpiresHeader(response, responseInfo); + } + + /// <summary> + /// Adds the expires header. + /// </summary> + /// <param name="response">The response.</param> + /// <param name="responseInfo">The response info.</param> + private void AddExpiresHeader(HttpListenerResponse response, ResponseInfo responseInfo) + { + if (responseInfo.CacheDuration.Ticks > 0) + { + response.Headers[HttpResponseHeader.Expires] = DateTime.UtcNow.Add(responseInfo.CacheDuration).ToString("r"); + } + else if (!responseInfo.Etag.HasValue) + { + response.Headers[HttpResponseHeader.Expires] = "-1"; + } + } + + /// <summary> + /// Adds the age header. + /// </summary> + /// <param name="response">The response.</param> + /// <param name="responseInfo">The response info.</param> + private void AddAgeHeader(HttpListenerResponse response, ResponseInfo responseInfo) + { + if (responseInfo.DateLastModified.HasValue) + { + response.Headers[HttpResponseHeader.Age] = Convert.ToInt32((DateTime.UtcNow - responseInfo.DateLastModified.Value).TotalSeconds).ToString(CultureInfo.InvariantCulture); + } + } + + /// <summary> + /// Writes the response to output stream. + /// </summary> + /// <param name="stream">The stream.</param> + /// <param name="responseInfo">The response info.</param> + /// <param name="contentLength">Length of the content.</param> + /// <returns>Task.</returns> + protected abstract Task WriteResponseToOutputStream(Stream stream, ResponseInfo responseInfo, long? contentLength); + + /// <summary> + /// Disposes the response stream. + /// </summary> + protected virtual void DisposeResponseStream() + { + if (CompressedStream != null) + { + try + { + CompressedStream.Dispose(); + } + catch (Exception ex) + { + Logger.ErrorException("Error disposing compressed stream", ex); + } + } + + try + { + //HttpListenerContext.Response.OutputStream.Dispose(); + HttpListenerContext.Response.Close(); + } + catch (Exception ex) + { + Logger.ErrorException("Error disposing response", ex); + } + } + + /// <summary> + /// Determines whether [is cache valid] [the specified if modified since]. + /// </summary> + /// <param name="ifModifiedSince">If modified since.</param> + /// <param name="cacheDuration">Duration of the cache.</param> + /// <param name="dateModified">The date modified.</param> + /// <returns><c>true</c> if [is cache valid] [the specified if modified since]; otherwise, <c>false</c>.</returns> + private bool IsCacheValid(DateTime ifModifiedSince, TimeSpan cacheDuration, DateTime? dateModified) + { + if (dateModified.HasValue) + { + DateTime lastModified = NormalizeDateForComparison(dateModified.Value); + ifModifiedSince = NormalizeDateForComparison(ifModifiedSince); + + return lastModified <= ifModifiedSince; + } + + DateTime cacheExpirationDate = ifModifiedSince.Add(cacheDuration); + + if (DateTime.UtcNow < cacheExpirationDate) + { + return true; + } + + return false; + } + + /// <summary> + /// When the browser sends the IfModifiedDate, it's precision is limited to seconds, so this will account for that + /// </summary> + /// <param name="date">The date.</param> + /// <returns>DateTime.</returns> + private DateTime NormalizeDateForComparison(DateTime date) + { + return new DateTime(date.Year, date.Month, date.Day, date.Hour, date.Minute, date.Second, date.Kind); + } + + /// <summary> + /// Gets the total length of the content. + /// </summary> + /// <param name="responseInfo">The response info.</param> + /// <returns>System.Nullable{System.Int64}.</returns> + protected virtual long? GetTotalContentLength(ResponseInfo responseInfo) + { + return null; + } + + /// <summary> + /// Gets the response info. + /// </summary> + /// <returns>Task{ResponseInfo}.</returns> + protected abstract Task<ResponseInfo> GetResponseInfo(); + + /// <summary> + /// Gets a bool query string param. + /// </summary> + /// <param name="name">The name.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + protected bool GetBoolQueryStringParam(string name) + { + var val = QueryString[name] ?? string.Empty; + + return val.Equals("1", StringComparison.OrdinalIgnoreCase) || val.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + /// <summary> + /// The _form values + /// </summary> + private Hashtable _formValues; + + /// <summary> + /// Gets a value from form POST data + /// </summary> + /// <param name="name">The name.</param> + /// <returns>Task{System.String}.</returns> + protected async Task<string> GetFormValue(string name) + { + if (_formValues == null) + { + _formValues = await GetFormValues(HttpListenerContext.Request).ConfigureAwait(false); + } + + if (_formValues.ContainsKey(name)) + { + return _formValues[name].ToString(); + } + + return null; + } + + /// <summary> + /// Extracts form POST data from a request + /// </summary> + /// <param name="request">The request.</param> + /// <returns>Task{Hashtable}.</returns> + private async Task<Hashtable> GetFormValues(HttpListenerRequest request) + { + var formVars = new Hashtable(); + + if (request.HasEntityBody) + { + if (request.ContentType.IndexOf("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) != -1) + { + using (var requestBody = request.InputStream) + { + using (var reader = new StreamReader(requestBody, request.ContentEncoding)) + { + var s = await reader.ReadToEndAsync().ConfigureAwait(false); + + var pairs = s.Split('&'); + + foreach (var pair in pairs) + { + var index = pair.IndexOf('='); + + if (index != -1) + { + var name = pair.Substring(0, index); + var value = pair.Substring(index + 1); + formVars.Add(name, value); + } + } + } + } + } + } + + return formVars; + } + } + + internal static class SharedLogger + { + /// <summary> + /// The logger + /// </summary> + internal static ILogger Logger = LogManager.GetLogger("Http Handler"); + } + + /// <summary> + /// Class ResponseInfo + /// </summary> + public class ResponseInfo + { + /// <summary> + /// Gets or sets the type of the content. + /// </summary> + /// <value>The type of the content.</value> + public string ContentType { get; set; } + /// <summary> + /// Gets or sets the etag. + /// </summary> + /// <value>The etag.</value> + public Guid? Etag { get; set; } + /// <summary> + /// Gets or sets the date last modified. + /// </summary> + /// <value>The date last modified.</value> + public DateTime? DateLastModified { get; set; } + /// <summary> + /// Gets or sets the duration of the cache. + /// </summary> + /// <value>The duration of the cache.</value> + public TimeSpan CacheDuration { get; set; } + /// <summary> + /// Gets or sets a value indicating whether [compress response]. + /// </summary> + /// <value><c>true</c> if [compress response]; otherwise, <c>false</c>.</value> + public bool CompressResponse { get; set; } + /// <summary> + /// Gets or sets the status code. + /// </summary> + /// <value>The status code.</value> + public int StatusCode { get; set; } + /// <summary> + /// Gets or sets a value indicating whether [supports byte range requests]. + /// </summary> + /// <value><c>true</c> if [supports byte range requests]; otherwise, <c>false</c>.</value> + public bool SupportsByteRangeRequests { get; set; } + + /// <summary> + /// Initializes a new instance of the <see cref="ResponseInfo" /> class. + /// </summary> + public ResponseInfo() + { + CacheDuration = TimeSpan.FromTicks(0); + + CompressResponse = true; + + StatusCode = 200; + } + + /// <summary> + /// Gets a value indicating whether this instance is response valid. + /// </summary> + /// <value><c>true</c> if this instance is response valid; otherwise, <c>false</c>.</value> + public bool IsResponseValid + { + get + { + return StatusCode >= 200 && StatusCode < 300; + } + } + } }
\ No newline at end of file diff --git a/MediaBrowser.Common/Net/Handlers/BaseSerializationHandler.cs b/MediaBrowser.Common/Net/Handlers/BaseSerializationHandler.cs index 53b3ee817f..293cb6e98a 100644 --- a/MediaBrowser.Common/Net/Handlers/BaseSerializationHandler.cs +++ b/MediaBrowser.Common/Net/Handlers/BaseSerializationHandler.cs @@ -1,90 +1,134 @@ -using MediaBrowser.Common.Serialization;
-using System;
-using System.IO;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Common.Net.Handlers
-{
- public abstract class BaseSerializationHandler<T> : BaseHandler
- where T : class
- {
- public SerializationFormat SerializationFormat
- {
- get
- {
- string format = QueryString["dataformat"];
-
- if (string.IsNullOrEmpty(format))
- {
- return SerializationFormat.Json;
- }
-
- return (SerializationFormat)Enum.Parse(typeof(SerializationFormat), format, true);
- }
- }
-
- protected string ContentType
- {
- get
- {
- switch (SerializationFormat)
- {
- case SerializationFormat.Jsv:
- return "text/plain";
- case SerializationFormat.Protobuf:
- return "application/x-protobuf";
- default:
- return MimeTypes.JsonMimeType;
- }
- }
- }
-
- protected override async Task<ResponseInfo> GetResponseInfo()
- {
- ResponseInfo info = new ResponseInfo
- {
- ContentType = ContentType
- };
-
- _objectToSerialize = await GetObjectToSerialize().ConfigureAwait(false);
-
- if (_objectToSerialize == null)
- {
- info.StatusCode = 404;
- }
-
- return info;
- }
-
- private T _objectToSerialize;
-
- protected abstract Task<T> GetObjectToSerialize();
-
- protected override Task WriteResponseToOutputStream(Stream stream)
- {
- return Task.Run(() =>
- {
- switch (SerializationFormat)
- {
- case SerializationFormat.Jsv:
- JsvSerializer.SerializeToStream(_objectToSerialize, stream);
- break;
- case SerializationFormat.Protobuf:
- ProtobufSerializer.SerializeToStream(_objectToSerialize, stream);
- break;
- default:
- JsonSerializer.SerializeToStream(_objectToSerialize, stream);
- break;
- }
- });
- }
- }
-
- public enum SerializationFormat
- {
- Json,
- Jsv,
- Protobuf
- }
-
-}
+using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Kernel; +using MediaBrowser.Common.Serialization; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace MediaBrowser.Common.Net.Handlers +{ + /// <summary> + /// Class BaseSerializationHandler + /// </summary> + /// <typeparam name="TKernelType">The type of the T kernel type.</typeparam> + /// <typeparam name="T"></typeparam> + public abstract class BaseSerializationHandler<TKernelType, T> : BaseHandler<TKernelType> + where TKernelType : IKernel + where T : class + { + /// <summary> + /// Gets the serialization format. + /// </summary> + /// <value>The serialization format.</value> + public SerializationFormat SerializationFormat + { + get + { + var format = QueryString["dataformat"]; + + if (string.IsNullOrEmpty(format)) + { + return SerializationFormat.Json; + } + + return (SerializationFormat)Enum.Parse(typeof(SerializationFormat), format, true); + } + } + + /// <summary> + /// Gets the type of the content. + /// </summary> + /// <value>The type of the content.</value> + protected string ContentType + { + get + { + switch (SerializationFormat) + { + case SerializationFormat.Protobuf: + return "application/x-protobuf"; + default: + return MimeTypes.JsonMimeType; + } + } + } + + /// <summary> + /// Gets the response info. + /// </summary> + /// <returns>Task{ResponseInfo}.</returns> + protected override Task<ResponseInfo> GetResponseInfo() + { + return Task.FromResult(new ResponseInfo + { + ContentType = ContentType + }); + } + + /// <summary> + /// Called when [processing request]. + /// </summary> + /// <param name="responseInfo">The response info.</param> + /// <returns>Task.</returns> + protected override async Task OnProcessingRequest(ResponseInfo responseInfo) + { + _objectToSerialize = await GetObjectToSerialize().ConfigureAwait(false); + + if (_objectToSerialize == null) + { + throw new ResourceNotFoundException(); + } + + await base.OnProcessingRequest(responseInfo).ConfigureAwait(false); + } + + /// <summary> + /// The _object to serialize + /// </summary> + private T _objectToSerialize; + + /// <summary> + /// Gets the object to serialize. + /// </summary> + /// <returns>Task{`0}.</returns> + protected abstract Task<T> GetObjectToSerialize(); + + /// <summary> + /// Writes the response to output stream. + /// </summary> + /// <param name="stream">The stream.</param> + /// <param name="responseInfo">The response info.</param> + /// <param name="contentLength">Length of the content.</param> + /// <returns>Task.</returns> + protected override Task WriteResponseToOutputStream(Stream stream, ResponseInfo responseInfo, long? contentLength) + { + return Task.Run(() => + { + switch (SerializationFormat) + { + case SerializationFormat.Protobuf: + Kernel.ProtobufSerializer.SerializeToStream(_objectToSerialize, stream); + break; + default: + JsonSerializer.SerializeToStream(_objectToSerialize, stream); + break; + } + }); + } + } + + /// <summary> + /// Enum SerializationFormat + /// </summary> + public enum SerializationFormat + { + /// <summary> + /// The json + /// </summary> + Json, + /// <summary> + /// The protobuf + /// </summary> + Protobuf + } +} diff --git a/MediaBrowser.Common/Net/Handlers/IHttpServerHandler.cs b/MediaBrowser.Common/Net/Handlers/IHttpServerHandler.cs new file mode 100644 index 0000000000..dadd614737 --- /dev/null +++ b/MediaBrowser.Common/Net/Handlers/IHttpServerHandler.cs @@ -0,0 +1,32 @@ +using MediaBrowser.Common.Kernel; +using System.Net; +using System.Threading.Tasks; + +namespace MediaBrowser.Common.Net.Handlers +{ + /// <summary> + /// Interface IHttpServerHandler + /// </summary> + public interface IHttpServerHandler + { + /// <summary> + /// Initializes the specified kernel. + /// </summary> + /// <param name="kernel">The kernel.</param> + void Initialize(IKernel kernel); + + /// <summary> + /// Handleses the request. + /// </summary> + /// <param name="request">The request.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + bool HandlesRequest(HttpListenerRequest request); + + /// <summary> + /// Processes the request. + /// </summary> + /// <param name="ctx">The CTX.</param> + /// <returns>Task.</returns> + Task ProcessRequest(HttpListenerContext ctx); + } +} diff --git a/MediaBrowser.Common/Net/Handlers/StaticFileHandler.cs b/MediaBrowser.Common/Net/Handlers/StaticFileHandler.cs index 11438b164b..3967d15c35 100644 --- a/MediaBrowser.Common/Net/Handlers/StaticFileHandler.cs +++ b/MediaBrowser.Common/Net/Handlers/StaticFileHandler.cs @@ -1,249 +1,264 @@ -using MediaBrowser.Common.Logging;
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Net;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Common.Net.Handlers
-{
- public class StaticFileHandler : BaseHandler
- {
- public override bool HandlesRequest(HttpListenerRequest request)
- {
- return false;
- }
-
- private string _path;
- public virtual string Path
- {
- get
- {
- if (!string.IsNullOrWhiteSpace(_path))
- {
- return _path;
- }
-
- return QueryString["path"];
- }
- set
- {
- _path = value;
- }
- }
-
- private Stream SourceStream { get; set; }
-
- protected override bool SupportsByteRangeRequests
- {
- get
- {
- return true;
- }
- }
-
- private bool ShouldCompressResponse(string contentType)
- {
- // Can't compress these
- if (IsRangeRequest)
- {
- return false;
- }
-
- // Don't compress media
- if (contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) || contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- // It will take some work to support compression within this handler
- return false;
- }
-
- protected override long? GetTotalContentLength()
- {
- return SourceStream.Length;
- }
-
- protected override Task<ResponseInfo> GetResponseInfo()
- {
- ResponseInfo info = new ResponseInfo
- {
- ContentType = MimeTypes.GetMimeType(Path),
- };
-
- try
- {
- SourceStream = File.OpenRead(Path);
- }
- catch (FileNotFoundException ex)
- {
- info.StatusCode = 404;
- Logger.LogException(ex);
- }
- catch (DirectoryNotFoundException ex)
- {
- info.StatusCode = 404;
- Logger.LogException(ex);
- }
- catch (UnauthorizedAccessException ex)
- {
- info.StatusCode = 403;
- Logger.LogException(ex);
- }
-
- info.CompressResponse = ShouldCompressResponse(info.ContentType);
-
- if (SourceStream != null)
- {
- info.DateLastModified = File.GetLastWriteTimeUtc(Path);
- }
-
- return Task.FromResult<ResponseInfo>(info);
- }
-
- protected override Task WriteResponseToOutputStream(Stream stream)
- {
- if (IsRangeRequest)
- {
- KeyValuePair<long, long?> requestedRange = RequestedRanges.First();
-
- // If the requested range is "0-" and we know the total length, we can optimize by avoiding having to buffer the content into memory
- if (requestedRange.Value == null && TotalContentLength != null)
- {
- return ServeCompleteRangeRequest(requestedRange, stream);
- }
- if (TotalContentLength.HasValue)
- {
- // This will have to buffer a portion of the content into memory
- return ServePartialRangeRequestWithKnownTotalContentLength(requestedRange, stream);
- }
-
- // This will have to buffer the entire content into memory
- return ServePartialRangeRequestWithUnknownTotalContentLength(requestedRange, stream);
- }
-
- return SourceStream.CopyToAsync(stream);
- }
-
- protected override void DisposeResponseStream()
- {
- base.DisposeResponseStream();
-
- if (SourceStream != null)
- {
- SourceStream.Dispose();
- }
- }
-
- /// <summary>
- /// Handles a range request of "bytes=0-"
- /// This will serve the complete content and add the content-range header
- /// </summary>
- private Task ServeCompleteRangeRequest(KeyValuePair<long, long?> requestedRange, Stream responseStream)
- {
- long totalContentLength = TotalContentLength.Value;
-
- long rangeStart = requestedRange.Key;
- long rangeEnd = totalContentLength - 1;
- long rangeLength = 1 + rangeEnd - rangeStart;
-
- // Content-Length is the length of what we're serving, not the original content
- HttpListenerContext.Response.ContentLength64 = rangeLength;
- HttpListenerContext.Response.Headers["Content-Range"] = string.Format("bytes {0}-{1}/{2}", rangeStart, rangeEnd, totalContentLength);
-
- if (rangeStart > 0)
- {
- SourceStream.Position = rangeStart;
- }
-
- return SourceStream.CopyToAsync(responseStream);
- }
-
- /// <summary>
- /// Serves a partial range request where the total content length is not known
- /// </summary>
- private async Task ServePartialRangeRequestWithUnknownTotalContentLength(KeyValuePair<long, long?> requestedRange, Stream responseStream)
- {
- // Read the entire stream so that we can determine the length
- byte[] bytes = await ReadBytes(SourceStream, 0, null).ConfigureAwait(false);
-
- long totalContentLength = bytes.LongLength;
-
- long rangeStart = requestedRange.Key;
- long rangeEnd = requestedRange.Value ?? (totalContentLength - 1);
- long rangeLength = 1 + rangeEnd - rangeStart;
-
- // Content-Length is the length of what we're serving, not the original content
- HttpListenerContext.Response.ContentLength64 = rangeLength;
- HttpListenerContext.Response.Headers["Content-Range"] = string.Format("bytes {0}-{1}/{2}", rangeStart, rangeEnd, totalContentLength);
-
- await responseStream.WriteAsync(bytes, Convert.ToInt32(rangeStart), Convert.ToInt32(rangeLength)).ConfigureAwait(false);
- }
-
- /// <summary>
- /// Serves a partial range request where the total content length is already known
- /// </summary>
- private async Task ServePartialRangeRequestWithKnownTotalContentLength(KeyValuePair<long, long?> requestedRange, Stream responseStream)
- {
- long totalContentLength = TotalContentLength.Value;
- long rangeStart = requestedRange.Key;
- long rangeEnd = requestedRange.Value ?? (totalContentLength - 1);
- long rangeLength = 1 + rangeEnd - rangeStart;
-
- // Only read the bytes we need
- byte[] bytes = await ReadBytes(SourceStream, Convert.ToInt32(rangeStart), Convert.ToInt32(rangeLength)).ConfigureAwait(false);
-
- // Content-Length is the length of what we're serving, not the original content
- HttpListenerContext.Response.ContentLength64 = rangeLength;
-
- HttpListenerContext.Response.Headers["Content-Range"] = string.Format("bytes {0}-{1}/{2}", rangeStart, rangeEnd, totalContentLength);
-
- await responseStream.WriteAsync(bytes, 0, Convert.ToInt32(rangeLength)).ConfigureAwait(false);
- }
-
- /// <summary>
- /// Reads bytes from a stream
- /// </summary>
- /// <param name="input">The input stream</param>
- /// <param name="start">The starting position</param>
- /// <param name="count">The number of bytes to read, or null to read to the end.</param>
- private async Task<byte[]> ReadBytes(Stream input, int start, int? count)
- {
- if (start > 0)
- {
- input.Position = start;
- }
-
- if (count == null)
- {
- var buffer = new byte[16 * 1024];
-
- using (var ms = new MemoryStream())
- {
- int read;
- while ((read = await input.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0)
- {
- await ms.WriteAsync(buffer, 0, read).ConfigureAwait(false);
- }
- return ms.ToArray();
- }
- }
- else
- {
- var buffer = new byte[count.Value];
-
- using (var ms = new MemoryStream())
- {
- int read = await input.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false);
-
- await ms.WriteAsync(buffer, 0, read).ConfigureAwait(false);
-
- return ms.ToArray();
- }
- }
-
- }
- }
-}
+using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Kernel; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace MediaBrowser.Common.Net.Handlers +{ + /// <summary> + /// Represents an http handler that serves static content + /// </summary> + public class StaticFileHandler : BaseHandler<IKernel> + { + /// <summary> + /// Initializes a new instance of the <see cref="StaticFileHandler" /> class. + /// </summary> + /// <param name="kernel">The kernel.</param> + public StaticFileHandler(IKernel kernel) + { + Initialize(kernel); + } + + /// <summary> + /// The _path + /// </summary> + private string _path; + /// <summary> + /// Gets or sets the path to the static resource + /// </summary> + /// <value>The path.</value> + public string Path + { + get + { + if (!string.IsNullOrWhiteSpace(_path)) + { + return _path; + } + + return QueryString["path"]; + } + set + { + _path = value; + } + } + + /// <summary> + /// Gets or sets the last date modified of the resource + /// </summary> + /// <value>The last date modified.</value> + public DateTime? LastDateModified { get; set; } + + /// <summary> + /// Gets or sets the content type of the resource + /// </summary> + /// <value>The type of the content.</value> + public string ContentType { get; set; } + + /// <summary> + /// Gets or sets the content type of the resource + /// </summary> + /// <value>The etag.</value> + public Guid Etag { get; set; } + + /// <summary> + /// Gets or sets the source stream of the resource + /// </summary> + /// <value>The source stream.</value> + public Stream SourceStream { get; set; } + + /// <summary> + /// Shoulds the compress response. + /// </summary> + /// <param name="contentType">Type of the content.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + private bool ShouldCompressResponse(string contentType) + { + // It will take some work to support compression with byte range requests + if (IsRangeRequest) + { + 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; + } + + return true; + } + + /// <summary> + /// Gets or sets the duration of the cache. + /// </summary> + /// <value>The duration of the cache.</value> + public TimeSpan? CacheDuration { get; set; } + + /// <summary> + /// Gets the total length of the content. + /// </summary> + /// <param name="responseInfo">The response info.</param> + /// <returns>System.Nullable{System.Int64}.</returns> + protected override long? GetTotalContentLength(ResponseInfo responseInfo) + { + // If we're compressing the response, content length must be the compressed length, which we don't know + if (responseInfo.CompressResponse && ClientSupportsCompression) + { + return null; + } + + return SourceStream.Length; + } + + /// <summary> + /// Gets the response info. + /// </summary> + /// <returns>Task{ResponseInfo}.</returns> + protected override Task<ResponseInfo> GetResponseInfo() + { + var info = new ResponseInfo + { + ContentType = ContentType ?? MimeTypes.GetMimeType(Path), + Etag = Etag, + DateLastModified = LastDateModified + }; + + if (SourceStream == null && !string.IsNullOrEmpty(Path)) + { + // FileShare must be ReadWrite in case someone else is currently writing to it. + SourceStream = new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous); + } + + info.CompressResponse = ShouldCompressResponse(info.ContentType); + + info.SupportsByteRangeRequests = !info.CompressResponse || !ClientSupportsCompression; + + if (!info.DateLastModified.HasValue && !string.IsNullOrWhiteSpace(Path)) + { + info.DateLastModified = File.GetLastWriteTimeUtc(Path); + } + + if (CacheDuration.HasValue) + { + info.CacheDuration = CacheDuration.Value; + } + + if (SourceStream == null && string.IsNullOrEmpty(Path)) + { + throw new ResourceNotFoundException(); + } + + return Task.FromResult(info); + } + + /// <summary> + /// Writes the response to output stream. + /// </summary> + /// <param name="stream">The stream.</param> + /// <param name="responseInfo">The response info.</param> + /// <param name="totalContentLength">Total length of the content.</param> + /// <returns>Task.</returns> + protected override Task WriteResponseToOutputStream(Stream stream, ResponseInfo responseInfo, long? totalContentLength) + { + if (IsRangeRequest && totalContentLength.HasValue) + { + var requestedRange = RequestedRanges.First(); + + // If the requested range is "0-", we can optimize by just doing a stream copy + if (!requestedRange.Value.HasValue) + { + return ServeCompleteRangeRequest(requestedRange, stream, totalContentLength.Value); + } + + // This will have to buffer a portion of the content into memory + return ServePartialRangeRequest(requestedRange.Key, requestedRange.Value.Value, stream, totalContentLength.Value); + } + + return SourceStream.CopyToAsync(stream); + } + + /// <summary> + /// Disposes the response stream. + /// </summary> + protected override void DisposeResponseStream() + { + if (SourceStream != null) + { + SourceStream.Dispose(); + } + + base.DisposeResponseStream(); + } + + /// <summary> + /// Handles a range request of "bytes=0-" + /// This will serve the complete content and add the content-range header + /// </summary> + /// <param name="requestedRange">The requested range.</param> + /// <param name="responseStream">The response stream.</param> + /// <param name="totalContentLength">Total length of the content.</param> + /// <returns>Task.</returns> + private Task ServeCompleteRangeRequest(KeyValuePair<long, long?> requestedRange, Stream responseStream, long totalContentLength) + { + var rangeStart = requestedRange.Key; + var rangeEnd = totalContentLength - 1; + var rangeLength = 1 + rangeEnd - rangeStart; + + // Content-Length is the length of what we're serving, not the original content + HttpListenerContext.Response.ContentLength64 = rangeLength; + HttpListenerContext.Response.Headers["Content-Range"] = string.Format("bytes {0}-{1}/{2}", rangeStart, rangeEnd, totalContentLength); + + if (rangeStart > 0) + { + SourceStream.Position = rangeStart; + } + + return SourceStream.CopyToAsync(responseStream); + } + + /// <summary> + /// Serves a partial range request + /// </summary> + /// <param name="rangeStart">The range start.</param> + /// <param name="rangeEnd">The range end.</param> + /// <param name="responseStream">The response stream.</param> + /// <param name="totalContentLength">Total length of the content.</param> + /// <returns>Task.</returns> + private async Task ServePartialRangeRequest(long rangeStart, long rangeEnd, Stream responseStream, long totalContentLength) + { + var rangeLength = 1 + rangeEnd - rangeStart; + + // Content-Length is the length of what we're serving, not the original content + HttpListenerContext.Response.ContentLength64 = rangeLength; + HttpListenerContext.Response.Headers["Content-Range"] = string.Format("bytes {0}-{1}/{2}", rangeStart, rangeEnd, totalContentLength); + + SourceStream.Position = rangeStart; + + // Fast track to just copy the stream to the end + if (rangeEnd == totalContentLength - 1) + { + await SourceStream.CopyToAsync(responseStream).ConfigureAwait(false); + } + else + { + // Read the bytes we need + var buffer = new byte[Convert.ToInt32(rangeLength)]; + await SourceStream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + + await responseStream.WriteAsync(buffer, 0, Convert.ToInt32(rangeLength)).ConfigureAwait(false); + } + } + } +} diff --git a/MediaBrowser.Common/Net/HttpManager.cs b/MediaBrowser.Common/Net/HttpManager.cs new file mode 100644 index 0000000000..3a87a89514 --- /dev/null +++ b/MediaBrowser.Common/Net/HttpManager.cs @@ -0,0 +1,452 @@ +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Kernel; +using MediaBrowser.Model.Net; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Cache; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Class HttpManager + /// </summary> + public class HttpManager : BaseManager<IKernel> + { + /// <summary> + /// Initializes a new instance of the <see cref="HttpManager" /> class. + /// </summary> + /// <param name="kernel">The kernel.</param> + public HttpManager(IKernel kernel) + : base(kernel) + { + } + + /// <summary> + /// Holds a dictionary of http clients by host. Use GetHttpClient(host) to retrieve or create a client for web requests. + /// DON'T dispose it after use. + /// </summary> + /// <value>The HTTP clients.</value> + private readonly ConcurrentDictionary<string, HttpClient> _httpClients = new ConcurrentDictionary<string, HttpClient>(); + + /// <summary> + /// Gets + /// </summary> + /// <param name="host">The host.</param> + /// <returns>HttpClient.</returns> + /// <exception cref="System.ArgumentNullException">host</exception> + private HttpClient GetHttpClient(string host) + { + if (string.IsNullOrEmpty(host)) + { + throw new ArgumentNullException("host"); + } + + HttpClient client; + if (!_httpClients.TryGetValue(host, out client)) + { + var handler = new WebRequestHandler + { + AutomaticDecompression = DecompressionMethods.Deflate, + CachePolicy = new RequestCachePolicy(RequestCacheLevel.Revalidate) + }; + + client = new HttpClient(handler); + client.DefaultRequestHeaders.Add("Accept", "application/json,image/*"); + client.Timeout = TimeSpan.FromSeconds(15); + _httpClients.TryAdd(host, client); + } + + return client; + } + + /// <summary> + /// Performs a GET request and returns the resulting stream + /// </summary> + /// <param name="url">The URL.</param> + /// <param name="resourcePool">The resource pool.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{Stream}.</returns> + /// <exception cref="MediaBrowser.Model.Net.HttpException"></exception> + public async Task<Stream> Get(string url, SemaphoreSlim resourcePool, CancellationToken cancellationToken) + { + ValidateParams(url, resourcePool, cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + + await resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); + + Logger.Info("HttpManager.Get url: {0}", url); + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + var msg = await GetHttpClient(GetHostFromUrl(url)).GetAsync(url, cancellationToken).ConfigureAwait(false); + + EnsureSuccessStatusCode(msg); + + return await msg.Content.ReadAsStreamAsync().ConfigureAwait(false); + } + catch (OperationCanceledException ex) + { + throw GetCancellationException(url, cancellationToken, ex); + } + catch (HttpRequestException ex) + { + Logger.ErrorException("Error getting response from " + url, ex); + + throw new HttpException(ex.Message, ex); + } + finally + { + resourcePool.Release(); + } + } + + /// <summary> + /// Performs a POST request + /// </summary> + /// <param name="url">The URL.</param> + /// <param name="postData">Params to add to the POST data.</param> + /// <param name="resourcePool">The resource pool.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>stream on success, null on failure</returns> + /// <exception cref="System.ArgumentNullException">postData</exception> + /// <exception cref="MediaBrowser.Model.Net.HttpException"></exception> + public async Task<Stream> Post(string url, Dictionary<string, string> postData, SemaphoreSlim resourcePool, CancellationToken cancellationToken) + { + ValidateParams(url, resourcePool, cancellationToken); + + if (postData == null) + { + throw new ArgumentNullException("postData"); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var strings = postData.Keys.Select(key => string.Format("{0}={1}", key, postData[key])); + var postContent = string.Join("&", strings.ToArray()); + var content = new StringContent(postContent, Encoding.UTF8, "application/x-www-form-urlencoded"); + + await resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); + + Logger.Info("HttpManager.Post url: {0}", url); + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + var msg = await GetHttpClient(GetHostFromUrl(url)).PostAsync(url, content, cancellationToken).ConfigureAwait(false); + + EnsureSuccessStatusCode(msg); + + return await msg.Content.ReadAsStreamAsync().ConfigureAwait(false); + } + catch (OperationCanceledException ex) + { + throw GetCancellationException(url, cancellationToken, ex); + } + catch (HttpRequestException ex) + { + Logger.ErrorException("Error getting response from " + url, ex); + + throw new HttpException(ex.Message, ex); + } + finally + { + resourcePool.Release(); + } + } + + /// <summary> + /// Downloads the contents of a given url into a temporary location + /// </summary> + /// <param name="url">The URL.</param> + /// <param name="resourcePool">The resource pool.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <param name="userAgent">The user agent.</param> + /// <returns>Task{System.String}.</returns> + /// <exception cref="System.ArgumentNullException">progress</exception> + /// <exception cref="MediaBrowser.Model.Net.HttpException"></exception> + public async Task<string> FetchToTempFile(string url, SemaphoreSlim resourcePool, CancellationToken cancellationToken, IProgress<double> progress, string userAgent = null) + { + ValidateParams(url, resourcePool, cancellationToken); + + if (progress == null) + { + throw new ArgumentNullException("progress"); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var tempFile = Path.Combine(Kernel.ApplicationPaths.TempDirectory, Guid.NewGuid() + ".tmp"); + + var message = new HttpRequestMessage(HttpMethod.Get, url); + + if (!string.IsNullOrEmpty(userAgent)) + { + message.Headers.Add("User-Agent", userAgent); + } + + await resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); + + Logger.Info("HttpManager.FetchToTempFile url: {0}, temp file: {1}", url, tempFile); + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + using (var response = await GetHttpClient(GetHostFromUrl(url)).SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false)) + { + EnsureSuccessStatusCode(response); + + cancellationToken.ThrowIfCancellationRequested(); + + IEnumerable<string> lengthValues; + + if (!response.Headers.TryGetValues("content-length", out lengthValues) && + !response.Content.Headers.TryGetValues("content-length", out lengthValues)) + { + // We're not able to track progress + using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) + { + using (var fs = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous)) + { + await stream.CopyToAsync(fs, StreamDefaults.DefaultCopyToBufferSize, cancellationToken).ConfigureAwait(false); + } + } + } + else + { + var length = long.Parse(string.Join(string.Empty, lengthValues.ToArray())); + + using (var stream = ProgressStream.CreateReadProgressStream(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), progress.Report, length)) + { + using (var fs = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous)) + { + await stream.CopyToAsync(fs, StreamDefaults.DefaultCopyToBufferSize, cancellationToken).ConfigureAwait(false); + } + } + } + + progress.Report(100); + + cancellationToken.ThrowIfCancellationRequested(); + } + + return tempFile; + } + catch (OperationCanceledException ex) + { + // Cleanup + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + + throw GetCancellationException(url, cancellationToken, ex); + } + catch (HttpRequestException ex) + { + Logger.ErrorException("Error getting response from " + url, ex); + + // Cleanup + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + + throw new HttpException(ex.Message, ex); + } + catch (Exception ex) + { + Logger.ErrorException("Error getting response from " + url, ex); + + // Cleanup + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + + throw; + } + finally + { + resourcePool.Release(); + } + } + + /// <summary> + /// Downloads the contents of a given url into a MemoryStream + /// </summary> + /// <param name="url">The URL.</param> + /// <param name="resourcePool">The resource pool.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{MemoryStream}.</returns> + /// <exception cref="MediaBrowser.Model.Net.HttpException"></exception> + public async Task<MemoryStream> FetchToMemoryStream(string url, SemaphoreSlim resourcePool, CancellationToken cancellationToken) + { + ValidateParams(url, resourcePool, cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + + var message = new HttpRequestMessage(HttpMethod.Get, url); + + await resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); + + var ms = new MemoryStream(); + + Logger.Info("HttpManager.FetchToMemoryStream url: {0}", url); + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + using (var response = await GetHttpClient(GetHostFromUrl(url)).SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false)) + { + EnsureSuccessStatusCode(response); + + cancellationToken.ThrowIfCancellationRequested(); + + using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) + { + await stream.CopyToAsync(ms, StreamDefaults.DefaultCopyToBufferSize, cancellationToken).ConfigureAwait(false); + } + + cancellationToken.ThrowIfCancellationRequested(); + } + + ms.Position = 0; + + return ms; + } + catch (OperationCanceledException ex) + { + ms.Dispose(); + + throw GetCancellationException(url, cancellationToken, ex); + } + catch (HttpRequestException ex) + { + Logger.ErrorException("Error getting response from " + url, ex); + + ms.Dispose(); + + throw new HttpException(ex.Message, ex); + } + catch (Exception ex) + { + Logger.ErrorException("Error getting response from " + url, ex); + + ms.Dispose(); + + throw; + } + finally + { + resourcePool.Release(); + } + } + + /// <summary> + /// Validates the params. + /// </summary> + /// <param name="url">The URL.</param> + /// <param name="resourcePool">The resource pool.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <exception cref="System.ArgumentNullException">url</exception> + private void ValidateParams(string url, SemaphoreSlim resourcePool, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(url)) + { + throw new ArgumentNullException("url"); + } + + if (resourcePool == null) + { + throw new ArgumentNullException("resourcePool"); + } + + if (cancellationToken == null) + { + throw new ArgumentNullException("cancellationToken"); + } + } + + /// <summary> + /// Gets the host from URL. + /// </summary> + /// <param name="url">The URL.</param> + /// <returns>System.String.</returns> + private string GetHostFromUrl(string url) + { + var start = url.IndexOf("://", StringComparison.OrdinalIgnoreCase) + 3; + var len = url.IndexOf('/', start) - start; + return url.Substring(start, len); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected override void Dispose(bool dispose) + { + if (dispose) + { + foreach (var client in _httpClients.Values.ToList()) + { + client.Dispose(); + } + + _httpClients.Clear(); + } + + base.Dispose(dispose); + } + + /// <summary> + /// Throws the cancellation exception. + /// </summary> + /// <param name="url">The URL.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="exception">The exception.</param> + /// <returns>Exception.</returns> + private Exception GetCancellationException(string url, CancellationToken cancellationToken, OperationCanceledException exception) + { + // If the HttpClient's timeout is reached, it will cancel the Task internally + if (!cancellationToken.IsCancellationRequested) + { + var msg = string.Format("Connection to {0} timed out", url); + + Logger.Error(msg); + + // Throw an HttpException so that the caller doesn't think it was cancelled by user code + return new HttpException(msg, exception) { IsTimedOut = true }; + } + + return exception; + } + + /// <summary> + /// Ensures the success status code. + /// </summary> + /// <param name="response">The response.</param> + /// <exception cref="MediaBrowser.Model.Net.HttpException"></exception> + private void EnsureSuccessStatusCode(HttpResponseMessage response) + { + if (!response.IsSuccessStatusCode) + { + throw new HttpException(response.ReasonPhrase) { StatusCode = response.StatusCode }; + } + } + } +} diff --git a/MediaBrowser.Common/Net/HttpServer.cs b/MediaBrowser.Common/Net/HttpServer.cs index 276e14eb31..efd6d9d325 100644 --- a/MediaBrowser.Common/Net/HttpServer.cs +++ b/MediaBrowser.Common/Net/HttpServer.cs @@ -1,40 +1,444 @@ -using System;
-using System.Net;
-using System.Reactive.Linq;
-
-namespace MediaBrowser.Common.Net
-{
- public class HttpServer : IObservable<HttpListenerContext>, IDisposable
- {
- private readonly HttpListener _listener;
- private readonly IObservable<HttpListenerContext> _stream;
-
- public HttpServer(string url)
- {
- _listener = new HttpListener();
- _listener.Prefixes.Add(url);
- _listener.Start();
- _stream = ObservableHttpContext();
- }
-
- private IObservable<HttpListenerContext> ObservableHttpContext()
- {
- return Observable.Create<HttpListenerContext>(obs =>
- Observable.FromAsync(() => _listener.GetContextAsync())
- .Subscribe(obs))
- .Repeat()
- .Retry()
- .Publish()
- .RefCount();
- }
- public void Dispose()
- {
- _listener.Stop();
- }
-
- public IDisposable Subscribe(IObserver<HttpListenerContext> observer)
- {
- return _stream.Subscribe(observer);
- }
- }
+using Funq; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Kernel; +using MediaBrowser.Model.Logging; +using ServiceStack.Api.Swagger; +using ServiceStack.Common.Web; +using ServiceStack.Logging; +using ServiceStack.Logging.NLogger; +using ServiceStack.ServiceHost; +using ServiceStack.ServiceInterface.Cors; +using ServiceStack.Text; +using ServiceStack.WebHost.Endpoints; +using ServiceStack.WebHost.Endpoints.Extensions; +using ServiceStack.WebHost.Endpoints.Support; +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Reactive.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Class HttpServer + /// </summary> + public class HttpServer : HttpListenerBase + { + /// <summary> + /// The logger + /// </summary> + private static ILogger Logger = Logging.LogManager.GetLogger("HttpServer"); + + /// <summary> + /// Gets the URL prefix. + /// </summary> + /// <value>The URL prefix.</value> + public string UrlPrefix { get; private set; } + + /// <summary> + /// Gets or sets the kernel. + /// </summary> + /// <value>The kernel.</value> + private IKernel Kernel { get; set; } + + /// <summary> + /// This subscribes to HttpListener requests and finds the appropriate BaseHandler to process it + /// </summary> + /// <value>The HTTP listener.</value> + private IDisposable HttpListener { get; set; } + + /// <summary> + /// Occurs when [web socket connected]. + /// </summary> + public event EventHandler<WebSocketConnectEventArgs> WebSocketConnected; + + /// <summary> + /// Gets the default redirect path. + /// </summary> + /// <value>The default redirect path.</value> + public string DefaultRedirectPath { get; private set; } + + /// <summary> + /// Initializes a new instance of the <see cref="HttpServer" /> class. + /// </summary> + /// <param name="urlPrefix">The URL.</param> + /// <param name="serverName">Name of the product.</param> + /// <param name="kernel">The kernel.</param> + /// <param name="defaultRedirectpath">The default redirectpath.</param> + /// <exception cref="System.ArgumentNullException">urlPrefix</exception> + public HttpServer(string urlPrefix, string serverName, IKernel kernel, string defaultRedirectpath = null) + : base() + { + if (string.IsNullOrEmpty(urlPrefix)) + { + throw new ArgumentNullException("urlPrefix"); + } + + DefaultRedirectPath = defaultRedirectpath; + + EndpointHostConfig.Instance.ServiceStackHandlerFactoryPath = null; + EndpointHostConfig.Instance.MetadataRedirectPath = "metadata"; + + UrlPrefix = urlPrefix; + Kernel = kernel; + + EndpointHost.ConfigureHost(this, serverName, CreateServiceManager()); + + ContentTypeFilters.Register(ContentType.ProtoBuf, (reqCtx, res, stream) => Kernel.ProtobufSerializer.SerializeToStream(res, stream), (type, stream) => Kernel.ProtobufSerializer.DeserializeFromStream(stream, type)); + + Init(); + Start(urlPrefix); + } + + /// <summary> + /// Shut down the Web Service + /// </summary> + public override void Stop() + { + if (HttpListener != null) + { + HttpListener.Dispose(); + HttpListener = null; + } + + if (Listener != null) + { + Listener.Prefixes.Remove(UrlPrefix); + } + + base.Stop(); + } + + /// <summary> + /// Configures the specified container. + /// </summary> + /// <param name="container">The container.</param> + public override void Configure(Container container) + { + if (!string.IsNullOrEmpty(DefaultRedirectPath)) + { + SetConfig(new EndpointHostConfig + { + DefaultRedirectPath = DefaultRedirectPath, + + // Tell SS to bubble exceptions up to here + WriteErrorsToResponse = false, + + DebugMode = true + }); + } + + container.Register(Kernel); + + foreach (var service in Kernel.RestServices) + { + service.Configure(this); + } + + Plugins.Add(new SwaggerFeature()); + Plugins.Add(new CorsFeature()); + + Serialization.JsonSerializer.Configure(); + + LogManager.LogFactory = new NLogFactory(); + } + + /// <summary> + /// Starts the Web Service + /// </summary> + /// <param name="urlBase">A Uri that acts as the base that the server is listening on. + /// Format should be: http://127.0.0.1:8080/ or http://127.0.0.1:8080/somevirtual/ + /// Note: the trailing slash is required! For more info see the + /// HttpListener.Prefixes property on MSDN.</param> + public override void Start(string urlBase) + { + // *** Already running - just leave it in place + if (IsStarted) + { + return; + } + + if (Listener == null) + { + Listener = new HttpListener(); + } + + EndpointHost.Config.ServiceStackHandlerFactoryPath = HttpListenerRequestWrapper.GetHandlerPathIfAny(urlBase); + + Listener.Prefixes.Add(urlBase); + + IsStarted = true; + Listener.Start(); + + HttpListener = CreateObservableStream().Subscribe(ProcessHttpRequestAsync); + } + + /// <summary> + /// Creates the observable stream. + /// </summary> + /// <returns>IObservable{HttpListenerContext}.</returns> + private IObservable<HttpListenerContext> CreateObservableStream() + { + return Observable.Create<HttpListenerContext>(obs => + Observable.FromAsync(() => Listener.GetContextAsync()) + .Subscribe(obs)) + .Repeat() + .Retry() + .Publish() + .RefCount(); + } + + /// <summary> + /// Processes incoming http requests by routing them to the appropiate handler + /// </summary> + /// <param name="context">The CTX.</param> + private async void ProcessHttpRequestAsync(HttpListenerContext context) + { + LogHttpRequest(context); + + if (context.Request.IsWebSocketRequest) + { + await ProcessWebSocketRequest(context).ConfigureAwait(false); + return; + } + + RaiseReceiveWebRequest(context); + + Task.Run(() => + { + try + { + ProcessRequest(context); + } + catch (InvalidOperationException ex) + { + HandleException(context.Response, ex, 422); + + throw; + } + catch (ResourceNotFoundException ex) + { + HandleException(context.Response, ex, 404); + + throw; + } + catch (FileNotFoundException ex) + { + HandleException(context.Response, ex, 404); + + throw; + } + catch (DirectoryNotFoundException ex) + { + HandleException(context.Response, ex, 404); + + throw; + } + catch (UnauthorizedAccessException ex) + { + HandleException(context.Response, ex, 401); + + throw; + } + catch (ArgumentException ex) + { + HandleException(context.Response, ex, 400); + + throw; + } + catch (Exception ex) + { + HandleException(context.Response, ex, 500); + + throw; + } + + }); + } + + /// <summary> + /// Processes the web socket request. + /// </summary> + /// <param name="ctx">The CTX.</param> + /// <returns>Task.</returns> + private async Task ProcessWebSocketRequest(HttpListenerContext ctx) + { + try + { + var webSocketContext = await ctx.AcceptWebSocketAsync(null).ConfigureAwait(false); + + if (WebSocketConnected != null) + { + WebSocketConnected(this, new WebSocketConnectEventArgs { WebSocket = new NativeWebSocket(webSocketContext.WebSocket), Endpoint = ctx.Request.RemoteEndPoint }); + } + } + catch (Exception ex) + { + Logger.ErrorException("AcceptWebSocketAsync error", ex); + + ctx.Response.StatusCode = 500; + ctx.Response.Close(); + } + } + + /// <summary> + /// Logs the HTTP request. + /// </summary> + /// <param name="ctx">The CTX.</param> + private void LogHttpRequest(HttpListenerContext ctx) + { + var log = new StringBuilder(); + + log.AppendLine("Url: " + ctx.Request.Url); + log.AppendLine("Headers: " + string.Join(",", ctx.Request.Headers.AllKeys.Select(k => k + "=" + ctx.Request.Headers[k]))); + + var type = ctx.Request.IsWebSocketRequest ? "Web Socket" : "HTTP " + ctx.Request.HttpMethod; + + if (Kernel.Configuration.EnableHttpLevelLogging) + { + Logger.LogMultiline(type + " request received from " + ctx.Request.RemoteEndPoint, LogSeverity.Debug, log); + } + } + + /// <summary> + /// Appends the error message. + /// </summary> + /// <param name="response">The response.</param> + /// <param name="ex">The ex.</param> + /// <param name="statusCode">The status code.</param> + private void HandleException(HttpListenerResponse response, Exception ex, int statusCode) + { + Logger.ErrorException("Error processing request", ex); + + response.StatusCode = statusCode; + + response.Headers.Add("Status", statusCode.ToString(new CultureInfo("en-US"))); + + response.Headers.Remove("Age"); + response.Headers.Remove("Expires"); + response.Headers.Remove("Cache-Control"); + response.Headers.Remove("Etag"); + response.Headers.Remove("Last-Modified"); + + response.ContentType = "text/plain"; + + if (!string.IsNullOrEmpty(ex.Message)) + { + response.AddHeader("X-Application-Error-Code", ex.Message); + } + + // This could fail, but try to add the stack trace as the body content + try + { + var sb = new StringBuilder(); + sb.AppendLine("{"); + sb.AppendLine("\"ResponseStatus\":{"); + sb.AppendFormat(" \"ErrorCode\":{0},\n", ex.GetType().Name.EncodeJson()); + sb.AppendFormat(" \"Message\":{0},\n", ex.Message.EncodeJson()); + sb.AppendFormat(" \"StackTrace\":{0}\n", ex.StackTrace.EncodeJson()); + sb.AppendLine("}"); + sb.AppendLine("}"); + + response.StatusCode = 500; + response.ContentType = ContentType.Json; + var sbBytes = sb.ToString().ToUtf8Bytes(); + response.OutputStream.Write(sbBytes, 0, sbBytes.Length); + response.Close(); + } + catch (Exception errorEx) + { + Logger.ErrorException("Error processing failed request", errorEx); + } + } + + + /// <summary> + /// Overridable method that can be used to implement a custom hnandler + /// </summary> + /// <param name="context">The context.</param> + /// <exception cref="System.NotImplementedException">Cannot execute handler: + handler + at PathInfo: + httpReq.PathInfo</exception> + protected override void ProcessRequest(HttpListenerContext context) + { + if (string.IsNullOrEmpty(context.Request.RawUrl)) return; + + var operationName = context.Request.GetOperationName(); + + var httpReq = new HttpListenerRequestWrapper(operationName, context.Request); + var httpRes = new HttpListenerResponseWrapper(context.Response); + var handler = ServiceStackHttpHandlerFactory.GetHandler(httpReq); + + var serviceStackHandler = handler as IServiceStackHttpHandler; + + if (serviceStackHandler != null) + { + var restHandler = serviceStackHandler as RestHandler; + if (restHandler != null) + { + httpReq.OperationName = operationName = restHandler.RestPath.RequestType.Name; + } + serviceStackHandler.ProcessRequest(httpReq, httpRes, operationName); + LogResponse(context); + httpRes.Close(); + return; + } + + throw new NotImplementedException("Cannot execute handler: " + handler + " at PathInfo: " + httpReq.PathInfo); + } + + /// <summary> + /// Logs the response. + /// </summary> + /// <param name="ctx">The CTX.</param> + private void LogResponse(HttpListenerContext ctx) + { + var statusode = ctx.Response.StatusCode; + + var log = new StringBuilder(); + + log.AppendLine(string.Format("Url: {0}", ctx.Request.Url)); + + log.AppendLine("Headers: " + string.Join(",", ctx.Response.Headers.AllKeys.Select(k => k + "=" + ctx.Response.Headers[k]))); + + var msg = "Http Response Sent (" + statusode + ") to " + ctx.Request.RemoteEndPoint; + + if (Kernel.Configuration.EnableHttpLevelLogging) + { + Logger.LogMultiline(msg, LogSeverity.Debug, log); + } + } + + /// <summary> + /// Creates the service manager. + /// </summary> + /// <param name="assembliesWithServices">The assemblies with services.</param> + /// <returns>ServiceManager.</returns> + protected override ServiceManager CreateServiceManager(params Assembly[] assembliesWithServices) + { + var types = Kernel.RestServices.Select(r => r.GetType()).ToArray(); + + return new ServiceManager(new Container(), new ServiceController(() => types)); + } + } + + /// <summary> + /// Class WebSocketConnectEventArgs + /// </summary> + public class WebSocketConnectEventArgs : EventArgs + { + /// <summary> + /// Gets or sets the web socket. + /// </summary> + /// <value>The web socket.</value> + public IWebSocket WebSocket { get; set; } + /// <summary> + /// Gets or sets the endpoint. + /// </summary> + /// <value>The endpoint.</value> + public IPEndPoint Endpoint { get; set; } + } }
\ No newline at end of file diff --git a/MediaBrowser.Common/Net/IRestfulService.cs b/MediaBrowser.Common/Net/IRestfulService.cs new file mode 100644 index 0000000000..7fc6bb61ec --- /dev/null +++ b/MediaBrowser.Common/Net/IRestfulService.cs @@ -0,0 +1,14 @@ +using ServiceStack.ServiceHost; +using ServiceStack.WebHost.Endpoints; +using System; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Interface IRestfulService + /// </summary> + public interface IRestfulService : IService, IRequiresRequestContext, IDisposable + { + void Configure(IAppHost appHost); + } +} diff --git a/MediaBrowser.Common/Net/IWebSocket.cs b/MediaBrowser.Common/Net/IWebSocket.cs new file mode 100644 index 0000000000..96299e3b2e --- /dev/null +++ b/MediaBrowser.Common/Net/IWebSocket.cs @@ -0,0 +1,35 @@ +using System; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Interface IWebSocket + /// </summary> + public interface IWebSocket : IDisposable + { + /// <summary> + /// Gets or sets the state. + /// </summary> + /// <value>The state.</value> + WebSocketState State { get; } + + /// <summary> + /// Gets or sets the receive action. + /// </summary> + /// <value>The receive action.</value> + Action<WebSocketMessageInfo> OnReceiveDelegate { get; set; } + + /// <summary> + /// Sends the async. + /// </summary> + /// <param name="bytes">The bytes.</param> + /// <param name="type">The type.</param> + /// <param name="endOfMessage">if set to <c>true</c> [end of message].</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task SendAsync(byte[] bytes, WebSocketMessageType type, bool endOfMessage, CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Common/Net/MimeTypes.cs b/MediaBrowser.Common/Net/MimeTypes.cs index fb85b0f2a3..8f980200e7 100644 --- a/MediaBrowser.Common/Net/MimeTypes.cs +++ b/MediaBrowser.Common/Net/MimeTypes.cs @@ -1,160 +1,206 @@ -using System;
-using System.IO;
-
-namespace MediaBrowser.Common.Net
-{
- public static class MimeTypes
- {
- public static string JsonMimeType = "application/json";
-
- public static string GetMimeType(string path)
- {
- var ext = Path.GetExtension(path);
-
- // http://en.wikipedia.org/wiki/Internet_media_type
- // Add more as needed
-
- // Type video
- if (ext.EndsWith("mpg", StringComparison.OrdinalIgnoreCase) || ext.EndsWith("mpeg", StringComparison.OrdinalIgnoreCase))
- {
- return "video/mpeg";
- }
- if (ext.EndsWith("mp4", StringComparison.OrdinalIgnoreCase))
- {
- return "video/mp4";
- }
- if (ext.EndsWith("ogv", StringComparison.OrdinalIgnoreCase))
- {
- return "video/ogg";
- }
- if (ext.EndsWith("mov", StringComparison.OrdinalIgnoreCase))
- {
- return "video/quicktime";
- }
- if (ext.EndsWith("webm", StringComparison.OrdinalIgnoreCase))
- {
- return "video/webm";
- }
- if (ext.EndsWith("mkv", StringComparison.OrdinalIgnoreCase))
- {
- return "video/x-matroska";
- }
- if (ext.EndsWith("wmv", StringComparison.OrdinalIgnoreCase))
- {
- return "video/x-ms-wmv";
- }
- if (ext.EndsWith("flv", StringComparison.OrdinalIgnoreCase))
- {
- return "video/x-flv";
- }
- if (ext.EndsWith("avi", StringComparison.OrdinalIgnoreCase))
- {
- return "video/avi";
- }
- if (ext.EndsWith("m4v", StringComparison.OrdinalIgnoreCase))
- {
- return "video/x-m4v";
- }
- if (ext.EndsWith("asf", StringComparison.OrdinalIgnoreCase))
- {
- return "video/x-ms-asf";
- }
- if (ext.EndsWith("3gp", StringComparison.OrdinalIgnoreCase))
- {
- return "video/3gpp";
- }
- if (ext.EndsWith("3g2", StringComparison.OrdinalIgnoreCase))
- {
- return "video/3gpp2";
- }
- if (ext.EndsWith("ts", StringComparison.OrdinalIgnoreCase))
- {
- return "video/mp2t";
- }
-
- // Type text
- if (ext.EndsWith("css", StringComparison.OrdinalIgnoreCase))
- {
- return "text/css";
- }
- if (ext.EndsWith("csv", StringComparison.OrdinalIgnoreCase))
- {
- return "text/csv";
- }
- if (ext.EndsWith("html", StringComparison.OrdinalIgnoreCase) || ext.EndsWith("html", StringComparison.OrdinalIgnoreCase))
- {
- return "text/html";
- }
- if (ext.EndsWith("txt", StringComparison.OrdinalIgnoreCase))
- {
- return "text/plain";
- }
-
- // Type image
- if (ext.EndsWith("gif", StringComparison.OrdinalIgnoreCase))
- {
- return "image/gif";
- }
- if (ext.EndsWith("jpg", StringComparison.OrdinalIgnoreCase) || ext.EndsWith("jpeg", StringComparison.OrdinalIgnoreCase))
- {
- return "image/jpeg";
- }
- if (ext.EndsWith("png", StringComparison.OrdinalIgnoreCase))
- {
- return "image/png";
- }
- if (ext.EndsWith("ico", StringComparison.OrdinalIgnoreCase))
- {
- return "image/vnd.microsoft.icon";
- }
-
- // Type audio
- if (ext.EndsWith("mp3", StringComparison.OrdinalIgnoreCase))
- {
- return "audio/mpeg";
- }
- if (ext.EndsWith("m4a", StringComparison.OrdinalIgnoreCase) || ext.EndsWith("aac", StringComparison.OrdinalIgnoreCase))
- {
- return "audio/mp4";
- }
- if (ext.EndsWith("webma", StringComparison.OrdinalIgnoreCase))
- {
- return "audio/webm";
- }
- if (ext.EndsWith("wav", StringComparison.OrdinalIgnoreCase))
- {
- return "audio/wav";
- }
- if (ext.EndsWith("wma", StringComparison.OrdinalIgnoreCase))
- {
- return "audio/x-ms-wma";
- }
- if (ext.EndsWith("flac", StringComparison.OrdinalIgnoreCase))
- {
- return "audio/flac";
- }
- if (ext.EndsWith("aac", StringComparison.OrdinalIgnoreCase))
- {
- return "audio/x-aac";
- }
- if (ext.EndsWith("ogg", StringComparison.OrdinalIgnoreCase) || ext.EndsWith("oga", StringComparison.OrdinalIgnoreCase))
- {
- return "audio/ogg";
- }
-
- // Playlists
- if (ext.EndsWith("m3u8", StringComparison.OrdinalIgnoreCase))
- {
- return "application/x-mpegURL";
- }
-
- // Misc
- if (ext.EndsWith("dll", StringComparison.OrdinalIgnoreCase))
- {
- return "application/x-msdownload";
- }
-
- throw new InvalidOperationException("Argument not supported: " + path);
- }
- }
-}
+using System; +using System.IO; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Class MimeTypes + /// </summary> + public static class MimeTypes + { + /// <summary> + /// The json MIME type + /// </summary> + public static string JsonMimeType = "application/json"; + + /// <summary> + /// Gets the type of the MIME. + /// </summary> + /// <param name="path">The path.</param> + /// <returns>System.String.</returns> + /// <exception cref="System.ArgumentNullException">path</exception> + /// <exception cref="System.InvalidOperationException">Argument not supported: + path</exception> + public static string GetMimeType(string path) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentNullException("path"); + } + + var ext = Path.GetExtension(path) ?? string.Empty; + + // http://en.wikipedia.org/wiki/Internet_media_type + // Add more as needed + + // Type video + if (ext.Equals(".mpg", StringComparison.OrdinalIgnoreCase) || ext.EndsWith("mpeg", StringComparison.OrdinalIgnoreCase)) + { + return "video/mpeg"; + } + if (ext.Equals(".mp4", StringComparison.OrdinalIgnoreCase)) + { + return "video/mp4"; + } + if (ext.Equals(".ogv", StringComparison.OrdinalIgnoreCase)) + { + return "video/ogg"; + } + if (ext.Equals(".mov", StringComparison.OrdinalIgnoreCase)) + { + return "video/quicktime"; + } + if (ext.Equals(".webm", StringComparison.OrdinalIgnoreCase)) + { + return "video/webm"; + } + if (ext.Equals(".mkv", StringComparison.OrdinalIgnoreCase)) + { + return "video/x-matroska"; + } + if (ext.Equals(".wmv", StringComparison.OrdinalIgnoreCase)) + { + return "video/x-ms-wmv"; + } + if (ext.Equals(".flv", StringComparison.OrdinalIgnoreCase)) + { + return "video/x-flv"; + } + if (ext.Equals(".avi", StringComparison.OrdinalIgnoreCase)) + { + return "video/avi"; + } + if (ext.Equals(".m4v", StringComparison.OrdinalIgnoreCase)) + { + return "video/x-m4v"; + } + if (ext.EndsWith("asf", StringComparison.OrdinalIgnoreCase)) + { + return "video/x-ms-asf"; + } + if (ext.Equals(".3gp", StringComparison.OrdinalIgnoreCase)) + { + return "video/3gpp"; + } + if (ext.Equals(".3g2", StringComparison.OrdinalIgnoreCase)) + { + return "video/3gpp2"; + } + if (ext.Equals(".ts", StringComparison.OrdinalIgnoreCase)) + { + return "video/mp2t"; + } + + // Type text + if (ext.Equals(".css", StringComparison.OrdinalIgnoreCase)) + { + return "text/css"; + } + if (ext.Equals(".csv", StringComparison.OrdinalIgnoreCase)) + { + return "text/csv"; + } + if (ext.Equals(".html", StringComparison.OrdinalIgnoreCase) || ext.Equals(".htm", StringComparison.OrdinalIgnoreCase)) + { + return "text/html; charset=UTF-8"; + } + if (ext.Equals(".txt", StringComparison.OrdinalIgnoreCase)) + { + return "text/plain"; + } + if (ext.Equals(".xml", StringComparison.OrdinalIgnoreCase)) + { + return "application/xml"; + } + + // Type image + if (ext.Equals(".gif", StringComparison.OrdinalIgnoreCase)) + { + return "image/gif"; + } + if (ext.Equals(".jpg", StringComparison.OrdinalIgnoreCase) || ext.Equals(".jpeg", StringComparison.OrdinalIgnoreCase)) + { + return "image/jpeg"; + } + if (ext.Equals(".png", StringComparison.OrdinalIgnoreCase)) + { + return "image/png"; + } + if (ext.Equals(".ico", StringComparison.OrdinalIgnoreCase)) + { + return "image/vnd.microsoft.icon"; + } + + // Type audio + if (ext.Equals(".mp3", StringComparison.OrdinalIgnoreCase)) + { + return "audio/mpeg"; + } + if (ext.Equals(".m4a", StringComparison.OrdinalIgnoreCase) || ext.Equals(".aac", StringComparison.OrdinalIgnoreCase)) + { + return "audio/mp4"; + } + if (ext.Equals(".webma", StringComparison.OrdinalIgnoreCase)) + { + return "audio/webm"; + } + if (ext.Equals(".wav", StringComparison.OrdinalIgnoreCase)) + { + return "audio/wav"; + } + if (ext.Equals(".wma", StringComparison.OrdinalIgnoreCase)) + { + return "audio/x-ms-wma"; + } + if (ext.Equals(".flac", StringComparison.OrdinalIgnoreCase)) + { + return "audio/flac"; + } + if (ext.Equals(".aac", StringComparison.OrdinalIgnoreCase)) + { + return "audio/x-aac"; + } + if (ext.Equals(".ogg", StringComparison.OrdinalIgnoreCase) || ext.Equals(".oga", StringComparison.OrdinalIgnoreCase)) + { + return "audio/ogg"; + } + + // Playlists + if (ext.Equals(".m3u8", StringComparison.OrdinalIgnoreCase)) + { + return "application/x-mpegURL"; + } + + // Misc + if (ext.Equals(".dll", StringComparison.OrdinalIgnoreCase)) + { + return "application/x-msdownload"; + } + + // Web + if (ext.Equals(".js", StringComparison.OrdinalIgnoreCase)) + { + return "application/x-javascript"; + } + + if (ext.Equals(".woff", StringComparison.OrdinalIgnoreCase)) + { + return "font/woff"; + } + + if (ext.Equals(".ttf", StringComparison.OrdinalIgnoreCase)) + { + return "font/ttf"; + } + if (ext.Equals(".eot", StringComparison.OrdinalIgnoreCase)) + { + return "application/vnd.ms-fontobject"; + } + if (ext.Equals(".svg", StringComparison.OrdinalIgnoreCase) || ext.Equals(".svgz", StringComparison.OrdinalIgnoreCase)) + { + return "image/svg+xml"; + } + + throw new InvalidOperationException("Argument not supported: " + path); + } + } +} diff --git a/MediaBrowser.Common/Net/NativeWebSocket.cs b/MediaBrowser.Common/Net/NativeWebSocket.cs new file mode 100644 index 0000000000..d57deca54e --- /dev/null +++ b/MediaBrowser.Common/Net/NativeWebSocket.cs @@ -0,0 +1,153 @@ +using MediaBrowser.Common.Logging; +using MediaBrowser.Common.Serialization; +using MediaBrowser.Model.Logging; +using System; +using System.IO; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Class NativeWebSocket + /// </summary> + public class NativeWebSocket : IWebSocket + { + /// <summary> + /// The logger + /// </summary> + private static ILogger Logger = LogManager.GetLogger("NativeWebSocket"); + + /// <summary> + /// Gets or sets the web socket. + /// </summary> + /// <value>The web socket.</value> + private WebSocket WebSocket { get; set; } + + /// <summary> + /// Initializes a new instance of the <see cref="NativeWebSocket" /> class. + /// </summary> + /// <param name="socket">The socket.</param> + /// <exception cref="System.ArgumentNullException">socket</exception> + public NativeWebSocket(WebSocket socket) + { + if (socket == null) + { + throw new ArgumentNullException("socket"); + } + + WebSocket = socket; + + Receive(); + } + + /// <summary> + /// Gets or sets the state. + /// </summary> + /// <value>The state.</value> + public WebSocketState State + { + get { return WebSocket.State; } + } + + /// <summary> + /// Receives this instance. + /// </summary> + private async void Receive() + { + while (true) + { + byte[] bytes; + + try + { + bytes = await ReceiveBytesAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (WebSocketException ex) + { + Logger.ErrorException("Error reveiving web socket message", ex); + + break; + } + + if (OnReceiveDelegate != null) + { + using (var memoryStream = new MemoryStream(bytes)) + { + try + { + var messageResult = JsonSerializer.DeserializeFromStream<WebSocketMessageInfo>(memoryStream); + + OnReceiveDelegate(messageResult); + } + catch (Exception ex) + { + Logger.ErrorException("Error processing web socket message", ex); + } + } + } + } + } + + /// <summary> + /// Receives the async. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{WebSocketMessageInfo}.</returns> + /// <exception cref="System.Net.WebSockets.WebSocketException">Connection closed</exception> + private async Task<byte[]> ReceiveBytesAsync(CancellationToken cancellationToken) + { + var bytes = new byte[4096]; + var buffer = new ArraySegment<byte>(bytes); + + var result = await WebSocket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); + + if (result.CloseStatus.HasValue) + { + throw new WebSocketException("Connection closed"); + } + + return buffer.Array; + } + + /// <summary> + /// Sends the async. + /// </summary> + /// <param name="bytes">The bytes.</param> + /// <param name="type">The type.</param> + /// <param name="endOfMessage">if set to <c>true</c> [end of message].</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public Task SendAsync(byte[] bytes, WebSocketMessageType type, bool endOfMessage, CancellationToken cancellationToken) + { + return WebSocket.SendAsync(new ArraySegment<byte>(bytes), type, true, cancellationToken); + } + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + public void Dispose() + { + Dispose(true); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + if (dispose) + { + WebSocket.Dispose(); + } + } + + /// <summary> + /// Gets or sets the receive action. + /// </summary> + /// <value>The receive action.</value> + public Action<WebSocketMessageInfo> OnReceiveDelegate { get; set; } + } +} diff --git a/MediaBrowser.Common/Net/NetUtils.cs b/MediaBrowser.Common/Net/NetUtils.cs new file mode 100644 index 0000000000..eb50a879d6 --- /dev/null +++ b/MediaBrowser.Common/Net/NetUtils.cs @@ -0,0 +1,219 @@ +using MediaBrowser.Common.Win32; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Management; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Class NetUtils + /// </summary> + public static class NetUtils + { + /// <summary> + /// Gets the machine's local ip address + /// </summary> + /// <returns>IPAddress.</returns> + public static IPAddress GetLocalIpAddress() + { + var host = Dns.GetHostEntry(Dns.GetHostName()); + + return host.AddressList.FirstOrDefault(i => i.AddressFamily == AddressFamily.InterNetwork); + } + + /// <summary> + /// Gets a random port number that is currently available + /// </summary> + /// <returns>System.Int32.</returns> + public static int GetRandomUnusedPort() + { + var listener = new TcpListener(IPAddress.Any, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + /// <summary> + /// Creates the netsh URL registration. + /// </summary> + /// <param name="urlPrefix">The URL prefix.</param> + public static void CreateNetshUrlRegistration(string urlPrefix) + { + var startInfo = new ProcessStartInfo + { + FileName = "netsh", + Arguments = string.Format("http add urlacl url={0} user=\"NT AUTHORITY\\Authenticated Users\"", urlPrefix), + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + Verb = "runas", + ErrorDialog = false + }; + + using (var process = Process.Start(startInfo)) + { + process.WaitForExit(); + } + } + + /// <summary> + /// Adds the windows firewall rule. + /// </summary> + /// <param name="port">The port.</param> + /// <param name="protocol">The protocol.</param> + public static void AddWindowsFirewallRule(int port, NetworkProtocol protocol) + { + // First try to remove it so we don't end up creating duplicates + RemoveWindowsFirewallRule(port, protocol); + + var args = string.Format("advfirewall firewall add rule name=\"Port {0}\" dir=in action=allow protocol={1} localport={0}", port, protocol); + + RunNetsh(args); + } + + /// <summary> + /// Removes the windows firewall rule. + /// </summary> + /// <param name="port">The port.</param> + /// <param name="protocol">The protocol.</param> + public static void RemoveWindowsFirewallRule(int port, NetworkProtocol protocol) + { + var args = string.Format("advfirewall firewall delete rule name=\"Port {0}\" protocol={1} localport={0}", port, protocol); + + RunNetsh(args); + } + + /// <summary> + /// Runs the netsh. + /// </summary> + /// <param name="args">The args.</param> + private static void RunNetsh(string args) + { + var startInfo = new ProcessStartInfo + { + FileName = "netsh", + Arguments = args, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + Verb = "runas", + ErrorDialog = false + }; + + using (var process = new Process { StartInfo = startInfo }) + { + process.Start(); + process.WaitForExit(); + } + } + + /// <summary> + /// Returns MAC Address from first Network Card in Computer + /// </summary> + /// <returns>[string] MAC Address</returns> + public static string GetMacAddress() + { + var mc = new ManagementClass("Win32_NetworkAdapterConfiguration"); + var moc = mc.GetInstances(); + var macAddress = String.Empty; + foreach (ManagementObject mo in moc) + { + if (macAddress == String.Empty) // only return MAC Address from first card + { + try + { + if ((bool)mo["IPEnabled"]) macAddress = mo["MacAddress"].ToString(); + } + catch + { + mo.Dispose(); + return ""; + } + } + mo.Dispose(); + } + + return macAddress.Replace(":", ""); + } + + /// <summary> + /// Uses the DllImport : NetServerEnum with all its required parameters + /// (see http://msdn.microsoft.com/library/default.asp?url=/library/en-us/netmgmt/netmgmt/netserverenum.asp + /// for full details or method signature) to retrieve a list of domain SV_TYPE_WORKSTATION + /// and SV_TYPE_SERVER PC's + /// </summary> + /// <returns>Arraylist that represents all the SV_TYPE_WORKSTATION and SV_TYPE_SERVER + /// PC's in the Domain</returns> + public static IEnumerable<string> GetNetworkComputers() + { + //local fields + const int MAX_PREFERRED_LENGTH = -1; + var SV_TYPE_WORKSTATION = 1; + var SV_TYPE_SERVER = 2; + var buffer = IntPtr.Zero; + var tmpBuffer = IntPtr.Zero; + var entriesRead = 0; + var totalEntries = 0; + var resHandle = 0; + var sizeofINFO = Marshal.SizeOf(typeof(_SERVER_INFO_100)); + + try + { + //call the DllImport : NetServerEnum with all its required parameters + //see http://msdn.microsoft.com/library/default.asp?url=/library/en-us/netmgmt/netmgmt/netserverenum.asp + //for full details of method signature + var ret = NativeMethods.NetServerEnum(null, 100, ref buffer, MAX_PREFERRED_LENGTH, out entriesRead, out totalEntries, SV_TYPE_WORKSTATION | SV_TYPE_SERVER, null, out resHandle); + + //if the returned with a NERR_Success (C++ term), =0 for C# + if (ret == 0) + { + //loop through all SV_TYPE_WORKSTATION and SV_TYPE_SERVER PC's + for (var i = 0; i < totalEntries; i++) + { + //get pointer to, Pointer to the buffer that received the data from + //the call to NetServerEnum. Must ensure to use correct size of + //STRUCTURE to ensure correct location in memory is pointed to + tmpBuffer = new IntPtr((int)buffer + (i * sizeofINFO)); + //Have now got a pointer to the list of SV_TYPE_WORKSTATION and + //SV_TYPE_SERVER PC's, which is unmanaged memory + //Needs to Marshal data from an unmanaged block of memory to a + //managed object, again using STRUCTURE to ensure the correct data + //is marshalled + var svrInfo = (_SERVER_INFO_100)Marshal.PtrToStructure(tmpBuffer, typeof(_SERVER_INFO_100)); + + //add the PC names to the ArrayList + if (!string.IsNullOrEmpty(svrInfo.sv100_name)) + { + yield return svrInfo.sv100_name; + } + } + } + } + finally + { + //The NetApiBufferFree function frees + //the memory that the NetApiBufferAllocate function allocates + NativeMethods.NetApiBufferFree(buffer); + } + } + } + + /// <summary> + /// Enum NetworkProtocol + /// </summary> + public enum NetworkProtocol + { + /// <summary> + /// The TCP + /// </summary> + Tcp, + /// <summary> + /// The UDP + /// </summary> + Udp + } +} diff --git a/MediaBrowser.Common/Net/NetworkShares.cs b/MediaBrowser.Common/Net/NetworkShares.cs new file mode 100644 index 0000000000..202865b4c1 --- /dev/null +++ b/MediaBrowser.Common/Net/NetworkShares.cs @@ -0,0 +1,644 @@ +using System; +using System.IO; +using System.Collections; +using System.Runtime.InteropServices; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Type of share + /// </summary> + [Flags] + public enum ShareType + { + /// <summary>Disk share</summary> + Disk = 0, + /// <summary>Printer share</summary> + Printer = 1, + /// <summary>Device share</summary> + Device = 2, + /// <summary>IPC share</summary> + IPC = 3, + /// <summary>Special share</summary> + Special = -2147483648, // 0x80000000, + } + + #region Share + + /// <summary> + /// Information about a local share + /// </summary> + public class Share + { + #region Private data + + private string _server; + private string _netName; + private string _path; + private ShareType _shareType; + private string _remark; + + #endregion + + #region Constructor + + /// <summary> + /// Constructor + /// </summary> + /// <param name="Server"></param> + /// <param name="shi"></param> + public Share(string server, string netName, string path, ShareType shareType, string remark) + { + if (ShareType.Special == shareType && "IPC$" == netName) + { + shareType |= ShareType.IPC; + } + + _server = server; + _netName = netName; + _path = path; + _shareType = shareType; + _remark = remark; + } + + #endregion + + #region Properties + + /// <summary> + /// The name of the computer that this share belongs to + /// </summary> + public string Server + { + get { return _server; } + } + + /// <summary> + /// Share name + /// </summary> + public string NetName + { + get { return _netName; } + } + + /// <summary> + /// Local path + /// </summary> + public string Path + { + get { return _path; } + } + + /// <summary> + /// Share type + /// </summary> + public ShareType ShareType + { + get { return _shareType; } + } + + /// <summary> + /// Comment + /// </summary> + public string Remark + { + get { return _remark; } + } + + /// <summary> + /// Returns true if this is a file system share + /// </summary> + public bool IsFileSystem + { + get + { + // Shared device + if (0 != (_shareType & ShareType.Device)) return false; + // IPC share + if (0 != (_shareType & ShareType.IPC)) return false; + // Shared printer + if (0 != (_shareType & ShareType.Printer)) return false; + + // Standard disk share + if (0 == (_shareType & ShareType.Special)) return true; + + // Special disk share (e.g. C$) + if (ShareType.Special == _shareType && null != _netName && 0 != _netName.Length) + return true; + else + return false; + } + } + + /// <summary> + /// Get the root of a disk-based share + /// </summary> + public DirectoryInfo Root + { + get + { + if (IsFileSystem) + { + if (null == _server || 0 == _server.Length) + if (null == _path || 0 == _path.Length) + return new DirectoryInfo(ToString()); + else + return new DirectoryInfo(_path); + else + return new DirectoryInfo(ToString()); + } + else + return null; + } + } + + #endregion + + /// <summary> + /// Returns the path to this share + /// </summary> + /// <returns></returns> + public override string ToString() + { + if (null == _server || 0 == _server.Length) + { + return string.Format(@"\\{0}\{1}", Environment.MachineName, _netName); + } + else + return string.Format(@"\\{0}\{1}", _server, _netName); + } + + /// <summary> + /// Returns true if this share matches the local path + /// </summary> + /// <param name="path"></param> + /// <returns></returns> + public bool MatchesPath(string path) + { + if (!IsFileSystem) return false; + if (null == path || 0 == path.Length) return true; + + return path.ToLower().StartsWith(_path.ToLower()); + } + } + + #endregion + + /// <summary> + /// A collection of shares + /// </summary> + public class ShareCollection : ReadOnlyCollectionBase + { + #region Platform + + /// <summary> + /// Is this an NT platform? + /// </summary> + protected static bool IsNT + { + get { return (PlatformID.Win32NT == Environment.OSVersion.Platform); } + } + + /// <summary> + /// Returns true if this is Windows 2000 or higher + /// </summary> + protected static bool IsW2KUp + { + get + { + OperatingSystem os = Environment.OSVersion; + if (PlatformID.Win32NT == os.Platform && os.Version.Major >= 5) + return true; + else + return false; + } + } + + #endregion + + #region Interop + + #region Constants + + /// <summary>Maximum path length</summary> + protected const int MAX_PATH = 260; + /// <summary>No error</summary> + protected const int NO_ERROR = 0; + /// <summary>Access denied</summary> + protected const int ERROR_ACCESS_DENIED = 5; + /// <summary>Access denied</summary> + protected const int ERROR_WRONG_LEVEL = 124; + /// <summary>More data available</summary> + protected const int ERROR_MORE_DATA = 234; + /// <summary>Not connected</summary> + protected const int ERROR_NOT_CONNECTED = 2250; + /// <summary>Level 1</summary> + protected const int UNIVERSAL_NAME_INFO_LEVEL = 1; + /// <summary>Max extries (9x)</summary> + protected const int MAX_SI50_ENTRIES = 20; + + #endregion + + #region Structures + + /// <summary>Unc name</summary> + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + protected struct UNIVERSAL_NAME_INFO + { + [MarshalAs(UnmanagedType.LPTStr)] + public string lpUniversalName; + } + + /// <summary>Share information, NT, level 2</summary> + /// <remarks> + /// Requires admin rights to work. + /// </remarks> + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + protected struct SHARE_INFO_2 + { + [MarshalAs(UnmanagedType.LPWStr)] + public string NetName; + public ShareType ShareType; + [MarshalAs(UnmanagedType.LPWStr)] + public string Remark; + public int Permissions; + public int MaxUsers; + public int CurrentUsers; + [MarshalAs(UnmanagedType.LPWStr)] + public string Path; + [MarshalAs(UnmanagedType.LPWStr)] + public string Password; + } + + /// <summary>Share information, NT, level 1</summary> + /// <remarks> + /// Fallback when no admin rights. + /// </remarks> + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + protected struct SHARE_INFO_1 + { + [MarshalAs(UnmanagedType.LPWStr)] + public string NetName; + public ShareType ShareType; + [MarshalAs(UnmanagedType.LPWStr)] + public string Remark; + } + + /// <summary>Share information, Win9x</summary> + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi, Pack = 1)] + protected struct SHARE_INFO_50 + { + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 13)] + public string NetName; + + public byte bShareType; + public ushort Flags; + + [MarshalAs(UnmanagedType.LPTStr)] + public string Remark; + [MarshalAs(UnmanagedType.LPTStr)] + public string Path; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 9)] + public string PasswordRW; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 9)] + public string PasswordRO; + + public ShareType ShareType + { + get { return (ShareType)((int)bShareType & 0x7F); } + } + } + + /// <summary>Share information level 1, Win9x</summary> + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi, Pack = 1)] + protected struct SHARE_INFO_1_9x + { + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 13)] + public string NetName; + public byte Padding; + + public ushort bShareType; + + [MarshalAs(UnmanagedType.LPTStr)] + public string Remark; + + public ShareType ShareType + { + get { return (ShareType)((int)bShareType & 0x7FFF); } + } + } + + #endregion + + #region Functions + + /// <summary>Get a UNC name</summary> + [DllImport("mpr", CharSet = CharSet.Auto)] + protected static extern int WNetGetUniversalName(string lpLocalPath, + int dwInfoLevel, ref UNIVERSAL_NAME_INFO lpBuffer, ref int lpBufferSize); + + /// <summary>Get a UNC name</summary> + [DllImport("mpr", CharSet = CharSet.Auto)] + protected static extern int WNetGetUniversalName(string lpLocalPath, + int dwInfoLevel, IntPtr lpBuffer, ref int lpBufferSize); + + /// <summary>Enumerate shares (NT)</summary> + [DllImport("netapi32", CharSet = CharSet.Unicode)] + protected static extern int NetShareEnum(string lpServerName, int dwLevel, + out IntPtr lpBuffer, int dwPrefMaxLen, out int entriesRead, + out int totalEntries, ref int hResume); + + /// <summary>Enumerate shares (9x)</summary> + [DllImport("svrapi", CharSet = CharSet.Ansi)] + protected static extern int NetShareEnum( + [MarshalAs(UnmanagedType.LPTStr)] string lpServerName, int dwLevel, + IntPtr lpBuffer, ushort cbBuffer, out ushort entriesRead, + out ushort totalEntries); + + /// <summary>Free the buffer (NT)</summary> + [DllImport("netapi32")] + protected static extern int NetApiBufferFree(IntPtr lpBuffer); + + #endregion + + #region Enumerate shares + + /// <summary> + /// Enumerates the shares on Windows NT + /// </summary> + /// <param name="server">The server name</param> + /// <param name="shares">The ShareCollection</param> + protected static void EnumerateSharesNT(string server, ShareCollection shares) + { + int level = 2; + int entriesRead, totalEntries, nRet, hResume = 0; + IntPtr pBuffer = IntPtr.Zero; + + try + { + nRet = NetShareEnum(server, level, out pBuffer, -1, + out entriesRead, out totalEntries, ref hResume); + + if (ERROR_ACCESS_DENIED == nRet) + { + //Need admin for level 2, drop to level 1 + level = 1; + nRet = NetShareEnum(server, level, out pBuffer, -1, + out entriesRead, out totalEntries, ref hResume); + } + + if (NO_ERROR == nRet && entriesRead > 0) + { + Type t = (2 == level) ? typeof(SHARE_INFO_2) : typeof(SHARE_INFO_1); + int offset = Marshal.SizeOf(t); + + for (int i = 0, lpItem = pBuffer.ToInt32(); i < entriesRead; i++, lpItem += offset) + { + IntPtr pItem = new IntPtr(lpItem); + if (1 == level) + { + SHARE_INFO_1 si = (SHARE_INFO_1)Marshal.PtrToStructure(pItem, t); + shares.Add(si.NetName, string.Empty, si.ShareType, si.Remark); + } + else + { + SHARE_INFO_2 si = (SHARE_INFO_2)Marshal.PtrToStructure(pItem, t); + shares.Add(si.NetName, si.Path, si.ShareType, si.Remark); + } + } + } + + } + finally + { + // Clean up buffer allocated by system + if (IntPtr.Zero != pBuffer) + NetApiBufferFree(pBuffer); + } + } + + /// <summary> + /// Enumerates the shares on Windows 9x + /// </summary> + /// <param name="server">The server name</param> + /// <param name="shares">The ShareCollection</param> + protected static void EnumerateShares9x(string server, ShareCollection shares) + { + int level = 50; + int nRet = 0; + ushort entriesRead, totalEntries; + + Type t = typeof(SHARE_INFO_50); + int size = Marshal.SizeOf(t); + ushort cbBuffer = (ushort)(MAX_SI50_ENTRIES * size); + //On Win9x, must allocate buffer before calling API + IntPtr pBuffer = Marshal.AllocHGlobal(cbBuffer); + + try + { + nRet = NetShareEnum(server, level, pBuffer, cbBuffer, + out entriesRead, out totalEntries); + + if (ERROR_WRONG_LEVEL == nRet) + { + level = 1; + t = typeof(SHARE_INFO_1_9x); + size = Marshal.SizeOf(t); + + nRet = NetShareEnum(server, level, pBuffer, cbBuffer, + out entriesRead, out totalEntries); + } + + if (NO_ERROR == nRet || ERROR_MORE_DATA == nRet) + { + for (int i = 0, lpItem = pBuffer.ToInt32(); i < entriesRead; i++, lpItem += size) + { + IntPtr pItem = new IntPtr(lpItem); + + if (1 == level) + { + SHARE_INFO_1_9x si = (SHARE_INFO_1_9x)Marshal.PtrToStructure(pItem, t); + shares.Add(si.NetName, string.Empty, si.ShareType, si.Remark); + } + else + { + SHARE_INFO_50 si = (SHARE_INFO_50)Marshal.PtrToStructure(pItem, t); + shares.Add(si.NetName, si.Path, si.ShareType, si.Remark); + } + } + } + else + Console.WriteLine(nRet); + + } + finally + { + //Clean up buffer + Marshal.FreeHGlobal(pBuffer); + } + } + + /// <summary> + /// Enumerates the shares + /// </summary> + /// <param name="server">The server name</param> + /// <param name="shares">The ShareCollection</param> + protected static void EnumerateShares(string server, ShareCollection shares) + { + if (null != server && 0 != server.Length && !IsW2KUp) + { + server = server.ToUpper(); + + // On NT4, 9x and Me, server has to start with "\\" + if (!('\\' == server[0] && '\\' == server[1])) + server = @"\\" + server; + } + + if (IsNT) + EnumerateSharesNT(server, shares); + else + EnumerateShares9x(server, shares); + } + + #endregion + + #endregion + + #region Static methods + + /// <summary> + /// Returns true if fileName is a valid local file-name of the form: + /// X:\, where X is a drive letter from A-Z + /// </summary> + /// <param name="fileName">The filename to check</param> + /// <returns></returns> + public static bool IsValidFilePath(string fileName) + { + if (null == fileName || 0 == fileName.Length) return false; + + char drive = char.ToUpper(fileName[0]); + if ('A' > drive || drive > 'Z') + return false; + + else if (Path.VolumeSeparatorChar != fileName[1]) + return false; + else if (Path.DirectorySeparatorChar != fileName[2]) + return false; + else + return true; + } + + #endregion + + /// <summary>The name of the server this collection represents</summary> + private string _server; + + #region Constructor + + /// <summary> + /// Default constructor - local machine + /// </summary> + public ShareCollection() + { + _server = string.Empty; + EnumerateShares(_server, this); + } + + /// <summary> + /// Constructor + /// </summary> + /// <param name="Server"></param> + public ShareCollection(string server) + { + _server = server; + EnumerateShares(_server, this); + } + + #endregion + + #region Add + + protected void Add(Share share) + { + InnerList.Add(share); + } + + protected void Add(string netName, string path, ShareType shareType, string remark) + { + InnerList.Add(new Share(_server, netName, path, shareType, remark)); + } + + #endregion + + #region Properties + + /// <summary> + /// Returns the name of the server this collection represents + /// </summary> + public string Server + { + get { return _server; } + } + + /// <summary> + /// Returns the <see cref="Share"/> at the specified index. + /// </summary> + public Share this[int index] + { + get { return (Share)InnerList[index]; } + } + + /// <summary> + /// Returns the <see cref="Share"/> which matches a given local path + /// </summary> + /// <param name="path">The path to match</param> + public Share this[string path] + { + get + { + if (null == path || 0 == path.Length) return null; + + path = Path.GetFullPath(path); + if (!IsValidFilePath(path)) return null; + + Share match = null; + + for (int i = 0; i < InnerList.Count; i++) + { + Share s = (Share)InnerList[i]; + + if (s.IsFileSystem && s.MatchesPath(path)) + { + //Store first match + if (null == match) + match = s; + + // If this has a longer path, + // and this is a disk share or match is a special share, + // then this is a better match + else if (match.Path.Length < s.Path.Length) + { + if (ShareType.Disk == s.ShareType || ShareType.Disk != match.ShareType) + match = s; + } + } + } + + return match; + } + } + + #endregion + + /// <summary> + /// Copy this collection to an array + /// </summary> + /// <param name="array"></param> + /// <param name="index"></param> + public void CopyTo(Share[] array, int index) + { + InnerList.CopyTo(array, index); + } + } +}
\ No newline at end of file diff --git a/MediaBrowser.Common/Net/Request.cs b/MediaBrowser.Common/Net/Request.cs deleted file mode 100644 index 795c9c36ba..0000000000 --- a/MediaBrowser.Common/Net/Request.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-
-namespace MediaBrowser.Common.Net
-{
- public class Request
- {
- public string HttpMethod { get; set; }
- public IDictionary<string, IEnumerable<string>> Headers { get; set; }
- public Stream InputStream { get; set; }
- public string RawUrl { get; set; }
- public int ContentLength
- {
- get { return int.Parse(Headers["Content-Length"].First()); }
- }
- }
-}
\ No newline at end of file diff --git a/MediaBrowser.Common/Net/StaticResult.cs b/MediaBrowser.Common/Net/StaticResult.cs new file mode 100644 index 0000000000..0dd6372cfa --- /dev/null +++ b/MediaBrowser.Common/Net/StaticResult.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MediaBrowser.Common.Net +{ + public class StaticResult + { + public Stream Stream { get; set; } + } +} diff --git a/MediaBrowser.Common/Net/UdpServer.cs b/MediaBrowser.Common/Net/UdpServer.cs new file mode 100644 index 0000000000..a3c6a8a78c --- /dev/null +++ b/MediaBrowser.Common/Net/UdpServer.cs @@ -0,0 +1,142 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Reactive.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Provides a Udp Server + /// </summary> + public class UdpServer : IObservable<UdpReceiveResult>, IDisposable + { + /// <summary> + /// The _udp client + /// </summary> + private readonly UdpClient _udpClient; + /// <summary> + /// The _stream + /// </summary> + private readonly IObservable<UdpReceiveResult> _stream; + + /// <summary> + /// Initializes a new instance of the <see cref="UdpServer" /> class. + /// </summary> + /// <param name="endPoint">The end point.</param> + /// <exception cref="System.ArgumentNullException">endPoint</exception> + public UdpServer(IPEndPoint endPoint) + { + if (endPoint == null) + { + throw new ArgumentNullException("endPoint"); + } + + _udpClient = new UdpClient(endPoint); + + _udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + //_udpClient.ExclusiveAddressUse = false; + + _stream = CreateObservable(); + } + + /// <summary> + /// Creates the observable. + /// </summary> + /// <returns>IObservable{UdpReceiveResult}.</returns> + private IObservable<UdpReceiveResult> CreateObservable() + { + return Observable.Create<UdpReceiveResult>(obs => + Observable.FromAsync(() => _udpClient.ReceiveAsync()) + .Subscribe(obs)) + .Repeat() + .Retry() + .Publish() + .RefCount(); + } + + /// <summary> + /// Subscribes the specified observer. + /// </summary> + /// <param name="observer">The observer.</param> + /// <returns>IDisposable.</returns> + /// <exception cref="System.ArgumentNullException">observer</exception> + public IDisposable Subscribe(IObserver<UdpReceiveResult> observer) + { + if (observer == null) + { + throw new ArgumentNullException("observer"); + } + + return _stream.Subscribe(observer); + } + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + if (dispose) + { + _udpClient.Close(); + } + } + + /// <summary> + /// Sends the async. + /// </summary> + /// <param name="data">The data.</param> + /// <param name="endPoint">The end point.</param> + /// <returns>Task{System.Int32}.</returns> + /// <exception cref="System.ArgumentNullException">data</exception> + public async Task<int> SendAsync(string data, IPEndPoint endPoint) + { + if (data == null) + { + throw new ArgumentNullException("data"); + } + + if (endPoint == null) + { + throw new ArgumentNullException("endPoint"); + } + + var bytes = Encoding.UTF8.GetBytes(data); + + return await _udpClient.SendAsync(bytes, bytes.Length, endPoint).ConfigureAwait(false); + } + + /// <summary> + /// Sends the async. + /// </summary> + /// <param name="bytes">The bytes.</param> + /// <param name="endPoint">The end point.</param> + /// <returns>Task{System.Int32}.</returns> + /// <exception cref="System.ArgumentNullException">bytes</exception> + public async Task<int> SendAsync(byte[] bytes, IPEndPoint endPoint) + { + if (bytes == null) + { + throw new ArgumentNullException("bytes"); + } + + if (endPoint == null) + { + throw new ArgumentNullException("endPoint"); + } + + return await _udpClient.SendAsync(bytes, bytes.Length, endPoint).ConfigureAwait(false); + } + } +} diff --git a/MediaBrowser.Common/Net/WebSocketConnection.cs b/MediaBrowser.Common/Net/WebSocketConnection.cs new file mode 100644 index 0000000000..ca12d07be8 --- /dev/null +++ b/MediaBrowser.Common/Net/WebSocketConnection.cs @@ -0,0 +1,228 @@ +using MediaBrowser.Common.Logging; +using MediaBrowser.Common.Serialization; +using MediaBrowser.Model.Logging; +using System; +using System.Net; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Class WebSocketConnection + /// </summary> + public class WebSocketConnection : IDisposable + { + /// <summary> + /// The _socket + /// </summary> + private readonly IWebSocket _socket; + + /// <summary> + /// The _remote end point + /// </summary> + public readonly EndPoint RemoteEndPoint; + + /// <summary> + /// The _cancellation token source + /// </summary> + private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + + /// <summary> + /// The _send semaphore + /// </summary> + private readonly SemaphoreSlim _sendSemaphore = new SemaphoreSlim(1,1); + + /// <summary> + /// The logger + /// </summary> + private static readonly ILogger Logger = LogManager.GetLogger("WebSocketConnection"); + + /// <summary> + /// Initializes a new instance of the <see cref="WebSocketConnection" /> class. + /// </summary> + /// <param name="socket">The socket.</param> + /// <param name="remoteEndPoint">The remote end point.</param> + /// <param name="receiveAction">The receive action.</param> + /// <exception cref="System.ArgumentNullException">socket</exception> + public WebSocketConnection(IWebSocket socket, EndPoint remoteEndPoint, Action<WebSocketMessageInfo> receiveAction) + { + if (socket == null) + { + throw new ArgumentNullException("socket"); + } + if (remoteEndPoint == null) + { + throw new ArgumentNullException("remoteEndPoint"); + } + if (receiveAction == null) + { + throw new ArgumentNullException("receiveAction"); + } + + _socket = socket; + _socket.OnReceiveDelegate = info => OnReceive(info, receiveAction); + RemoteEndPoint = remoteEndPoint; + } + + /// <summary> + /// Called when [receive]. + /// </summary> + /// <param name="info">The info.</param> + /// <param name="callback">The callback.</param> + private void OnReceive(WebSocketMessageInfo info, Action<WebSocketMessageInfo> callback) + { + try + { + info.Connection = this; + + callback(info); + } + catch (Exception ex) + { + Logger.ErrorException("Error processing web socket message", ex); + } + } + + /// <summary> + /// Sends a message asynchronously. + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="message">The message.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">message</exception> + public Task SendAsync<T>(WebSocketMessage<T> message, CancellationToken cancellationToken) + { + if (message == null) + { + throw new ArgumentNullException("message"); + } + + var bytes = JsonSerializer.SerializeToBytes(message); + + return SendAsync(bytes, cancellationToken); + } + + /// <summary> + /// Sends a message asynchronously. + /// </summary> + /// <param name="buffer">The buffer.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public Task SendAsync(byte[] buffer, CancellationToken cancellationToken) + { + return SendAsync(buffer, WebSocketMessageType.Text, cancellationToken); + } + + /// <summary> + /// Sends a message asynchronously. + /// </summary> + /// <param name="buffer">The buffer.</param> + /// <param name="type">The type.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">buffer</exception> + public async Task SendAsync(byte[] buffer, WebSocketMessageType type, CancellationToken cancellationToken) + { + if (buffer == null) + { + throw new ArgumentNullException("buffer"); + } + + if (cancellationToken == null) + { + throw new ArgumentNullException("cancellationToken"); + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Per msdn docs, attempting to send simultaneous messages will result in one failing. + // This should help us workaround that and ensure all messages get sent + await _sendSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + await _socket.SendAsync(buffer, type, true, cancellationToken); + } + catch (OperationCanceledException) + { + Logger.Info("WebSocket message to {0} was cancelled", RemoteEndPoint); + + throw; + } + catch (Exception ex) + { + Logger.ErrorException("Error sending WebSocket message {0}", ex, RemoteEndPoint); + + throw; + } + finally + { + _sendSemaphore.Release(); + } + } + + /// <summary> + /// Gets the state. + /// </summary> + /// <value>The state.</value> + public WebSocketState State + { + get { return _socket.State; } + } + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + if (dispose) + { + _cancellationTokenSource.Dispose(); + _socket.Dispose(); + } + } + } + + /// <summary> + /// Class WebSocketMessage + /// </summary> + /// <typeparam name="T"></typeparam> + public class WebSocketMessage<T> + { + /// <summary> + /// Gets or sets the type of the message. + /// </summary> + /// <value>The type of the message.</value> + public string MessageType { get; set; } + /// <summary> + /// Gets or sets the data. + /// </summary> + /// <value>The data.</value> + public T Data { get; set; } + } + + /// <summary> + /// Class WebSocketMessageInfo + /// </summary> + public class WebSocketMessageInfo : WebSocketMessage<string> + { + /// <summary> + /// Gets or sets the connection. + /// </summary> + /// <value>The connection.</value> + public WebSocketConnection Connection { get; set; } + } +} |
