commit
15135dc3b8
@ -1,250 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using MediaBrowser.Model.IO;
|
|
||||||
using MediaBrowser.Model.Services;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Net.Http.Headers;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.HttpServer
|
|
||||||
{
|
|
||||||
public class FileWriter : IHttpResult
|
|
||||||
{
|
|
||||||
private static readonly CultureInfo UsCulture = CultureInfo.ReadOnly(new CultureInfo("en-US"));
|
|
||||||
|
|
||||||
private static readonly string[] _skipLogExtensions = {
|
|
||||||
".js",
|
|
||||||
".html",
|
|
||||||
".css"
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly IStreamHelper _streamHelper;
|
|
||||||
private readonly ILogger _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The _options.
|
|
||||||
/// </summary>
|
|
||||||
private readonly IDictionary<string, string> _options = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The _requested ranges.
|
|
||||||
/// </summary>
|
|
||||||
private List<KeyValuePair<long, long?>> _requestedRanges;
|
|
||||||
|
|
||||||
public FileWriter(string path, string contentType, string rangeHeader, ILogger logger, IFileSystem fileSystem, IStreamHelper streamHelper)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(contentType))
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(contentType));
|
|
||||||
}
|
|
||||||
|
|
||||||
_streamHelper = streamHelper;
|
|
||||||
|
|
||||||
Path = path;
|
|
||||||
_logger = logger;
|
|
||||||
RangeHeader = rangeHeader;
|
|
||||||
|
|
||||||
Headers[HeaderNames.ContentType] = contentType;
|
|
||||||
|
|
||||||
TotalContentLength = fileSystem.GetFileInfo(path).Length;
|
|
||||||
Headers[HeaderNames.AcceptRanges] = "bytes";
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(rangeHeader))
|
|
||||||
{
|
|
||||||
Headers[HeaderNames.ContentLength] = TotalContentLength.ToString(CultureInfo.InvariantCulture);
|
|
||||||
StatusCode = HttpStatusCode.OK;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
StatusCode = HttpStatusCode.PartialContent;
|
|
||||||
SetRangeValues();
|
|
||||||
}
|
|
||||||
|
|
||||||
FileShare = FileShare.Read;
|
|
||||||
Cookies = new List<Cookie>();
|
|
||||||
}
|
|
||||||
|
|
||||||
private string RangeHeader { get; set; }
|
|
||||||
|
|
||||||
private bool IsHeadRequest { get; set; }
|
|
||||||
|
|
||||||
private long RangeStart { get; set; }
|
|
||||||
|
|
||||||
private long RangeEnd { get; set; }
|
|
||||||
|
|
||||||
private long RangeLength { get; set; }
|
|
||||||
|
|
||||||
public long TotalContentLength { get; set; }
|
|
||||||
|
|
||||||
public Action OnComplete { get; set; }
|
|
||||||
|
|
||||||
public Action OnError { get; set; }
|
|
||||||
|
|
||||||
public List<Cookie> Cookies { get; private set; }
|
|
||||||
|
|
||||||
public FileShare FileShare { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the options.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The options.</value>
|
|
||||||
public IDictionary<string, string> Headers => _options;
|
|
||||||
|
|
||||||
public string Path { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the requested ranges.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The requested ranges.</value>
|
|
||||||
protected List<KeyValuePair<long, long?>> RequestedRanges
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
if (_requestedRanges == null)
|
|
||||||
{
|
|
||||||
_requestedRanges = new List<KeyValuePair<long, long?>>();
|
|
||||||
|
|
||||||
// Example: bytes=0-,32-63
|
|
||||||
var ranges = RangeHeader.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], UsCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(vals[1]))
|
|
||||||
{
|
|
||||||
end = long.Parse(vals[1], UsCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
_requestedRanges.Add(new KeyValuePair<long, long?>(start, end));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return _requestedRanges;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string ContentType { get; set; }
|
|
||||||
|
|
||||||
public IRequest RequestContext { get; set; }
|
|
||||||
|
|
||||||
public object Response { get; set; }
|
|
||||||
|
|
||||||
public int Status { get; set; }
|
|
||||||
|
|
||||||
public HttpStatusCode StatusCode
|
|
||||||
{
|
|
||||||
get => (HttpStatusCode)Status;
|
|
||||||
set => Status = (int)value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sets the range values.
|
|
||||||
/// </summary>
|
|
||||||
private void SetRangeValues()
|
|
||||||
{
|
|
||||||
var requestedRange = RequestedRanges[0];
|
|
||||||
|
|
||||||
// If the requested range is "0-", we can optimize by just doing a stream copy
|
|
||||||
if (!requestedRange.Value.HasValue)
|
|
||||||
{
|
|
||||||
RangeEnd = TotalContentLength - 1;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
RangeEnd = requestedRange.Value.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
RangeStart = requestedRange.Key;
|
|
||||||
RangeLength = 1 + RangeEnd - RangeStart;
|
|
||||||
|
|
||||||
// Content-Length is the length of what we're serving, not the original content
|
|
||||||
var lengthString = RangeLength.ToString(CultureInfo.InvariantCulture);
|
|
||||||
Headers[HeaderNames.ContentLength] = lengthString;
|
|
||||||
var rangeString = $"bytes {RangeStart}-{RangeEnd}/{TotalContentLength}";
|
|
||||||
Headers[HeaderNames.ContentRange] = rangeString;
|
|
||||||
|
|
||||||
_logger.LogDebug("Setting range response values for {0}. RangeRequest: {1} Content-Length: {2}, Content-Range: {3}", Path, RangeHeader, lengthString, rangeString);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task WriteToAsync(HttpResponse response, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Headers only
|
|
||||||
if (IsHeadRequest)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var path = Path;
|
|
||||||
var offset = RangeStart;
|
|
||||||
var count = RangeLength;
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(RangeHeader) || RangeStart <= 0 && RangeEnd >= TotalContentLength - 1)
|
|
||||||
{
|
|
||||||
var extension = System.IO.Path.GetExtension(path);
|
|
||||||
|
|
||||||
if (extension == null || !_skipLogExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Transmit file {0}", path);
|
|
||||||
}
|
|
||||||
|
|
||||||
offset = 0;
|
|
||||||
count = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
await TransmitFile(response.Body, path, offset, count, FileShare, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
OnComplete?.Invoke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task TransmitFile(Stream stream, string path, long offset, long count, FileShare fileShare, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var fileOptions = FileOptions.SequentialScan;
|
|
||||||
|
|
||||||
// use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039
|
|
||||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
||||||
{
|
|
||||||
fileOptions |= FileOptions.Asynchronous;
|
|
||||||
}
|
|
||||||
|
|
||||||
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, fileShare, IODefaults.FileStreamBufferSize, fileOptions))
|
|
||||||
{
|
|
||||||
if (offset > 0)
|
|
||||||
{
|
|
||||||
fs.Position = offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count > 0)
|
|
||||||
{
|
|
||||||
await _streamHelper.CopyToAsync(fs, stream, count, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await fs.CopyToAsync(stream, IODefaults.CopyToBufferSize, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,766 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net.Sockets;
|
|
||||||
using System.Net.WebSockets;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Emby.Server.Implementations.Services;
|
|
||||||
using Emby.Server.Implementations.SocketSharp;
|
|
||||||
using Jellyfin.Data.Events;
|
|
||||||
using MediaBrowser.Common.Extensions;
|
|
||||||
using MediaBrowser.Common.Net;
|
|
||||||
using MediaBrowser.Controller;
|
|
||||||
using MediaBrowser.Controller.Authentication;
|
|
||||||
using MediaBrowser.Controller.Configuration;
|
|
||||||
using MediaBrowser.Controller.Net;
|
|
||||||
using MediaBrowser.Model.Globalization;
|
|
||||||
using MediaBrowser.Model.Serialization;
|
|
||||||
using MediaBrowser.Model.Services;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.AspNetCore.Http.Extensions;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Primitives;
|
|
||||||
using ServiceStack.Text.Jsv;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.HttpServer
|
|
||||||
{
|
|
||||||
public class HttpListenerHost : IHttpServer
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The key for a setting that specifies the default redirect path
|
|
||||||
/// to use for requests where the URL base prefix is invalid or missing.
|
|
||||||
/// </summary>
|
|
||||||
public const string DefaultRedirectKey = "HttpListenerHost:DefaultRedirectPath";
|
|
||||||
|
|
||||||
private readonly ILogger<HttpListenerHost> _logger;
|
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
|
||||||
private readonly IServerConfigurationManager _config;
|
|
||||||
private readonly INetworkManager _networkManager;
|
|
||||||
private readonly IServerApplicationHost _appHost;
|
|
||||||
private readonly IJsonSerializer _jsonSerializer;
|
|
||||||
private readonly IXmlSerializer _xmlSerializer;
|
|
||||||
private readonly Func<Type, Func<string, object>> _funcParseFn;
|
|
||||||
private readonly string _defaultRedirectPath;
|
|
||||||
private readonly string _baseUrlPrefix;
|
|
||||||
|
|
||||||
private readonly Dictionary<Type, Type> _serviceOperationsMap = new Dictionary<Type, Type>();
|
|
||||||
private readonly IHostEnvironment _hostEnvironment;
|
|
||||||
|
|
||||||
private IWebSocketListener[] _webSocketListeners = Array.Empty<IWebSocketListener>();
|
|
||||||
private bool _disposed = false;
|
|
||||||
|
|
||||||
public HttpListenerHost(
|
|
||||||
IServerApplicationHost applicationHost,
|
|
||||||
ILogger<HttpListenerHost> logger,
|
|
||||||
IServerConfigurationManager config,
|
|
||||||
IConfiguration configuration,
|
|
||||||
INetworkManager networkManager,
|
|
||||||
IJsonSerializer jsonSerializer,
|
|
||||||
IXmlSerializer xmlSerializer,
|
|
||||||
ILocalizationManager localizationManager,
|
|
||||||
ServiceController serviceController,
|
|
||||||
IHostEnvironment hostEnvironment,
|
|
||||||
ILoggerFactory loggerFactory)
|
|
||||||
{
|
|
||||||
_appHost = applicationHost;
|
|
||||||
_logger = logger;
|
|
||||||
_config = config;
|
|
||||||
_defaultRedirectPath = configuration[DefaultRedirectKey];
|
|
||||||
_baseUrlPrefix = _config.Configuration.BaseUrl;
|
|
||||||
_networkManager = networkManager;
|
|
||||||
_jsonSerializer = jsonSerializer;
|
|
||||||
_xmlSerializer = xmlSerializer;
|
|
||||||
ServiceController = serviceController;
|
|
||||||
_hostEnvironment = hostEnvironment;
|
|
||||||
_loggerFactory = loggerFactory;
|
|
||||||
|
|
||||||
_funcParseFn = t => s => JsvReader.GetParseFn(t)(s);
|
|
||||||
|
|
||||||
Instance = this;
|
|
||||||
ResponseFilters = Array.Empty<Action<IRequest, HttpResponse, object>>();
|
|
||||||
GlobalResponse = localizationManager.GetLocalizedString("StartupEmbyServerIsLoading");
|
|
||||||
}
|
|
||||||
|
|
||||||
public event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
|
|
||||||
|
|
||||||
public Action<IRequest, HttpResponse, object>[] ResponseFilters { get; set; }
|
|
||||||
|
|
||||||
public static HttpListenerHost Instance { get; protected set; }
|
|
||||||
|
|
||||||
public string[] UrlPrefixes { get; private set; }
|
|
||||||
|
|
||||||
public string GlobalResponse { get; set; }
|
|
||||||
|
|
||||||
public ServiceController ServiceController { get; }
|
|
||||||
|
|
||||||
public object CreateInstance(Type type)
|
|
||||||
{
|
|
||||||
return _appHost.CreateInstance(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeUrlPath(string path)
|
|
||||||
{
|
|
||||||
if (path.Length > 0 && path[0] == '/')
|
|
||||||
{
|
|
||||||
// If the path begins with a leading slash, just return it as-is
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// If the path does not begin with a leading slash, append one for consistency
|
|
||||||
return "/" + path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Applies the request filters. Returns whether or not the request has been handled
|
|
||||||
/// and no more processing should be done.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns></returns>
|
|
||||||
public void ApplyRequestFilters(IRequest req, HttpResponse res, object requestDto)
|
|
||||||
{
|
|
||||||
// Exec all RequestFilter attributes with Priority < 0
|
|
||||||
var attributes = GetRequestFilterAttributes(requestDto.GetType());
|
|
||||||
|
|
||||||
int count = attributes.Count;
|
|
||||||
int i = 0;
|
|
||||||
for (; i < count && attributes[i].Priority < 0; i++)
|
|
||||||
{
|
|
||||||
var attribute = attributes[i];
|
|
||||||
attribute.RequestFilter(req, res, requestDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exec remaining RequestFilter attributes with Priority >= 0
|
|
||||||
for (; i < count && attributes[i].Priority >= 0; i++)
|
|
||||||
{
|
|
||||||
var attribute = attributes[i];
|
|
||||||
attribute.RequestFilter(req, res, requestDto);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Type GetServiceTypeByRequest(Type requestType)
|
|
||||||
{
|
|
||||||
_serviceOperationsMap.TryGetValue(requestType, out var serviceType);
|
|
||||||
return serviceType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AddServiceInfo(Type serviceType, Type requestType)
|
|
||||||
{
|
|
||||||
_serviceOperationsMap[requestType] = serviceType;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<IHasRequestFilter> GetRequestFilterAttributes(Type requestDtoType)
|
|
||||||
{
|
|
||||||
var attributes = requestDtoType.GetCustomAttributes(true).OfType<IHasRequestFilter>().ToList();
|
|
||||||
|
|
||||||
var serviceType = GetServiceTypeByRequest(requestDtoType);
|
|
||||||
if (serviceType != null)
|
|
||||||
{
|
|
||||||
attributes.AddRange(serviceType.GetCustomAttributes(true).OfType<IHasRequestFilter>());
|
|
||||||
}
|
|
||||||
|
|
||||||
attributes.Sort((x, y) => x.Priority - y.Priority);
|
|
||||||
|
|
||||||
return attributes;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Exception GetActualException(Exception ex)
|
|
||||||
{
|
|
||||||
if (ex is AggregateException agg)
|
|
||||||
{
|
|
||||||
var inner = agg.InnerException;
|
|
||||||
if (inner != null)
|
|
||||||
{
|
|
||||||
return GetActualException(inner);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var inners = agg.InnerExceptions;
|
|
||||||
if (inners.Count > 0)
|
|
||||||
{
|
|
||||||
return GetActualException(inners[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ex;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int GetStatusCode(Exception ex)
|
|
||||||
{
|
|
||||||
switch (ex)
|
|
||||||
{
|
|
||||||
case ArgumentException _: return 400;
|
|
||||||
case AuthenticationException _: return 401;
|
|
||||||
case SecurityException _: return 403;
|
|
||||||
case DirectoryNotFoundException _:
|
|
||||||
case FileNotFoundException _:
|
|
||||||
case ResourceNotFoundException _: return 404;
|
|
||||||
case MethodNotAllowedException _: return 405;
|
|
||||||
default: return 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ErrorHandler(Exception ex, IRequest httpReq, int statusCode, string urlToLog, bool ignoreStackTrace)
|
|
||||||
{
|
|
||||||
if (ignoreStackTrace)
|
|
||||||
{
|
|
||||||
_logger.LogError("Error processing request: {Message}. URL: {Url}", ex.Message.TrimEnd('.'), urlToLog);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error processing request. URL: {Url}", urlToLog);
|
|
||||||
}
|
|
||||||
|
|
||||||
var httpRes = httpReq.Response;
|
|
||||||
|
|
||||||
if (httpRes.HasStarted)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
httpRes.StatusCode = statusCode;
|
|
||||||
|
|
||||||
var errContent = _hostEnvironment.IsDevelopment()
|
|
||||||
? (NormalizeExceptionMessage(ex) ?? string.Empty)
|
|
||||||
: "Error processing request.";
|
|
||||||
httpRes.ContentType = "text/plain";
|
|
||||||
httpRes.ContentLength = errContent.Length;
|
|
||||||
await httpRes.WriteAsync(errContent).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string NormalizeExceptionMessage(Exception ex)
|
|
||||||
{
|
|
||||||
// Do not expose the exception message for AuthenticationException
|
|
||||||
if (ex is AuthenticationException)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip any information we don't want to reveal
|
|
||||||
return ex.Message
|
|
||||||
?.Replace(_config.ApplicationPaths.ProgramSystemPath, string.Empty, StringComparison.OrdinalIgnoreCase)
|
|
||||||
.Replace(_config.ApplicationPaths.ProgramDataPath, string.Empty, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string RemoveQueryStringByKey(string url, string key)
|
|
||||||
{
|
|
||||||
var uri = new Uri(url);
|
|
||||||
|
|
||||||
// this gets all the query string key value pairs as a collection
|
|
||||||
var newQueryString = QueryHelpers.ParseQuery(uri.Query);
|
|
||||||
|
|
||||||
var originalCount = newQueryString.Count;
|
|
||||||
|
|
||||||
if (originalCount == 0)
|
|
||||||
{
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// this removes the key if exists
|
|
||||||
newQueryString.Remove(key);
|
|
||||||
|
|
||||||
if (originalCount == newQueryString.Count)
|
|
||||||
{
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// this gets the page path from root without QueryString
|
|
||||||
string pagePathWithoutQueryString = url.Split(new[] { '?' }, StringSplitOptions.RemoveEmptyEntries)[0];
|
|
||||||
|
|
||||||
return newQueryString.Count > 0
|
|
||||||
? QueryHelpers.AddQueryString(pagePathWithoutQueryString, newQueryString.ToDictionary(kv => kv.Key, kv => kv.Value.ToString()))
|
|
||||||
: pagePathWithoutQueryString;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetUrlToLog(string url)
|
|
||||||
{
|
|
||||||
url = RemoveQueryStringByKey(url, "api_key");
|
|
||||||
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeConfiguredLocalAddress(string address)
|
|
||||||
{
|
|
||||||
var add = address.AsSpan().Trim('/');
|
|
||||||
int index = add.IndexOf('/');
|
|
||||||
if (index != -1)
|
|
||||||
{
|
|
||||||
add = add.Slice(index + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return add.TrimStart('/').ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool ValidateHost(string host)
|
|
||||||
{
|
|
||||||
var hosts = _config
|
|
||||||
.Configuration
|
|
||||||
.LocalNetworkAddresses
|
|
||||||
.Select(NormalizeConfiguredLocalAddress)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (hosts.Count == 0)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
host ??= string.Empty;
|
|
||||||
|
|
||||||
if (_networkManager.IsInPrivateAddressSpace(host))
|
|
||||||
{
|
|
||||||
hosts.Add("localhost");
|
|
||||||
hosts.Add("127.0.0.1");
|
|
||||||
|
|
||||||
return hosts.Any(i => host.IndexOf(i, StringComparison.OrdinalIgnoreCase) != -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool ValidateRequest(string remoteIp, bool isLocal)
|
|
||||||
{
|
|
||||||
if (isLocal)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_config.Configuration.EnableRemoteAccess)
|
|
||||||
{
|
|
||||||
var addressFilter = _config.Configuration.RemoteIPFilter.Where(i => !string.IsNullOrWhiteSpace(i)).ToArray();
|
|
||||||
|
|
||||||
if (addressFilter.Length > 0 && !_networkManager.IsInLocalNetwork(remoteIp))
|
|
||||||
{
|
|
||||||
if (_config.Configuration.IsRemoteIPFilterBlacklist)
|
|
||||||
{
|
|
||||||
return !_networkManager.IsAddressInSubnets(remoteIp, addressFilter);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return _networkManager.IsAddressInSubnets(remoteIp, addressFilter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (!_networkManager.IsInLocalNetwork(remoteIp))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validate a connection from a remote IP address to a URL to see if a redirection to HTTPS is required.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>True if the request is valid, or false if the request is not valid and an HTTPS redirect is required.</returns>
|
|
||||||
private bool ValidateSsl(string remoteIp, string urlString)
|
|
||||||
{
|
|
||||||
if (_config.Configuration.RequireHttps
|
|
||||||
&& _appHost.ListenWithHttps
|
|
||||||
&& !urlString.Contains("https://", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
// These are hacks, but if these ever occur on ipv6 in the local network they could be incorrectly redirected
|
|
||||||
if (urlString.IndexOf("system/ping", StringComparison.OrdinalIgnoreCase) != -1
|
|
||||||
|| urlString.IndexOf("dlna/", StringComparison.OrdinalIgnoreCase) != -1)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_networkManager.IsInLocalNetwork(remoteIp))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task RequestHandler(HttpContext context)
|
|
||||||
{
|
|
||||||
if (context.WebSockets.IsWebSocketRequest)
|
|
||||||
{
|
|
||||||
return WebSocketRequestHandler(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = context.Request;
|
|
||||||
var response = context.Response;
|
|
||||||
var localPath = context.Request.Path.ToString();
|
|
||||||
|
|
||||||
var req = new WebSocketSharpRequest(request, response, request.Path);
|
|
||||||
return RequestHandler(req, request.GetDisplayUrl(), request.Host.ToString(), localPath, context.RequestAborted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Overridable method that can be used to implement a custom handler.
|
|
||||||
/// </summary>
|
|
||||||
private async Task RequestHandler(IHttpRequest httpReq, string urlString, string host, string localPath, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var stopWatch = new Stopwatch();
|
|
||||||
stopWatch.Start();
|
|
||||||
var httpRes = httpReq.Response;
|
|
||||||
string urlToLog = GetUrlToLog(urlString);
|
|
||||||
string remoteIp = httpReq.RemoteIp;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_disposed)
|
|
||||||
{
|
|
||||||
httpRes.StatusCode = 503;
|
|
||||||
httpRes.ContentType = "text/plain";
|
|
||||||
await httpRes.WriteAsync("Server shutting down", cancellationToken).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ValidateHost(host))
|
|
||||||
{
|
|
||||||
httpRes.StatusCode = 400;
|
|
||||||
httpRes.ContentType = "text/plain";
|
|
||||||
await httpRes.WriteAsync("Invalid host", cancellationToken).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ValidateRequest(remoteIp, httpReq.IsLocal))
|
|
||||||
{
|
|
||||||
httpRes.StatusCode = 403;
|
|
||||||
httpRes.ContentType = "text/plain";
|
|
||||||
await httpRes.WriteAsync("Forbidden", cancellationToken).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ValidateSsl(httpReq.RemoteIp, urlString))
|
|
||||||
{
|
|
||||||
RedirectToSecureUrl(httpReq, httpRes, urlString);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(httpReq.Verb, "OPTIONS", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
httpRes.StatusCode = 200;
|
|
||||||
foreach (var (key, value) in GetDefaultCorsHeaders(httpReq))
|
|
||||||
{
|
|
||||||
httpRes.Headers.Add(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
httpRes.ContentType = "text/plain";
|
|
||||||
await httpRes.WriteAsync(string.Empty, cancellationToken).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(localPath, _baseUrlPrefix + "/", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(localPath, _baseUrlPrefix, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(localPath, "/", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.IsNullOrEmpty(localPath)
|
|
||||||
|| !localPath.StartsWith(_baseUrlPrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
// Always redirect back to the default path if the base prefix is invalid or missing
|
|
||||||
_logger.LogDebug("Normalizing a URL at {0}", localPath);
|
|
||||||
httpRes.Redirect(_baseUrlPrefix + "/" + _defaultRedirectPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(GlobalResponse))
|
|
||||||
{
|
|
||||||
// We don't want the address pings in ApplicationHost to fail
|
|
||||||
if (localPath.IndexOf("system/ping", StringComparison.OrdinalIgnoreCase) == -1)
|
|
||||||
{
|
|
||||||
httpRes.StatusCode = 503;
|
|
||||||
httpRes.ContentType = "text/html";
|
|
||||||
await httpRes.WriteAsync(GlobalResponse, cancellationToken).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var handler = GetServiceHandler(httpReq);
|
|
||||||
if (handler != null)
|
|
||||||
{
|
|
||||||
await handler.ProcessRequestAsync(this, httpReq, httpRes, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new FileNotFoundException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception requestEx)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var requestInnerEx = GetActualException(requestEx);
|
|
||||||
var statusCode = GetStatusCode(requestInnerEx);
|
|
||||||
|
|
||||||
foreach (var (key, value) in GetDefaultCorsHeaders(httpReq))
|
|
||||||
{
|
|
||||||
if (!httpRes.Headers.ContainsKey(key))
|
|
||||||
{
|
|
||||||
httpRes.Headers.Add(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ignoreStackTrace =
|
|
||||||
requestInnerEx is SocketException
|
|
||||||
|| requestInnerEx is IOException
|
|
||||||
|| requestInnerEx is OperationCanceledException
|
|
||||||
|| requestInnerEx is SecurityException
|
|
||||||
|| requestInnerEx is AuthenticationException
|
|
||||||
|| requestInnerEx is FileNotFoundException;
|
|
||||||
|
|
||||||
// Do not handle 500 server exceptions manually when in development mode.
|
|
||||||
// Instead, re-throw the exception so it can be handled by the DeveloperExceptionPageMiddleware.
|
|
||||||
// However, do not use the DeveloperExceptionPageMiddleware when the stack trace should be ignored,
|
|
||||||
// because it will log the stack trace when it handles the exception.
|
|
||||||
if (statusCode == 500 && !ignoreStackTrace && _hostEnvironment.IsDevelopment())
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ErrorHandler(requestInnerEx, httpReq, statusCode, urlToLog, ignoreStackTrace).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception handlerException)
|
|
||||||
{
|
|
||||||
var aggregateEx = new AggregateException("Error while handling request exception", requestEx, handlerException);
|
|
||||||
_logger.LogError(aggregateEx, "Error while handling exception in response to {Url}", urlToLog);
|
|
||||||
|
|
||||||
if (_hostEnvironment.IsDevelopment())
|
|
||||||
{
|
|
||||||
throw aggregateEx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (httpRes.StatusCode >= 500)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Sending HTTP Response 500 in response to {Url}", urlToLog);
|
|
||||||
}
|
|
||||||
|
|
||||||
stopWatch.Stop();
|
|
||||||
var elapsed = stopWatch.Elapsed;
|
|
||||||
if (elapsed.TotalMilliseconds > 500)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("HTTP Response {StatusCode} to {RemoteIp}. Time (slow): {Elapsed:g}. {Url}", httpRes.StatusCode, remoteIp, elapsed, urlToLog);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task WebSocketRequestHandler(HttpContext context)
|
|
||||||
{
|
|
||||||
if (_disposed)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress);
|
|
||||||
|
|
||||||
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
using var connection = new WebSocketConnection(
|
|
||||||
_loggerFactory.CreateLogger<WebSocketConnection>(),
|
|
||||||
webSocket,
|
|
||||||
context.Connection.RemoteIpAddress,
|
|
||||||
context.Request.Query)
|
|
||||||
{
|
|
||||||
OnReceive = ProcessWebSocketMessageReceived
|
|
||||||
};
|
|
||||||
|
|
||||||
WebSocketConnected?.Invoke(this, new GenericEventArgs<IWebSocketConnection>(connection));
|
|
||||||
|
|
||||||
await connection.ProcessAsync().ConfigureAwait(false);
|
|
||||||
_logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress);
|
|
||||||
}
|
|
||||||
catch (Exception ex) // Otherwise ASP.Net will ignore the exception
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "WS {IP} WebSocketRequestHandler error", context.Connection.RemoteIpAddress);
|
|
||||||
if (!context.Response.HasStarted)
|
|
||||||
{
|
|
||||||
context.Response.StatusCode = 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the default CORS headers.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="req"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public IDictionary<string, string> GetDefaultCorsHeaders(IRequest req)
|
|
||||||
{
|
|
||||||
var origin = req.Headers["Origin"];
|
|
||||||
if (origin == StringValues.Empty)
|
|
||||||
{
|
|
||||||
origin = req.Headers["Host"];
|
|
||||||
if (origin == StringValues.Empty)
|
|
||||||
{
|
|
||||||
origin = "*";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var headers = new Dictionary<string, string>();
|
|
||||||
headers.Add("Access-Control-Allow-Origin", origin);
|
|
||||||
headers.Add("Access-Control-Allow-Credentials", "true");
|
|
||||||
headers.Add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
|
|
||||||
headers.Add("Access-Control-Allow-Headers", "Content-Type, Authorization, Range, X-MediaBrowser-Token, X-Emby-Authorization, Cookie");
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Entry point for HttpListener
|
|
||||||
public ServiceHandler GetServiceHandler(IHttpRequest httpReq)
|
|
||||||
{
|
|
||||||
var pathInfo = httpReq.PathInfo;
|
|
||||||
|
|
||||||
pathInfo = ServiceHandler.GetSanitizedPathInfo(pathInfo, out string contentType);
|
|
||||||
var restPath = ServiceController.GetRestPathForRequest(httpReq.HttpMethod, pathInfo);
|
|
||||||
if (restPath != null)
|
|
||||||
{
|
|
||||||
return new ServiceHandler(restPath, contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogError("Could not find handler for {PathInfo}", pathInfo);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RedirectToSecureUrl(IHttpRequest httpReq, HttpResponse httpRes, string url)
|
|
||||||
{
|
|
||||||
if (Uri.TryCreate(url, UriKind.Absolute, out Uri uri))
|
|
||||||
{
|
|
||||||
var builder = new UriBuilder(uri)
|
|
||||||
{
|
|
||||||
Port = _config.Configuration.PublicHttpsPort,
|
|
||||||
Scheme = "https"
|
|
||||||
};
|
|
||||||
url = builder.Uri.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
httpRes.Redirect(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds the rest handlers.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="serviceTypes">The service types to register with the <see cref="ServiceController"/>.</param>
|
|
||||||
/// <param name="listeners">The web socket listeners.</param>
|
|
||||||
/// <param name="urlPrefixes">The URL prefixes. See <see cref="UrlPrefixes"/>.</param>
|
|
||||||
public void Init(IEnumerable<Type> serviceTypes, IEnumerable<IWebSocketListener> listeners, IEnumerable<string> urlPrefixes)
|
|
||||||
{
|
|
||||||
_webSocketListeners = listeners.ToArray();
|
|
||||||
UrlPrefixes = urlPrefixes.ToArray();
|
|
||||||
|
|
||||||
ServiceController.Init(this, serviceTypes);
|
|
||||||
|
|
||||||
ResponseFilters = new Action<IRequest, HttpResponse, object>[]
|
|
||||||
{
|
|
||||||
new ResponseFilter(this, _logger).FilterResponse
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public RouteAttribute[] GetRouteAttributes(Type requestType)
|
|
||||||
{
|
|
||||||
var routes = requestType.GetTypeInfo().GetCustomAttributes<RouteAttribute>(true).ToList();
|
|
||||||
var clone = routes.ToList();
|
|
||||||
|
|
||||||
foreach (var route in clone)
|
|
||||||
{
|
|
||||||
routes.Add(new RouteAttribute(NormalizeCustomRoutePath(route.Path), route.Verbs)
|
|
||||||
{
|
|
||||||
Notes = route.Notes,
|
|
||||||
Priority = route.Priority,
|
|
||||||
Summary = route.Summary
|
|
||||||
});
|
|
||||||
|
|
||||||
routes.Add(new RouteAttribute(NormalizeEmbyRoutePath(route.Path), route.Verbs)
|
|
||||||
{
|
|
||||||
Notes = route.Notes,
|
|
||||||
Priority = route.Priority,
|
|
||||||
Summary = route.Summary
|
|
||||||
});
|
|
||||||
|
|
||||||
routes.Add(new RouteAttribute(NormalizeMediaBrowserRoutePath(route.Path), route.Verbs)
|
|
||||||
{
|
|
||||||
Notes = route.Notes,
|
|
||||||
Priority = route.Priority,
|
|
||||||
Summary = route.Summary
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return routes.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Func<string, object> GetParseFn(Type propertyType)
|
|
||||||
{
|
|
||||||
return _funcParseFn(propertyType);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SerializeToJson(object o, Stream stream)
|
|
||||||
{
|
|
||||||
_jsonSerializer.SerializeToStream(o, stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SerializeToXml(object o, Stream stream)
|
|
||||||
{
|
|
||||||
_xmlSerializer.SerializeToStream(o, stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<object> DeserializeXml(Type type, Stream stream)
|
|
||||||
{
|
|
||||||
return Task.FromResult(_xmlSerializer.DeserializeFromStream(type, stream));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<object> DeserializeJson(Type type, Stream stream)
|
|
||||||
{
|
|
||||||
return _jsonSerializer.DeserializeFromStreamAsync(stream, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string NormalizeEmbyRoutePath(string path)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Normalizing /emby route");
|
|
||||||
return _baseUrlPrefix + "/emby" + NormalizeUrlPath(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string NormalizeMediaBrowserRoutePath(string path)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Normalizing /mediabrowser route");
|
|
||||||
return _baseUrlPrefix + "/mediabrowser" + NormalizeUrlPath(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string NormalizeCustomRoutePath(string path)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Normalizing custom route {0}", path);
|
|
||||||
return _baseUrlPrefix + NormalizeUrlPath(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Processes the web socket message received.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="result">The result.</param>
|
|
||||||
private Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result)
|
|
||||||
{
|
|
||||||
if (_disposed)
|
|
||||||
{
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
IEnumerable<Task> GetTasks()
|
|
||||||
{
|
|
||||||
foreach (var x in _webSocketListeners)
|
|
||||||
{
|
|
||||||
yield return x.ProcessMessageAsync(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.WhenAll(GetTasks());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,721 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.IO.Compression;
|
|
||||||
using System.Net;
|
|
||||||
using System.Runtime.Serialization;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Xml;
|
|
||||||
using Emby.Server.Implementations.Services;
|
|
||||||
using MediaBrowser.Controller.Net;
|
|
||||||
using MediaBrowser.Model.IO;
|
|
||||||
using MediaBrowser.Model.Serialization;
|
|
||||||
using MediaBrowser.Model.Services;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Primitives;
|
|
||||||
using Microsoft.Net.Http.Headers;
|
|
||||||
using IRequest = MediaBrowser.Model.Services.IRequest;
|
|
||||||
using MimeTypes = MediaBrowser.Model.Net.MimeTypes;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.HttpServer
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Class HttpResultFactory.
|
|
||||||
/// </summary>
|
|
||||||
public class HttpResultFactory : IHttpResultFactory
|
|
||||||
{
|
|
||||||
// Last-Modified and If-Modified-Since must follow strict date format,
|
|
||||||
// see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since
|
|
||||||
private const string HttpDateFormat = "ddd, dd MMM yyyy HH:mm:ss \"GMT\"";
|
|
||||||
// We specifically use en-US culture because both day of week and month names require it
|
|
||||||
private static readonly CultureInfo _enUSculture = new CultureInfo("en-US", false);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The logger.
|
|
||||||
/// </summary>
|
|
||||||
private readonly ILogger<HttpResultFactory> _logger;
|
|
||||||
private readonly IFileSystem _fileSystem;
|
|
||||||
private readonly IJsonSerializer _jsonSerializer;
|
|
||||||
private readonly IStreamHelper _streamHelper;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="HttpResultFactory" /> class.
|
|
||||||
/// </summary>
|
|
||||||
public HttpResultFactory(ILoggerFactory loggerfactory, IFileSystem fileSystem, IJsonSerializer jsonSerializer, IStreamHelper streamHelper)
|
|
||||||
{
|
|
||||||
_fileSystem = fileSystem;
|
|
||||||
_jsonSerializer = jsonSerializer;
|
|
||||||
_streamHelper = streamHelper;
|
|
||||||
_logger = loggerfactory.CreateLogger<HttpResultFactory>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the result.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="requestContext">The request context.</param>
|
|
||||||
/// <param name="content">The content.</param>
|
|
||||||
/// <param name="contentType">Type of the content.</param>
|
|
||||||
/// <param name="responseHeaders">The response headers.</param>
|
|
||||||
/// <returns>System.Object.</returns>
|
|
||||||
public object GetResult(IRequest requestContext, byte[] content, string contentType, IDictionary<string, string> responseHeaders = null)
|
|
||||||
{
|
|
||||||
return GetHttpResult(requestContext, content, contentType, true, responseHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
public object GetResult(string content, string contentType, IDictionary<string, string> responseHeaders = null)
|
|
||||||
{
|
|
||||||
return GetHttpResult(null, content, contentType, true, responseHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
public object GetResult(IRequest requestContext, Stream content, string contentType, IDictionary<string, string> responseHeaders = null)
|
|
||||||
{
|
|
||||||
return GetHttpResult(requestContext, content, contentType, true, responseHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
public object GetResult(IRequest requestContext, string content, string contentType, IDictionary<string, string> responseHeaders = null)
|
|
||||||
{
|
|
||||||
return GetHttpResult(requestContext, content, contentType, true, responseHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
public object GetRedirectResult(string url)
|
|
||||||
{
|
|
||||||
var responseHeaders = new Dictionary<string, string>();
|
|
||||||
responseHeaders[HeaderNames.Location] = url;
|
|
||||||
|
|
||||||
var result = new HttpResult(Array.Empty<byte>(), "text/plain", HttpStatusCode.Redirect);
|
|
||||||
|
|
||||||
AddResponseHeaders(result, responseHeaders);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the HTTP result.
|
|
||||||
/// </summary>
|
|
||||||
private IHasHeaders GetHttpResult(IRequest requestContext, Stream content, string contentType, bool addCachePrevention, IDictionary<string, string> responseHeaders = null)
|
|
||||||
{
|
|
||||||
var result = new StreamWriter(content, contentType);
|
|
||||||
|
|
||||||
if (responseHeaders == null)
|
|
||||||
{
|
|
||||||
responseHeaders = new Dictionary<string, string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addCachePrevention && !responseHeaders.TryGetValue(HeaderNames.Expires, out _))
|
|
||||||
{
|
|
||||||
responseHeaders[HeaderNames.Expires] = "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
AddResponseHeaders(result, responseHeaders);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the HTTP result.
|
|
||||||
/// </summary>
|
|
||||||
private IHasHeaders GetHttpResult(IRequest requestContext, byte[] content, string contentType, bool addCachePrevention, IDictionary<string, string> responseHeaders = null)
|
|
||||||
{
|
|
||||||
string compressionType = null;
|
|
||||||
bool isHeadRequest = false;
|
|
||||||
|
|
||||||
if (requestContext != null)
|
|
||||||
{
|
|
||||||
compressionType = GetCompressionType(requestContext, content, contentType);
|
|
||||||
isHeadRequest = string.Equals(requestContext.Verb, "head", StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
IHasHeaders result;
|
|
||||||
if (string.IsNullOrEmpty(compressionType))
|
|
||||||
{
|
|
||||||
var contentLength = content.Length;
|
|
||||||
|
|
||||||
if (isHeadRequest)
|
|
||||||
{
|
|
||||||
content = Array.Empty<byte>();
|
|
||||||
}
|
|
||||||
|
|
||||||
result = new StreamWriter(content, contentType, contentLength);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
result = GetCompressedResult(content, compressionType, responseHeaders, isHeadRequest, contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (responseHeaders == null)
|
|
||||||
{
|
|
||||||
responseHeaders = new Dictionary<string, string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addCachePrevention && !responseHeaders.TryGetValue(HeaderNames.Expires, out string _))
|
|
||||||
{
|
|
||||||
responseHeaders[HeaderNames.Expires] = "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
AddResponseHeaders(result, responseHeaders);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the HTTP result.
|
|
||||||
/// </summary>
|
|
||||||
private IHasHeaders GetHttpResult(IRequest requestContext, string content, string contentType, bool addCachePrevention, IDictionary<string, string> responseHeaders = null)
|
|
||||||
{
|
|
||||||
IHasHeaders result;
|
|
||||||
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(content);
|
|
||||||
|
|
||||||
var compressionType = requestContext == null ? null : GetCompressionType(requestContext, bytes, contentType);
|
|
||||||
|
|
||||||
var isHeadRequest = requestContext == null ? false : string.Equals(requestContext.Verb, "head", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(compressionType))
|
|
||||||
{
|
|
||||||
var contentLength = bytes.Length;
|
|
||||||
|
|
||||||
if (isHeadRequest)
|
|
||||||
{
|
|
||||||
bytes = Array.Empty<byte>();
|
|
||||||
}
|
|
||||||
|
|
||||||
result = new StreamWriter(bytes, contentType, contentLength);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
result = GetCompressedResult(bytes, compressionType, responseHeaders, isHeadRequest, contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (responseHeaders == null)
|
|
||||||
{
|
|
||||||
responseHeaders = new Dictionary<string, string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addCachePrevention && !responseHeaders.TryGetValue(HeaderNames.Expires, out string _))
|
|
||||||
{
|
|
||||||
responseHeaders[HeaderNames.Expires] = "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
AddResponseHeaders(result, responseHeaders);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the optimized result.
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T"></typeparam>
|
|
||||||
public object GetResult<T>(IRequest requestContext, T result, IDictionary<string, string> responseHeaders = null)
|
|
||||||
where T : class
|
|
||||||
{
|
|
||||||
if (result == null)
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (responseHeaders == null)
|
|
||||||
{
|
|
||||||
responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
responseHeaders[HeaderNames.Expires] = "0";
|
|
||||||
|
|
||||||
return ToOptimizedResultInternal(requestContext, result, responseHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetCompressionType(IRequest request, byte[] content, string responseContentType)
|
|
||||||
{
|
|
||||||
if (responseContentType == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per apple docs, hls manifests must be compressed
|
|
||||||
if (!responseContentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
responseContentType.IndexOf("json", StringComparison.OrdinalIgnoreCase) == -1 &&
|
|
||||||
responseContentType.IndexOf("javascript", StringComparison.OrdinalIgnoreCase) == -1 &&
|
|
||||||
responseContentType.IndexOf("xml", StringComparison.OrdinalIgnoreCase) == -1 &&
|
|
||||||
responseContentType.IndexOf("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == -1)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.Length < 1024)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetCompressionType(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetCompressionType(IRequest request)
|
|
||||||
{
|
|
||||||
var acceptEncoding = request.Headers[HeaderNames.AcceptEncoding].ToString();
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(acceptEncoding))
|
|
||||||
{
|
|
||||||
// if (_brotliCompressor != null && acceptEncoding.IndexOf("br", StringComparison.OrdinalIgnoreCase) != -1)
|
|
||||||
// return "br";
|
|
||||||
|
|
||||||
if (acceptEncoding.Contains("deflate", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return "deflate";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (acceptEncoding.Contains("gzip", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return "gzip";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the optimized result for the IRequestContext.
|
|
||||||
/// Does not use or store results in any cache.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request"></param>
|
|
||||||
/// <param name="dto"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public object ToOptimizedResult<T>(IRequest request, T dto)
|
|
||||||
{
|
|
||||||
return ToOptimizedResultInternal(request, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
private object ToOptimizedResultInternal<T>(IRequest request, T dto, IDictionary<string, string> responseHeaders = null)
|
|
||||||
{
|
|
||||||
// TODO: @bond use Span and .Equals
|
|
||||||
var contentType = request.ResponseContentType?.Split(';')[0].Trim().ToLowerInvariant();
|
|
||||||
|
|
||||||
switch (contentType)
|
|
||||||
{
|
|
||||||
case "application/xml":
|
|
||||||
case "text/xml":
|
|
||||||
case "text/xml; charset=utf-8": //"text/xml; charset=utf-8" also matches xml
|
|
||||||
return GetHttpResult(request, SerializeToXmlString(dto), contentType, false, responseHeaders);
|
|
||||||
|
|
||||||
case "application/json":
|
|
||||||
case "text/json":
|
|
||||||
return GetHttpResult(request, _jsonSerializer.SerializeToString(dto), contentType, false, responseHeaders);
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var isHeadRequest = string.Equals(request.Verb, "head", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var ms = new MemoryStream();
|
|
||||||
var writerFn = RequestHelper.GetResponseWriter(HttpListenerHost.Instance, contentType);
|
|
||||||
|
|
||||||
writerFn(dto, ms);
|
|
||||||
|
|
||||||
ms.Position = 0;
|
|
||||||
|
|
||||||
if (isHeadRequest)
|
|
||||||
{
|
|
||||||
using (ms)
|
|
||||||
{
|
|
||||||
return GetHttpResult(request, Array.Empty<byte>(), contentType, true, responseHeaders);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetHttpResult(request, ms, contentType, true, responseHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
private IHasHeaders GetCompressedResult(
|
|
||||||
byte[] content,
|
|
||||||
string requestedCompressionType,
|
|
||||||
IDictionary<string, string> responseHeaders,
|
|
||||||
bool isHeadRequest,
|
|
||||||
string contentType)
|
|
||||||
{
|
|
||||||
if (responseHeaders == null)
|
|
||||||
{
|
|
||||||
responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
content = Compress(content, requestedCompressionType);
|
|
||||||
responseHeaders[HeaderNames.ContentEncoding] = requestedCompressionType;
|
|
||||||
|
|
||||||
responseHeaders[HeaderNames.Vary] = HeaderNames.AcceptEncoding;
|
|
||||||
|
|
||||||
var contentLength = content.Length;
|
|
||||||
|
|
||||||
if (isHeadRequest)
|
|
||||||
{
|
|
||||||
var result = new StreamWriter(Array.Empty<byte>(), contentType, contentLength);
|
|
||||||
AddResponseHeaders(result, responseHeaders);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var result = new StreamWriter(content, contentType, contentLength);
|
|
||||||
AddResponseHeaders(result, responseHeaders);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] Compress(byte[] bytes, string compressionType)
|
|
||||||
{
|
|
||||||
if (string.Equals(compressionType, "deflate", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return Deflate(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(compressionType, "gzip", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return GZip(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new NotSupportedException(compressionType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] Deflate(byte[] bytes)
|
|
||||||
{
|
|
||||||
// In .NET FX incompat-ville, you can't access compressed bytes without closing DeflateStream
|
|
||||||
// Which means we must use MemoryStream since you have to use ToArray() on a closed Stream
|
|
||||||
using (var ms = new MemoryStream())
|
|
||||||
using (var zipStream = new DeflateStream(ms, CompressionMode.Compress))
|
|
||||||
{
|
|
||||||
zipStream.Write(bytes, 0, bytes.Length);
|
|
||||||
zipStream.Dispose();
|
|
||||||
|
|
||||||
return ms.ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] GZip(byte[] buffer)
|
|
||||||
{
|
|
||||||
using (var ms = new MemoryStream())
|
|
||||||
using (var zipStream = new GZipStream(ms, CompressionMode.Compress))
|
|
||||||
{
|
|
||||||
zipStream.Write(buffer, 0, buffer.Length);
|
|
||||||
zipStream.Dispose();
|
|
||||||
|
|
||||||
return ms.ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string SerializeToXmlString(object from)
|
|
||||||
{
|
|
||||||
using (var ms = new MemoryStream())
|
|
||||||
{
|
|
||||||
var xwSettings = new XmlWriterSettings();
|
|
||||||
xwSettings.Encoding = new UTF8Encoding(false);
|
|
||||||
xwSettings.OmitXmlDeclaration = false;
|
|
||||||
|
|
||||||
using (var xw = XmlWriter.Create(ms, xwSettings))
|
|
||||||
{
|
|
||||||
var serializer = new DataContractSerializer(from.GetType());
|
|
||||||
serializer.WriteObject(xw, from);
|
|
||||||
xw.Flush();
|
|
||||||
ms.Seek(0, SeekOrigin.Begin);
|
|
||||||
using (var reader = new StreamReader(ms))
|
|
||||||
{
|
|
||||||
return reader.ReadToEnd();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pres the process optimized result.
|
|
||||||
/// </summary>
|
|
||||||
private object GetCachedResult(IRequest requestContext, IDictionary<string, string> responseHeaders, StaticResultOptions options)
|
|
||||||
{
|
|
||||||
bool noCache = requestContext.Headers[HeaderNames.CacheControl].ToString().IndexOf("no-cache", StringComparison.OrdinalIgnoreCase) != -1;
|
|
||||||
AddCachingHeaders(responseHeaders, options.CacheDuration, noCache, options.DateLastModified);
|
|
||||||
|
|
||||||
if (!noCache)
|
|
||||||
{
|
|
||||||
if (!DateTime.TryParseExact(requestContext.Headers[HeaderNames.IfModifiedSince], HttpDateFormat, _enUSculture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var ifModifiedSinceHeader))
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Failed to parse If-Modified-Since header date: {0}", requestContext.Headers[HeaderNames.IfModifiedSince]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsNotModified(ifModifiedSinceHeader, options.CacheDuration, options.DateLastModified))
|
|
||||||
{
|
|
||||||
AddAgeHeader(responseHeaders, options.DateLastModified);
|
|
||||||
|
|
||||||
var result = new HttpResult(Array.Empty<byte>(), options.ContentType ?? "text/html", HttpStatusCode.NotModified);
|
|
||||||
|
|
||||||
AddResponseHeaders(result, responseHeaders);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<object> GetStaticFileResult(IRequest requestContext,
|
|
||||||
string path,
|
|
||||||
FileShare fileShare = FileShare.Read)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(path))
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(path));
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetStaticFileResult(requestContext, new StaticFileResultOptions
|
|
||||||
{
|
|
||||||
Path = path,
|
|
||||||
FileShare = fileShare
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<object> GetStaticFileResult(IRequest requestContext, StaticFileResultOptions options)
|
|
||||||
{
|
|
||||||
var path = options.Path;
|
|
||||||
var fileShare = options.FileShare;
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(path))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Path can't be empty.", nameof(options));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileShare != FileShare.Read && fileShare != FileShare.ReadWrite)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("FileShare must be either Read or ReadWrite");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(options.ContentType))
|
|
||||||
{
|
|
||||||
options.ContentType = MimeTypes.GetMimeType(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.DateLastModified.HasValue)
|
|
||||||
{
|
|
||||||
options.DateLastModified = _fileSystem.GetLastWriteTimeUtc(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
options.ContentFactory = () => Task.FromResult(GetFileStream(path, fileShare));
|
|
||||||
|
|
||||||
options.ResponseHeaders = options.ResponseHeaders ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
return GetStaticResult(requestContext, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the file stream.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="path">The path.</param>
|
|
||||||
/// <param name="fileShare">The file share.</param>
|
|
||||||
/// <returns>Stream.</returns>
|
|
||||||
private Stream GetFileStream(string path, FileShare fileShare)
|
|
||||||
{
|
|
||||||
return new FileStream(path, FileMode.Open, FileAccess.Read, fileShare);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<object> GetStaticResult(IRequest requestContext,
|
|
||||||
Guid cacheKey,
|
|
||||||
DateTime? lastDateModified,
|
|
||||||
TimeSpan? cacheDuration,
|
|
||||||
string contentType,
|
|
||||||
Func<Task<Stream>> factoryFn,
|
|
||||||
IDictionary<string, string> responseHeaders = null,
|
|
||||||
bool isHeadRequest = false)
|
|
||||||
{
|
|
||||||
return GetStaticResult(requestContext, new StaticResultOptions
|
|
||||||
{
|
|
||||||
CacheDuration = cacheDuration,
|
|
||||||
ContentFactory = factoryFn,
|
|
||||||
ContentType = contentType,
|
|
||||||
DateLastModified = lastDateModified,
|
|
||||||
IsHeadRequest = isHeadRequest,
|
|
||||||
ResponseHeaders = responseHeaders
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<object> GetStaticResult(IRequest requestContext, StaticResultOptions options)
|
|
||||||
{
|
|
||||||
options.ResponseHeaders = options.ResponseHeaders ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var contentType = options.ContentType;
|
|
||||||
if (!StringValues.IsNullOrEmpty(requestContext.Headers[HeaderNames.IfModifiedSince]))
|
|
||||||
{
|
|
||||||
// See if the result is already cached in the browser
|
|
||||||
var result = GetCachedResult(requestContext, options.ResponseHeaders, options);
|
|
||||||
|
|
||||||
if (result != null)
|
|
||||||
{
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: We don't really need the option value
|
|
||||||
var isHeadRequest = options.IsHeadRequest || string.Equals(requestContext.Verb, "HEAD", StringComparison.OrdinalIgnoreCase);
|
|
||||||
var factoryFn = options.ContentFactory;
|
|
||||||
var responseHeaders = options.ResponseHeaders;
|
|
||||||
AddCachingHeaders(responseHeaders, options.CacheDuration, false, options.DateLastModified);
|
|
||||||
AddAgeHeader(responseHeaders, options.DateLastModified);
|
|
||||||
|
|
||||||
var rangeHeader = requestContext.Headers[HeaderNames.Range];
|
|
||||||
|
|
||||||
if (!isHeadRequest && !string.IsNullOrEmpty(options.Path))
|
|
||||||
{
|
|
||||||
var hasHeaders = new FileWriter(options.Path, contentType, rangeHeader, _logger, _fileSystem, _streamHelper)
|
|
||||||
{
|
|
||||||
OnComplete = options.OnComplete,
|
|
||||||
OnError = options.OnError,
|
|
||||||
FileShare = options.FileShare
|
|
||||||
};
|
|
||||||
|
|
||||||
AddResponseHeaders(hasHeaders, options.ResponseHeaders);
|
|
||||||
return hasHeaders;
|
|
||||||
}
|
|
||||||
|
|
||||||
var stream = await factoryFn().ConfigureAwait(false);
|
|
||||||
|
|
||||||
var totalContentLength = options.ContentLength;
|
|
||||||
if (!totalContentLength.HasValue)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
totalContentLength = stream.Length;
|
|
||||||
}
|
|
||||||
catch (NotSupportedException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(rangeHeader) && totalContentLength.HasValue)
|
|
||||||
{
|
|
||||||
var hasHeaders = new RangeRequestWriter(rangeHeader, totalContentLength.Value, stream, contentType, isHeadRequest)
|
|
||||||
{
|
|
||||||
OnComplete = options.OnComplete
|
|
||||||
};
|
|
||||||
|
|
||||||
AddResponseHeaders(hasHeaders, options.ResponseHeaders);
|
|
||||||
return hasHeaders;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (totalContentLength.HasValue)
|
|
||||||
{
|
|
||||||
responseHeaders["Content-Length"] = totalContentLength.Value.ToString(CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isHeadRequest)
|
|
||||||
{
|
|
||||||
using (stream)
|
|
||||||
{
|
|
||||||
return GetHttpResult(requestContext, Array.Empty<byte>(), contentType, true, responseHeaders);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasHeaders = new StreamWriter(stream, contentType)
|
|
||||||
{
|
|
||||||
OnComplete = options.OnComplete,
|
|
||||||
OnError = options.OnError
|
|
||||||
};
|
|
||||||
|
|
||||||
AddResponseHeaders(hasHeaders, options.ResponseHeaders);
|
|
||||||
return hasHeaders;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds the caching responseHeaders.
|
|
||||||
/// </summary>
|
|
||||||
private void AddCachingHeaders(
|
|
||||||
IDictionary<string, string> responseHeaders,
|
|
||||||
TimeSpan? cacheDuration,
|
|
||||||
bool noCache,
|
|
||||||
DateTime? lastModifiedDate)
|
|
||||||
{
|
|
||||||
if (noCache)
|
|
||||||
{
|
|
||||||
responseHeaders[HeaderNames.CacheControl] = "no-cache, no-store, must-revalidate";
|
|
||||||
responseHeaders[HeaderNames.Pragma] = "no-cache, no-store, must-revalidate";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cacheDuration.HasValue)
|
|
||||||
{
|
|
||||||
responseHeaders[HeaderNames.CacheControl] = "public, max-age=" + cacheDuration.Value.TotalSeconds;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
responseHeaders[HeaderNames.CacheControl] = "public";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastModifiedDate.HasValue)
|
|
||||||
{
|
|
||||||
responseHeaders[HeaderNames.LastModified] = lastModifiedDate.Value.ToUniversalTime().ToString(HttpDateFormat, _enUSculture);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds the age header.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="responseHeaders">The responseHeaders.</param>
|
|
||||||
/// <param name="lastDateModified">The last date modified.</param>
|
|
||||||
private static void AddAgeHeader(IDictionary<string, string> responseHeaders, DateTime? lastDateModified)
|
|
||||||
{
|
|
||||||
if (lastDateModified.HasValue)
|
|
||||||
{
|
|
||||||
responseHeaders[HeaderNames.Age] = Convert.ToInt64((DateTime.UtcNow - lastDateModified.Value).TotalSeconds).ToString(CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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 static DateTime NormalizeDateForComparison(DateTime date)
|
|
||||||
{
|
|
||||||
return new DateTime(date.Year, date.Month, date.Day, date.Hour, date.Minute, date.Second, date.Kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds the response headers.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="hasHeaders">The has options.</param>
|
|
||||||
/// <param name="responseHeaders">The response headers.</param>
|
|
||||||
private static void AddResponseHeaders(IHasHeaders hasHeaders, IEnumerable<KeyValuePair<string, string>> responseHeaders)
|
|
||||||
{
|
|
||||||
foreach (var item in responseHeaders)
|
|
||||||
{
|
|
||||||
hasHeaders.Headers[item.Key] = item.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,212 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Buffers;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Net;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using MediaBrowser.Model.Services;
|
|
||||||
using Microsoft.Net.Http.Headers;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.HttpServer
|
|
||||||
{
|
|
||||||
public class RangeRequestWriter : IAsyncStreamWriter, IHttpResult
|
|
||||||
{
|
|
||||||
private const int BufferSize = 81920;
|
|
||||||
|
|
||||||
private readonly Dictionary<string, string> _options = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
private List<KeyValuePair<long, long?>> _requestedRanges;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="RangeRequestWriter" /> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="rangeHeader">The range header.</param>
|
|
||||||
/// <param name="contentLength">The content length.</param>
|
|
||||||
/// <param name="source">The source.</param>
|
|
||||||
/// <param name="contentType">Type of the content.</param>
|
|
||||||
/// <param name="isHeadRequest">if set to <c>true</c> [is head request].</param>
|
|
||||||
public RangeRequestWriter(string rangeHeader, long contentLength, Stream source, string contentType, bool isHeadRequest)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(contentType))
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(contentType));
|
|
||||||
}
|
|
||||||
|
|
||||||
RangeHeader = rangeHeader;
|
|
||||||
SourceStream = source;
|
|
||||||
IsHeadRequest = isHeadRequest;
|
|
||||||
|
|
||||||
ContentType = contentType;
|
|
||||||
Headers[HeaderNames.ContentType] = contentType;
|
|
||||||
Headers[HeaderNames.AcceptRanges] = "bytes";
|
|
||||||
StatusCode = HttpStatusCode.PartialContent;
|
|
||||||
|
|
||||||
SetRangeValues(contentLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the source stream.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The source stream.</value>
|
|
||||||
private Stream SourceStream { get; set; }
|
|
||||||
private string RangeHeader { get; set; }
|
|
||||||
private bool IsHeadRequest { get; set; }
|
|
||||||
|
|
||||||
private long RangeStart { get; set; }
|
|
||||||
private long RangeEnd { get; set; }
|
|
||||||
private long RangeLength { get; set; }
|
|
||||||
private long TotalContentLength { get; set; }
|
|
||||||
|
|
||||||
public Action OnComplete { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Additional HTTP Headers
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The headers.</value>
|
|
||||||
public IDictionary<string, string> Headers => _options;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the requested ranges.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The requested ranges.</value>
|
|
||||||
protected List<KeyValuePair<long, long?>> RequestedRanges
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
if (_requestedRanges == null)
|
|
||||||
{
|
|
||||||
_requestedRanges = new List<KeyValuePair<long, long?>>();
|
|
||||||
|
|
||||||
// Example: bytes=0-,32-63
|
|
||||||
var ranges = RangeHeader.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], CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(vals[1]))
|
|
||||||
{
|
|
||||||
end = long.Parse(vals[1], CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
_requestedRanges.Add(new KeyValuePair<long, long?>(start, end));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return _requestedRanges;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string ContentType { get; set; }
|
|
||||||
|
|
||||||
public IRequest RequestContext { get; set; }
|
|
||||||
|
|
||||||
public object Response { get; set; }
|
|
||||||
|
|
||||||
public int Status { get; set; }
|
|
||||||
|
|
||||||
public HttpStatusCode StatusCode
|
|
||||||
{
|
|
||||||
get => (HttpStatusCode)Status;
|
|
||||||
set => Status = (int)value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sets the range values.
|
|
||||||
/// </summary>
|
|
||||||
private void SetRangeValues(long contentLength)
|
|
||||||
{
|
|
||||||
var requestedRange = RequestedRanges[0];
|
|
||||||
|
|
||||||
TotalContentLength = contentLength;
|
|
||||||
|
|
||||||
// If the requested range is "0-", we can optimize by just doing a stream copy
|
|
||||||
if (!requestedRange.Value.HasValue)
|
|
||||||
{
|
|
||||||
RangeEnd = TotalContentLength - 1;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
RangeEnd = requestedRange.Value.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
RangeStart = requestedRange.Key;
|
|
||||||
RangeLength = 1 + RangeEnd - RangeStart;
|
|
||||||
|
|
||||||
Headers[HeaderNames.ContentLength] = RangeLength.ToString(CultureInfo.InvariantCulture);
|
|
||||||
Headers[HeaderNames.ContentRange] = $"bytes {RangeStart}-{RangeEnd}/{TotalContentLength}";
|
|
||||||
|
|
||||||
if (RangeStart > 0 && SourceStream.CanSeek)
|
|
||||||
{
|
|
||||||
SourceStream.Position = RangeStart;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Headers only
|
|
||||||
if (IsHeadRequest)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
using (var source = SourceStream)
|
|
||||||
{
|
|
||||||
// If the requested range is "0-", we can optimize by just doing a stream copy
|
|
||||||
if (RangeEnd >= TotalContentLength - 1)
|
|
||||||
{
|
|
||||||
await source.CopyToAsync(responseStream, BufferSize, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await CopyToInternalAsync(source, responseStream, RangeLength, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
OnComplete?.Invoke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task CopyToInternalAsync(Stream source, Stream destination, long copyLength, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var array = ArrayPool<byte>.Shared.Rent(BufferSize);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
int bytesRead;
|
|
||||||
while ((bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false)) != 0)
|
|
||||||
{
|
|
||||||
var bytesToCopy = Math.Min(bytesRead, copyLength);
|
|
||||||
|
|
||||||
await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToCopy), cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
copyLength -= bytesToCopy;
|
|
||||||
|
|
||||||
if (copyLength <= 0)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
ArrayPool<byte>.Shared.Return(array);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,113 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Text;
|
|
||||||
using MediaBrowser.Controller.Net;
|
|
||||||
using MediaBrowser.Model.Services;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Net.Http.Headers;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.HttpServer
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Class ResponseFilter.
|
|
||||||
/// </summary>
|
|
||||||
public class ResponseFilter
|
|
||||||
{
|
|
||||||
private readonly IHttpServer _server;
|
|
||||||
private readonly ILogger _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="ResponseFilter"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="server">The HTTP server.</param>
|
|
||||||
/// <param name="logger">The logger.</param>
|
|
||||||
public ResponseFilter(IHttpServer server, ILogger logger)
|
|
||||||
{
|
|
||||||
_server = server;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Filters the response.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="req">The req.</param>
|
|
||||||
/// <param name="res">The res.</param>
|
|
||||||
/// <param name="dto">The dto.</param>
|
|
||||||
public void FilterResponse(IRequest req, HttpResponse res, object dto)
|
|
||||||
{
|
|
||||||
foreach(var (key, value) in _server.GetDefaultCorsHeaders(req))
|
|
||||||
{
|
|
||||||
res.Headers.Add(key, value);
|
|
||||||
}
|
|
||||||
// Try to prevent compatibility view
|
|
||||||
res.Headers["Access-Control-Allow-Headers"] = "Accept, Accept-Language, Authorization, Cache-Control, " +
|
|
||||||
"Content-Disposition, Content-Encoding, Content-Language, Content-Length, Content-MD5, Content-Range, " +
|
|
||||||
"Content-Type, Cookie, Date, Host, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, " +
|
|
||||||
"Origin, OriginToken, Pragma, Range, Slug, Transfer-Encoding, Want-Digest, X-MediaBrowser-Token, " +
|
|
||||||
"X-Emby-Authorization";
|
|
||||||
|
|
||||||
if (dto is Exception exception)
|
|
||||||
{
|
|
||||||
_logger.LogError(exception, "Error processing request for {RawUrl}", req.RawUrl);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(exception.Message))
|
|
||||||
{
|
|
||||||
var error = exception.Message.Replace(Environment.NewLine, " ", StringComparison.Ordinal);
|
|
||||||
error = RemoveControlCharacters(error);
|
|
||||||
|
|
||||||
res.Headers.Add("X-Application-Error-Code", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto is IHasHeaders hasHeaders)
|
|
||||||
{
|
|
||||||
if (!hasHeaders.Headers.ContainsKey(HeaderNames.Server))
|
|
||||||
{
|
|
||||||
hasHeaders.Headers[HeaderNames.Server] = "Microsoft-NetCore/2.0, UPnP/1.0 DLNADOC/1.50";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content length has to be explicitly set on on HttpListenerResponse or it won't be happy
|
|
||||||
if (hasHeaders.Headers.TryGetValue(HeaderNames.ContentLength, out string contentLength)
|
|
||||||
&& !string.IsNullOrEmpty(contentLength))
|
|
||||||
{
|
|
||||||
var length = long.Parse(contentLength, CultureInfo.InvariantCulture);
|
|
||||||
|
|
||||||
if (length > 0)
|
|
||||||
{
|
|
||||||
res.ContentLength = length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes the control characters.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="inString">The in string.</param>
|
|
||||||
/// <returns>System.String.</returns>
|
|
||||||
public static string RemoveControlCharacters(string inString)
|
|
||||||
{
|
|
||||||
if (inString == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
else if (inString.Length == 0)
|
|
||||||
{
|
|
||||||
return inString;
|
|
||||||
}
|
|
||||||
|
|
||||||
var newString = new StringBuilder(inString.Length);
|
|
||||||
|
|
||||||
foreach (var ch in inString)
|
|
||||||
{
|
|
||||||
if (!char.IsControl(ch))
|
|
||||||
{
|
|
||||||
newString.Append(ch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newString.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,120 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using MediaBrowser.Model.Services;
|
|
||||||
using Microsoft.Net.Http.Headers;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.HttpServer
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Class StreamWriter.
|
|
||||||
/// </summary>
|
|
||||||
public class StreamWriter : IAsyncStreamWriter, IHasHeaders
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The options.
|
|
||||||
/// </summary>
|
|
||||||
private readonly IDictionary<string, string> _options = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="StreamWriter" /> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="source">The source.</param>
|
|
||||||
/// <param name="contentType">Type of the content.</param>
|
|
||||||
public StreamWriter(Stream source, string contentType)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(contentType))
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(contentType));
|
|
||||||
}
|
|
||||||
|
|
||||||
SourceStream = source;
|
|
||||||
|
|
||||||
Headers["Content-Type"] = contentType;
|
|
||||||
|
|
||||||
if (source.CanSeek)
|
|
||||||
{
|
|
||||||
Headers[HeaderNames.ContentLength] = source.Length.ToString(CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
Headers[HeaderNames.ContentType] = contentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="StreamWriter"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="source">The source.</param>
|
|
||||||
/// <param name="contentType">Type of the content.</param>
|
|
||||||
/// <param name="contentLength">The content length.</param>
|
|
||||||
public StreamWriter(byte[] source, string contentType, int contentLength)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(contentType))
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(contentType));
|
|
||||||
}
|
|
||||||
|
|
||||||
SourceBytes = source;
|
|
||||||
|
|
||||||
Headers[HeaderNames.ContentLength] = contentLength.ToString(CultureInfo.InvariantCulture);
|
|
||||||
Headers[HeaderNames.ContentType] = contentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the source stream.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The source stream.</value>
|
|
||||||
private Stream SourceStream { get; set; }
|
|
||||||
|
|
||||||
private byte[] SourceBytes { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the options.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The options.</value>
|
|
||||||
public IDictionary<string, string> Headers => _options;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fires when complete.
|
|
||||||
/// </summary>
|
|
||||||
public Action OnComplete { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fires when an error occours.
|
|
||||||
/// </summary>
|
|
||||||
public Action OnError { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var bytes = SourceBytes;
|
|
||||||
|
|
||||||
if (bytes != null)
|
|
||||||
{
|
|
||||||
await responseStream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
using (var src = SourceStream)
|
|
||||||
{
|
|
||||||
await src.CopyToAsync(responseStream, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
OnError?.Invoke();
|
|
||||||
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
OnComplete?.Invoke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,102 @@
|
|||||||
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Events;
|
||||||
|
using MediaBrowser.Controller.Net;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.HttpServer
|
||||||
|
{
|
||||||
|
public class WebSocketManager : IWebSocketManager
|
||||||
|
{
|
||||||
|
private readonly ILogger<WebSocketManager> _logger;
|
||||||
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
|
||||||
|
private IWebSocketListener[] _webSocketListeners = Array.Empty<IWebSocketListener>();
|
||||||
|
private bool _disposed = false;
|
||||||
|
|
||||||
|
public WebSocketManager(
|
||||||
|
ILogger<WebSocketManager> logger,
|
||||||
|
ILoggerFactory loggerFactory)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_loggerFactory = loggerFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task WebSocketRequestHandler(HttpContext context)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress);
|
||||||
|
|
||||||
|
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
using var connection = new WebSocketConnection(
|
||||||
|
_loggerFactory.CreateLogger<WebSocketConnection>(),
|
||||||
|
webSocket,
|
||||||
|
context.Connection.RemoteIpAddress,
|
||||||
|
context.Request.Query)
|
||||||
|
{
|
||||||
|
OnReceive = ProcessWebSocketMessageReceived
|
||||||
|
};
|
||||||
|
|
||||||
|
WebSocketConnected?.Invoke(this, new GenericEventArgs<IWebSocketConnection>(connection));
|
||||||
|
|
||||||
|
await connection.ProcessAsync().ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress);
|
||||||
|
}
|
||||||
|
catch (Exception ex) // Otherwise ASP.Net will ignore the exception
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "WS {IP} WebSocketRequestHandler error", context.Connection.RemoteIpAddress);
|
||||||
|
if (!context.Response.HasStarted)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the rest handlers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="listeners">The web socket listeners.</param>
|
||||||
|
public void Init(IEnumerable<IWebSocketListener> listeners)
|
||||||
|
{
|
||||||
|
_webSocketListeners = listeners.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes the web socket message received.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="result">The result.</param>
|
||||||
|
private Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<Task> GetTasks()
|
||||||
|
{
|
||||||
|
foreach (var x in _webSocketListeners)
|
||||||
|
{
|
||||||
|
yield return x.ProcessMessageAsync(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.WhenAll(GetTasks());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,76 +1,117 @@
|
|||||||
{
|
{
|
||||||
"ProviderValue": "ผู้ให้บริการ: {0}",
|
"ProviderValue": "ผู้ให้บริการ: {0}",
|
||||||
"PluginUpdatedWithName": "{0} ได้รับการ update แล้ว",
|
"PluginUpdatedWithName": "อัปเดต {0} แล้ว",
|
||||||
"PluginUninstalledWithName": "ถอนการติดตั้ง {0}",
|
"PluginUninstalledWithName": "ถอนการติดตั้ง {0} แล้ว",
|
||||||
"PluginInstalledWithName": "{0} ได้รับการติดตั้ง",
|
"PluginInstalledWithName": "ติดตั้ง {0} แล้ว",
|
||||||
"Plugin": "Plugin",
|
"Plugin": "ปลั๊กอิน",
|
||||||
"Playlists": "รายการ",
|
"Playlists": "เพลย์ลิสต์",
|
||||||
"Photos": "รูปภาพ",
|
"Photos": "รูปภาพ",
|
||||||
"NotificationOptionVideoPlaybackStopped": "หยุดการเล่น Video",
|
"NotificationOptionVideoPlaybackStopped": "หยุดเล่นวิดีโอ",
|
||||||
"NotificationOptionVideoPlayback": "เริ่มแสดง Video",
|
"NotificationOptionVideoPlayback": "เริ่มเล่นวิดีโอ",
|
||||||
"NotificationOptionUserLockedOut": "ผู้ใช้ Locked Out",
|
"NotificationOptionUserLockedOut": "ผู้ใช้ถูกล็อก",
|
||||||
"NotificationOptionTaskFailed": "ตารางการทำงานล้มเหลว",
|
"NotificationOptionTaskFailed": "งานตามกำหนดการล้มเหลว",
|
||||||
"NotificationOptionServerRestartRequired": "ควร Restart Server",
|
"NotificationOptionServerRestartRequired": "จำเป็นต้องรีสตาร์ทเซิร์ฟเวอร์",
|
||||||
"NotificationOptionPluginUpdateInstalled": "Update Plugin แล้ว",
|
"NotificationOptionPluginUpdateInstalled": "ติดตั้งการอัปเดตปลั๊กอินแล้ว",
|
||||||
"NotificationOptionPluginUninstalled": "ถอด Plugin",
|
"NotificationOptionPluginUninstalled": "ถอนการติดตั้งปลั๊กอินแล้ว",
|
||||||
"NotificationOptionPluginInstalled": "ติดตั้ง Plugin แล้ว",
|
"NotificationOptionPluginInstalled": "ติดตั้งปลั๊กอินแล้ว",
|
||||||
"NotificationOptionPluginError": "Plugin ล้มเหลว",
|
"NotificationOptionPluginError": "ปลั๊กอินล้มเหลว",
|
||||||
"NotificationOptionNewLibraryContent": "เพิ่มข้อมูลใหม่แล้ว",
|
"NotificationOptionNewLibraryContent": "เพิ่มเนื้อหาใหม่แล้ว",
|
||||||
"NotificationOptionInstallationFailed": "ติดตั้งล้มเหลว",
|
"NotificationOptionInstallationFailed": "การติดตั้งล้มเหลว",
|
||||||
"NotificationOptionCameraImageUploaded": "รูปภาพถูก upload",
|
"NotificationOptionCameraImageUploaded": "อัปโหลดภาพถ่ายแล้ว",
|
||||||
"NotificationOptionAudioPlaybackStopped": "หยุดการเล่นเสียง",
|
"NotificationOptionAudioPlaybackStopped": "หยุดเล่นเสียง",
|
||||||
"NotificationOptionAudioPlayback": "เริ่มเล่นเสียง",
|
"NotificationOptionAudioPlayback": "เริ่มเล่นเสียง",
|
||||||
"NotificationOptionApplicationUpdateInstalled": "Update ระบบแล้ว",
|
"NotificationOptionApplicationUpdateInstalled": "ติดตั้งการอัปเดตแอพพลิเคชันแล้ว",
|
||||||
"NotificationOptionApplicationUpdateAvailable": "ระบบ update สามารถใช้ได้แล้ว",
|
"NotificationOptionApplicationUpdateAvailable": "มีการอัปเดตแอพพลิเคชัน",
|
||||||
"NewVersionIsAvailable": "ตรวจพบ Jellyfin เวอร์ชั่นใหม่",
|
"NewVersionIsAvailable": "เวอร์ชันใหม่ของเซิร์ฟเวอร์ Jellyfin พร้อมให้ดาวน์โหลดแล้ว",
|
||||||
"NameSeasonUnknown": "ไม่ทราบปี",
|
"NameSeasonUnknown": "ไม่ทราบซีซัน",
|
||||||
"NameSeasonNumber": "ปี {0}",
|
"NameSeasonNumber": "ซีซัน {0}",
|
||||||
"NameInstallFailed": "{0} ติดตั้งไม่สำเร็จ",
|
"NameInstallFailed": "การติดตั้ง {0} ล้มเหลว",
|
||||||
"MusicVideos": "MV",
|
"MusicVideos": "มิวสิควิดีโอ",
|
||||||
"Music": "เพลง",
|
"Music": "ดนตรี",
|
||||||
"Movies": "ภาพยนต์",
|
"Movies": "ภาพยนตร์",
|
||||||
"MixedContent": "รายการแบบผสม",
|
"MixedContent": "เนื้อหาผสม",
|
||||||
"MessageServerConfigurationUpdated": "การตั้งค่า update แล้ว",
|
"MessageServerConfigurationUpdated": "อัปเดตการกำหนดค่าเซิร์ฟเวอร์แล้ว",
|
||||||
"MessageNamedServerConfigurationUpdatedWithValue": "รายการตั้งค่า {0} ได้รับการ update แล้ว",
|
"MessageNamedServerConfigurationUpdatedWithValue": "อัปเดตการกำหนดค่าเซิร์ฟเวอร์ในส่วน {0} แล้ว",
|
||||||
"MessageApplicationUpdatedTo": "Jellyfin Server จะ update ไปที่ {0}",
|
"MessageApplicationUpdatedTo": "เซิร์ฟเวอร์ Jellyfin ได้รับการอัปเดตเป็น {0}",
|
||||||
"MessageApplicationUpdated": "Jellyfin Server update แล้ว",
|
"MessageApplicationUpdated": "อัพเดตเซิร์ฟเวอร์ Jellyfin แล้ว",
|
||||||
"Latest": "ล่าสุด",
|
"Latest": "ล่าสุด",
|
||||||
"LabelRunningTimeValue": "เวลาที่เล่น : {0}",
|
"LabelRunningTimeValue": "ผ่านไปแล้ว: {0}",
|
||||||
"LabelIpAddressValue": "IP address: {0}",
|
"LabelIpAddressValue": "ที่อยู่ IP: {0}",
|
||||||
"ItemRemovedWithName": "{0} ถูกลบจากรายการ",
|
"ItemRemovedWithName": "{0} ถูกลบออกจากไลบรารี",
|
||||||
"ItemAddedWithName": "{0} ถูกเพิ่มในรายการ",
|
"ItemAddedWithName": "{0} ถูกเพิ่มลงในไลบรารีแล้ว",
|
||||||
"Inherit": "การสืบทอด",
|
"Inherit": "สืบทอด",
|
||||||
"HomeVideos": "วีดีโอส่วนตัว",
|
"HomeVideos": "โฮมวิดีโอ",
|
||||||
"HeaderRecordingGroups": "ค่ายบันทึก",
|
"HeaderRecordingGroups": "กลุ่มการบันทึก",
|
||||||
"HeaderNextUp": "ถัดไป",
|
"HeaderNextUp": "ถัดไป",
|
||||||
"HeaderLiveTV": "รายการสด",
|
"HeaderLiveTV": "ทีวีสด",
|
||||||
"HeaderFavoriteSongs": "เพลงโปรด",
|
"HeaderFavoriteSongs": "เพลงที่ชื่นชอบ",
|
||||||
"HeaderFavoriteShows": "รายการโชว์โปรด",
|
"HeaderFavoriteShows": "รายการที่ชื่นชอบ",
|
||||||
"HeaderFavoriteEpisodes": "ฉากโปรด",
|
"HeaderFavoriteEpisodes": "ตอนที่ชื่นชอบ",
|
||||||
"HeaderFavoriteArtists": "นักแสดงโปรด",
|
"HeaderFavoriteArtists": "ศิลปินที่ชื่นชอบ",
|
||||||
"HeaderFavoriteAlbums": "อัมบั้มโปรด",
|
"HeaderFavoriteAlbums": "อัมบั้มที่ชื่นชอบ",
|
||||||
"HeaderContinueWatching": "ชมต่อจากเดิม",
|
"HeaderContinueWatching": "ดูต่อ",
|
||||||
"HeaderCameraUploads": "Upload รูปภาพ",
|
"HeaderCameraUploads": "อัปโหลดรูปถ่าย",
|
||||||
"HeaderAlbumArtists": "อัลบั้มนักแสดง",
|
"HeaderAlbumArtists": "อัลบั้มศิลปิน",
|
||||||
"Genres": "ประเภท",
|
"Genres": "ประเภท",
|
||||||
"Folders": "โฟลเดอร์",
|
"Folders": "โฟลเดอร์",
|
||||||
"Favorites": "รายการโปรด",
|
"Favorites": "รายการโปรด",
|
||||||
"FailedLoginAttemptWithUserName": "การเชื่อมต่อล้มเหลวจาก {0}",
|
"FailedLoginAttemptWithUserName": "ความพยายามในการเข้าสู่ระบบล้มเหลวจาก {0}",
|
||||||
"DeviceOnlineWithName": "{0} เชื่อมต่อสำเร็จ",
|
"DeviceOnlineWithName": "{0} เชื่อมต่อสำเร็จแล้ว",
|
||||||
"DeviceOfflineWithName": "{0} ตัดการเชื่อมต่อ",
|
"DeviceOfflineWithName": "{0} ยกเลิกการเชื่อมต่อแล้ว",
|
||||||
"Collections": "ชุด",
|
"Collections": "คอลเลกชัน",
|
||||||
"ChapterNameValue": "บทที่ {0}",
|
"ChapterNameValue": "บท {0}",
|
||||||
"Channels": "ชาแนล",
|
"Channels": "ช่อง",
|
||||||
"CameraImageUploadedFrom": "รูปภาพถูก upload จาก {0}",
|
"CameraImageUploadedFrom": "ภาพถ่ายใหม่ได้ถูกอัปโหลดมาจาก {0}",
|
||||||
"Books": "หนังสือ",
|
"Books": "หนังสือ",
|
||||||
"AuthenticationSucceededWithUserName": "{0} ยืนยันตัวสำเร็จ",
|
"AuthenticationSucceededWithUserName": "{0} ยืนยันตัวสำเร็จแล้ว",
|
||||||
"Artists": "นักแสดง",
|
"Artists": "ศิลปิน",
|
||||||
"Application": "แอปพลิเคชั่น",
|
"Application": "แอพพลิเคชัน",
|
||||||
"AppDeviceValues": "App: {0}, อุปกรณ์: {1}",
|
"AppDeviceValues": "แอพ: {0}, อุปกรณ์: {1}",
|
||||||
"Albums": "อัลบั้ม",
|
"Albums": "อัลบั้ม",
|
||||||
"ScheduledTaskStartedWithName": "{0} เริ่มต้น",
|
"ScheduledTaskStartedWithName": "{0} เริ่มต้น",
|
||||||
"ScheduledTaskFailedWithName": "{0} ล้มเหลว",
|
"ScheduledTaskFailedWithName": "{0} ล้มเหลว",
|
||||||
"Songs": "เพลง",
|
"Songs": "เพลง",
|
||||||
"Shows": "แสดง",
|
"Shows": "รายการ",
|
||||||
"ServerNameNeedsToBeRestarted": "{0} ต้องการรีสตาร์ท"
|
"ServerNameNeedsToBeRestarted": "{0} ต้องการการรีสตาร์ท",
|
||||||
|
"TaskDownloadMissingSubtitlesDescription": "ค้นหาคำบรรยายที่หายไปในอินเทอร์เน็ตตามค่ากำหนดในข้อมูลเมตา",
|
||||||
|
"TaskDownloadMissingSubtitles": "ดาวน์โหลดคำบรรยายที่ขาดหายไป",
|
||||||
|
"TaskRefreshChannelsDescription": "รีเฟรชข้อมูลช่องอินเทอร์เน็ต",
|
||||||
|
"TaskRefreshChannels": "รีเฟรชช่อง",
|
||||||
|
"TaskCleanTranscodeDescription": "ลบไฟล์ทรานส์โค้ดที่มีอายุมากกว่าหนึ่งวัน",
|
||||||
|
"TaskCleanTranscode": "ล้างไดเรกทอรีทรานส์โค้ด",
|
||||||
|
"TaskUpdatePluginsDescription": "ดาวน์โหลดและติดตั้งโปรแกรมปรับปรุงให้กับปลั๊กอินที่กำหนดค่าให้อัปเดตโดยอัตโนมัติ",
|
||||||
|
"TaskUpdatePlugins": "อัปเดตปลั๊กอิน",
|
||||||
|
"TaskRefreshPeopleDescription": "อัปเดตข้อมูลเมตานักแสดงและผู้กำกับในไลบรารีสื่อ",
|
||||||
|
"TaskRefreshPeople": "รีเฟรชบุคคล",
|
||||||
|
"TaskCleanLogsDescription": "ลบไฟล์บันทึกที่เก่ากว่า {0} วัน",
|
||||||
|
"TaskCleanLogs": "ล้างไดเรกทอรีบันทึก",
|
||||||
|
"TaskRefreshLibraryDescription": "สแกนไลบรารีสื่อของคุณเพื่อหาไฟล์ใหม่และรีเฟรชข้อมูลเมตา",
|
||||||
|
"TaskRefreshLibrary": "สแกนไลบรารีสื่อ",
|
||||||
|
"TaskRefreshChapterImagesDescription": "สร้างภาพขนาดย่อสำหรับวิดีโอที่มีบท",
|
||||||
|
"TaskRefreshChapterImages": "แตกรูปภาพบท",
|
||||||
|
"TaskCleanCacheDescription": "ลบไฟล์แคชที่ระบบไม่ต้องการ",
|
||||||
|
"TaskCleanCache": "ล้างไดเรกทอรีแคช",
|
||||||
|
"TasksChannelsCategory": "ช่องอินเทอร์เน็ต",
|
||||||
|
"TasksApplicationCategory": "แอพพลิเคชัน",
|
||||||
|
"TasksLibraryCategory": "ไลบรารี",
|
||||||
|
"TasksMaintenanceCategory": "ปิดซ่อมบำรุง",
|
||||||
|
"VersionNumber": "เวอร์ชัน {0}",
|
||||||
|
"ValueSpecialEpisodeName": "พิเศษ - {0}",
|
||||||
|
"ValueHasBeenAddedToLibrary": "เพิ่ม {0} ลงในไลบรารีสื่อของคุณแล้ว",
|
||||||
|
"UserStoppedPlayingItemWithValues": "{0} เล่นเสร็จแล้ว {1} บน {2}",
|
||||||
|
"UserStartedPlayingItemWithValues": "{0} กำลังเล่น {1} บน {2}",
|
||||||
|
"UserPolicyUpdatedWithName": "มีการอัปเดตนโยบายผู้ใช้ของ {0}",
|
||||||
|
"UserPasswordChangedWithName": "มีการเปลี่ยนรหัสผ่านของผู้ใช้ {0}",
|
||||||
|
"UserOnlineFromDevice": "{0} ออนไลน์จาก {1}",
|
||||||
|
"UserOfflineFromDevice": "{0} ได้ยกเลิกการเชื่อมต่อจาก {1}",
|
||||||
|
"UserLockedOutWithName": "ผู้ใช้ {0} ถูกล็อก",
|
||||||
|
"UserDownloadingItemWithValues": "{0} กำลังดาวน์โหลด {1}",
|
||||||
|
"UserDeletedWithName": "ลบผู้ใช้ {0} แล้ว",
|
||||||
|
"UserCreatedWithName": "สร้างผู้ใช้ {0} แล้ว",
|
||||||
|
"User": "ผู้ใช้งาน",
|
||||||
|
"TvShows": "รายการทีวี",
|
||||||
|
"System": "ระบบ",
|
||||||
|
"Sync": "ซิงค์",
|
||||||
|
"SubtitleDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดคำบรรยายจาก {0} สำหรับ {1} ได้",
|
||||||
|
"StartupEmbyServerIsLoading": "กำลังโหลดเซิร์ฟเวอร์ Jellyfin โปรดลองอีกครั้งในอีกสักครู่"
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,285 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using MediaBrowser.Common;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Authentication;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using MediaBrowser.Controller.Net;
|
||||||
|
using MediaBrowser.Controller.QuickConnect;
|
||||||
|
using MediaBrowser.Controller.Security;
|
||||||
|
using MediaBrowser.Model.QuickConnect;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.QuickConnect
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Quick connect implementation.
|
||||||
|
/// </summary>
|
||||||
|
public class QuickConnectManager : IQuickConnect, IDisposable
|
||||||
|
{
|
||||||
|
private readonly RNGCryptoServiceProvider _rng = new RNGCryptoServiceProvider();
|
||||||
|
private readonly ConcurrentDictionary<string, QuickConnectResult> _currentRequests = new ConcurrentDictionary<string, QuickConnectResult>();
|
||||||
|
|
||||||
|
private readonly IServerConfigurationManager _config;
|
||||||
|
private readonly ILogger<QuickConnectManager> _logger;
|
||||||
|
private readonly IAuthenticationRepository _authenticationRepository;
|
||||||
|
private readonly IAuthorizationContext _authContext;
|
||||||
|
private readonly IServerApplicationHost _appHost;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="QuickConnectManager"/> class.
|
||||||
|
/// Should only be called at server startup when a singleton is created.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">Configuration.</param>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
/// <param name="appHost">Application host.</param>
|
||||||
|
/// <param name="authContext">Authentication context.</param>
|
||||||
|
/// <param name="authenticationRepository">Authentication repository.</param>
|
||||||
|
public QuickConnectManager(
|
||||||
|
IServerConfigurationManager config,
|
||||||
|
ILogger<QuickConnectManager> logger,
|
||||||
|
IServerApplicationHost appHost,
|
||||||
|
IAuthorizationContext authContext,
|
||||||
|
IAuthenticationRepository authenticationRepository)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
_appHost = appHost;
|
||||||
|
_authContext = authContext;
|
||||||
|
_authenticationRepository = authenticationRepository;
|
||||||
|
|
||||||
|
ReloadConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public int CodeLength { get; set; } = 6;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string TokenName { get; set; } = "QuickConnect";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public QuickConnectState State { get; private set; } = QuickConnectState.Unavailable;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public int Timeout { get; set; } = 5;
|
||||||
|
|
||||||
|
private DateTime DateActivated { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void AssertActive()
|
||||||
|
{
|
||||||
|
if (State != QuickConnectState.Active)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Quick connect is not active on this server");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Activate()
|
||||||
|
{
|
||||||
|
DateActivated = DateTime.UtcNow;
|
||||||
|
SetState(QuickConnectState.Active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void SetState(QuickConnectState newState)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Changed quick connect state from {State} to {newState}", State, newState);
|
||||||
|
|
||||||
|
ExpireRequests(true);
|
||||||
|
|
||||||
|
State = newState;
|
||||||
|
_config.Configuration.QuickConnectAvailable = newState == QuickConnectState.Available || newState == QuickConnectState.Active;
|
||||||
|
_config.SaveConfiguration();
|
||||||
|
|
||||||
|
_logger.LogDebug("Configuration saved");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public QuickConnectResult TryConnect()
|
||||||
|
{
|
||||||
|
ExpireRequests();
|
||||||
|
|
||||||
|
if (State != QuickConnectState.Active)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Refusing quick connect initiation request, current state is {State}", State);
|
||||||
|
throw new AuthenticationException("Quick connect is not active on this server");
|
||||||
|
}
|
||||||
|
|
||||||
|
var code = GenerateCode();
|
||||||
|
var result = new QuickConnectResult()
|
||||||
|
{
|
||||||
|
Secret = GenerateSecureRandom(),
|
||||||
|
DateAdded = DateTime.UtcNow,
|
||||||
|
Code = code
|
||||||
|
};
|
||||||
|
|
||||||
|
_currentRequests[code] = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public QuickConnectResult CheckRequestStatus(string secret)
|
||||||
|
{
|
||||||
|
ExpireRequests();
|
||||||
|
AssertActive();
|
||||||
|
|
||||||
|
string code = _currentRequests.Where(x => x.Value.Secret == secret).Select(x => x.Value.Code).DefaultIfEmpty(string.Empty).First();
|
||||||
|
|
||||||
|
if (!_currentRequests.TryGetValue(code, out QuickConnectResult result))
|
||||||
|
{
|
||||||
|
throw new ResourceNotFoundException("Unable to find request with provided secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string GenerateCode()
|
||||||
|
{
|
||||||
|
Span<byte> raw = stackalloc byte[4];
|
||||||
|
|
||||||
|
int min = (int)Math.Pow(10, CodeLength - 1);
|
||||||
|
int max = (int)Math.Pow(10, CodeLength);
|
||||||
|
|
||||||
|
uint scale = uint.MaxValue;
|
||||||
|
while (scale == uint.MaxValue)
|
||||||
|
{
|
||||||
|
_rng.GetBytes(raw);
|
||||||
|
scale = BitConverter.ToUInt32(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
int code = (int)(min + ((max - min) * (scale / (double)uint.MaxValue)));
|
||||||
|
return code.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool AuthorizeRequest(Guid userId, string code)
|
||||||
|
{
|
||||||
|
ExpireRequests();
|
||||||
|
AssertActive();
|
||||||
|
|
||||||
|
if (!_currentRequests.TryGetValue(code, out QuickConnectResult result))
|
||||||
|
{
|
||||||
|
throw new ResourceNotFoundException("Unable to find request");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Authenticated)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Request is already authorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Authentication = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
// Change the time on the request so it expires one minute into the future. It can't expire immediately as otherwise some clients wouldn't ever see that they have been authenticated.
|
||||||
|
var added = result.DateAdded ?? DateTime.UtcNow.Subtract(TimeSpan.FromMinutes(Timeout));
|
||||||
|
result.DateAdded = added.Subtract(TimeSpan.FromMinutes(Timeout - 1));
|
||||||
|
|
||||||
|
_authenticationRepository.Create(new AuthenticationInfo
|
||||||
|
{
|
||||||
|
AppName = TokenName,
|
||||||
|
AccessToken = result.Authentication,
|
||||||
|
DateCreated = DateTime.UtcNow,
|
||||||
|
DeviceId = _appHost.SystemId,
|
||||||
|
DeviceName = _appHost.FriendlyName,
|
||||||
|
AppVersion = _appHost.ApplicationVersionString,
|
||||||
|
UserId = userId
|
||||||
|
});
|
||||||
|
|
||||||
|
_logger.LogDebug("Authorizing device with code {Code} to login as user {userId}", code, userId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public int DeleteAllDevices(Guid user)
|
||||||
|
{
|
||||||
|
var raw = _authenticationRepository.Get(new AuthenticationInfoQuery()
|
||||||
|
{
|
||||||
|
DeviceId = _appHost.SystemId,
|
||||||
|
UserId = user
|
||||||
|
});
|
||||||
|
|
||||||
|
var tokens = raw.Items.Where(x => x.AppName.StartsWith(TokenName, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
var removed = 0;
|
||||||
|
foreach (var token in tokens)
|
||||||
|
{
|
||||||
|
_authenticationRepository.Delete(token);
|
||||||
|
_logger.LogDebug("Deleted token {AccessToken}", token.AccessToken);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dispose.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dispose.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="disposing">Dispose unmanaged resources.</param>
|
||||||
|
protected virtual void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_rng?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GenerateSecureRandom(int length = 32)
|
||||||
|
{
|
||||||
|
Span<byte> bytes = stackalloc byte[length];
|
||||||
|
_rng.GetBytes(bytes);
|
||||||
|
|
||||||
|
return Hex.Encode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void ExpireRequests(bool expireAll = false)
|
||||||
|
{
|
||||||
|
// Check if quick connect should be deactivated
|
||||||
|
if (State == QuickConnectState.Active && DateTime.UtcNow > DateActivated.AddMinutes(Timeout) && !expireAll)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Quick connect time expired, deactivating");
|
||||||
|
SetState(QuickConnectState.Available);
|
||||||
|
expireAll = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expire stale connection requests
|
||||||
|
var code = string.Empty;
|
||||||
|
var values = _currentRequests.Values.ToList();
|
||||||
|
|
||||||
|
for (int i = 0; i < values.Count; i++)
|
||||||
|
{
|
||||||
|
var added = values[i].DateAdded ?? DateTime.UnixEpoch;
|
||||||
|
if (DateTime.UtcNow > added.AddMinutes(Timeout) || expireAll)
|
||||||
|
{
|
||||||
|
code = values[i].Code;
|
||||||
|
_logger.LogDebug("Removing expired request {code}", code);
|
||||||
|
|
||||||
|
if (!_currentRequests.TryRemove(code, out _))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Request {code} already expired", code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReloadConfiguration()
|
||||||
|
{
|
||||||
|
State = _config.Configuration.QuickConnectAvailable ? QuickConnectState.Available : QuickConnectState.Unavailable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,64 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Net;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using MediaBrowser.Model.Services;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Services
|
|
||||||
{
|
|
||||||
public class HttpResult
|
|
||||||
: IHttpResult, IAsyncStreamWriter
|
|
||||||
{
|
|
||||||
public HttpResult(object response, string contentType, HttpStatusCode statusCode)
|
|
||||||
{
|
|
||||||
this.Headers = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
this.Response = response;
|
|
||||||
this.ContentType = contentType;
|
|
||||||
this.StatusCode = statusCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public object Response { get; set; }
|
|
||||||
|
|
||||||
public string ContentType { get; set; }
|
|
||||||
|
|
||||||
public IDictionary<string, string> Headers { get; private set; }
|
|
||||||
|
|
||||||
public int Status { get; set; }
|
|
||||||
|
|
||||||
public HttpStatusCode StatusCode
|
|
||||||
{
|
|
||||||
get => (HttpStatusCode)Status;
|
|
||||||
set => Status = (int)value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IRequest RequestContext { get; set; }
|
|
||||||
|
|
||||||
public async Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var response = RequestContext?.Response;
|
|
||||||
|
|
||||||
if (this.Response is byte[] bytesResponse)
|
|
||||||
{
|
|
||||||
var contentLength = bytesResponse.Length;
|
|
||||||
|
|
||||||
if (response != null)
|
|
||||||
{
|
|
||||||
response.ContentLength = contentLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentLength > 0)
|
|
||||||
{
|
|
||||||
await responseStream.WriteAsync(bytesResponse, 0, contentLength, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ResponseHelper.WriteObject(this.RequestContext, this.Response, response).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,51 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Emby.Server.Implementations.HttpServer;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Services
|
|
||||||
{
|
|
||||||
public class RequestHelper
|
|
||||||
{
|
|
||||||
public static Func<Type, Stream, Task<object>> GetRequestReader(HttpListenerHost host, string contentType)
|
|
||||||
{
|
|
||||||
switch (GetContentTypeWithoutEncoding(contentType))
|
|
||||||
{
|
|
||||||
case "application/xml":
|
|
||||||
case "text/xml":
|
|
||||||
case "text/xml; charset=utf-8": //"text/xml; charset=utf-8" also matches xml
|
|
||||||
return host.DeserializeXml;
|
|
||||||
|
|
||||||
case "application/json":
|
|
||||||
case "text/json":
|
|
||||||
return host.DeserializeJson;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Action<object, Stream> GetResponseWriter(HttpListenerHost host, string contentType)
|
|
||||||
{
|
|
||||||
switch (GetContentTypeWithoutEncoding(contentType))
|
|
||||||
{
|
|
||||||
case "application/xml":
|
|
||||||
case "text/xml":
|
|
||||||
case "text/xml; charset=utf-8": //"text/xml; charset=utf-8" also matches xml
|
|
||||||
return host.SerializeToXml;
|
|
||||||
|
|
||||||
case "application/json":
|
|
||||||
case "text/json":
|
|
||||||
return host.SerializeToJson;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetContentTypeWithoutEncoding(string contentType)
|
|
||||||
{
|
|
||||||
return contentType?.Split(';')[0].ToLowerInvariant().Trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,141 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Net;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Emby.Server.Implementations.HttpServer;
|
|
||||||
using MediaBrowser.Model.Services;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Services
|
|
||||||
{
|
|
||||||
public static class ResponseHelper
|
|
||||||
{
|
|
||||||
public static Task WriteToResponse(HttpResponse response, IRequest request, object result, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (result == null)
|
|
||||||
{
|
|
||||||
if (response.StatusCode == (int)HttpStatusCode.OK)
|
|
||||||
{
|
|
||||||
response.StatusCode = (int)HttpStatusCode.NoContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
response.ContentLength = 0;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
var httpResult = result as IHttpResult;
|
|
||||||
if (httpResult != null)
|
|
||||||
{
|
|
||||||
httpResult.RequestContext = request;
|
|
||||||
request.ResponseContentType = httpResult.ContentType ?? request.ResponseContentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaultContentType = request.ResponseContentType;
|
|
||||||
|
|
||||||
if (httpResult != null)
|
|
||||||
{
|
|
||||||
if (httpResult.RequestContext == null)
|
|
||||||
{
|
|
||||||
httpResult.RequestContext = request;
|
|
||||||
}
|
|
||||||
|
|
||||||
response.StatusCode = httpResult.Status;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result is IHasHeaders responseOptions)
|
|
||||||
{
|
|
||||||
foreach (var responseHeaders in responseOptions.Headers)
|
|
||||||
{
|
|
||||||
if (string.Equals(responseHeaders.Key, "Content-Length", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
response.ContentLength = long.Parse(responseHeaders.Value, CultureInfo.InvariantCulture);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
response.Headers.Add(responseHeaders.Key, responseHeaders.Value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContentType='text/html' is the default for a HttpResponse
|
|
||||||
// Do not override if another has been set
|
|
||||||
if (response.ContentType == null || response.ContentType == "text/html")
|
|
||||||
{
|
|
||||||
response.ContentType = defaultContentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.ContentType == "application/json")
|
|
||||||
{
|
|
||||||
response.ContentType += "; charset=utf-8";
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (result)
|
|
||||||
{
|
|
||||||
case IAsyncStreamWriter asyncStreamWriter:
|
|
||||||
return asyncStreamWriter.WriteToAsync(response.Body, cancellationToken);
|
|
||||||
case IStreamWriter streamWriter:
|
|
||||||
streamWriter.WriteTo(response.Body);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
case FileWriter fileWriter:
|
|
||||||
return fileWriter.WriteToAsync(response, cancellationToken);
|
|
||||||
case Stream stream:
|
|
||||||
return CopyStream(stream, response.Body);
|
|
||||||
case byte[] bytes:
|
|
||||||
response.ContentType = "application/octet-stream";
|
|
||||||
response.ContentLength = bytes.Length;
|
|
||||||
|
|
||||||
if (bytes.Length > 0)
|
|
||||||
{
|
|
||||||
return response.Body.WriteAsync(bytes, 0, bytes.Length, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
case string responseText:
|
|
||||||
var responseTextAsBytes = Encoding.UTF8.GetBytes(responseText);
|
|
||||||
response.ContentLength = responseTextAsBytes.Length;
|
|
||||||
|
|
||||||
if (responseTextAsBytes.Length > 0)
|
|
||||||
{
|
|
||||||
return response.Body.WriteAsync(responseTextAsBytes, 0, responseTextAsBytes.Length, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
return WriteObject(request, result, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task CopyStream(Stream src, Stream dest)
|
|
||||||
{
|
|
||||||
using (src)
|
|
||||||
{
|
|
||||||
await src.CopyToAsync(dest).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task WriteObject(IRequest request, object result, HttpResponse response)
|
|
||||||
{
|
|
||||||
var contentType = request.ResponseContentType;
|
|
||||||
var serializer = RequestHelper.GetResponseWriter(HttpListenerHost.Instance, contentType);
|
|
||||||
|
|
||||||
using (var ms = new MemoryStream())
|
|
||||||
{
|
|
||||||
serializer(result, ms);
|
|
||||||
|
|
||||||
ms.Position = 0;
|
|
||||||
|
|
||||||
var contentLength = ms.Length;
|
|
||||||
response.ContentLength = contentLength;
|
|
||||||
|
|
||||||
if (contentLength > 0)
|
|
||||||
{
|
|
||||||
await ms.CopyToAsync(response.Body).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,202 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Emby.Server.Implementations.HttpServer;
|
|
||||||
using MediaBrowser.Model.Services;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Services
|
|
||||||
{
|
|
||||||
public delegate object ActionInvokerFn(object intance, object request);
|
|
||||||
|
|
||||||
public delegate void VoidActionInvokerFn(object intance, object request);
|
|
||||||
|
|
||||||
public class ServiceController
|
|
||||||
{
|
|
||||||
private readonly ILogger<ServiceController> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="ServiceController"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="logger">The <see cref="ServiceController"/> logger.</param>
|
|
||||||
public ServiceController(ILogger<ServiceController> logger)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Init(HttpListenerHost appHost, IEnumerable<Type> serviceTypes)
|
|
||||||
{
|
|
||||||
foreach (var serviceType in serviceTypes)
|
|
||||||
{
|
|
||||||
RegisterService(appHost, serviceType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RegisterService(HttpListenerHost appHost, Type serviceType)
|
|
||||||
{
|
|
||||||
// Make sure the provided type implements IService
|
|
||||||
if (!typeof(IService).IsAssignableFrom(serviceType))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Tried to register a service that does not implement IService: {ServiceType}", serviceType);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var processedReqs = new HashSet<Type>();
|
|
||||||
|
|
||||||
var actions = ServiceExecGeneral.Reset(serviceType);
|
|
||||||
|
|
||||||
foreach (var mi in serviceType.GetActions())
|
|
||||||
{
|
|
||||||
var requestType = mi.GetParameters()[0].ParameterType;
|
|
||||||
if (processedReqs.Contains(requestType))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
processedReqs.Add(requestType);
|
|
||||||
|
|
||||||
ServiceExecGeneral.CreateServiceRunnersFor(requestType, actions);
|
|
||||||
|
|
||||||
// var returnMarker = GetTypeWithGenericTypeDefinitionOf(requestType, typeof(IReturn<>));
|
|
||||||
// var responseType = returnMarker != null ?
|
|
||||||
// GetGenericArguments(returnMarker)[0]
|
|
||||||
// : mi.ReturnType != typeof(object) && mi.ReturnType != typeof(void) ?
|
|
||||||
// mi.ReturnType
|
|
||||||
// : Type.GetType(requestType.FullName + "Response");
|
|
||||||
|
|
||||||
RegisterRestPaths(appHost, requestType, serviceType);
|
|
||||||
|
|
||||||
appHost.AddServiceInfo(serviceType, requestType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public readonly RestPath.RestPathMap RestPathMap = new RestPath.RestPathMap();
|
|
||||||
|
|
||||||
public void RegisterRestPaths(HttpListenerHost appHost, Type requestType, Type serviceType)
|
|
||||||
{
|
|
||||||
var attrs = appHost.GetRouteAttributes(requestType);
|
|
||||||
foreach (var attr in attrs)
|
|
||||||
{
|
|
||||||
var restPath = new RestPath(appHost.CreateInstance, appHost.GetParseFn, requestType, serviceType, attr.Path, attr.Verbs, attr.IsHidden, attr.Summary, attr.Description);
|
|
||||||
|
|
||||||
RegisterRestPath(restPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static readonly char[] InvalidRouteChars = new[] { '?', '&' };
|
|
||||||
|
|
||||||
public void RegisterRestPath(RestPath restPath)
|
|
||||||
{
|
|
||||||
if (restPath.Path[0] != '/')
|
|
||||||
{
|
|
||||||
throw new ArgumentException(
|
|
||||||
string.Format(
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
"Route '{0}' on '{1}' must start with a '/'",
|
|
||||||
restPath.Path,
|
|
||||||
restPath.RequestType.GetMethodName()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (restPath.Path.IndexOfAny(InvalidRouteChars) != -1)
|
|
||||||
{
|
|
||||||
throw new ArgumentException(
|
|
||||||
string.Format(
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
"Route '{0}' on '{1}' contains invalid chars. ",
|
|
||||||
restPath.Path,
|
|
||||||
restPath.RequestType.GetMethodName()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (RestPathMap.TryGetValue(restPath.FirstMatchHashKey, out List<RestPath> pathsAtFirstMatch))
|
|
||||||
{
|
|
||||||
pathsAtFirstMatch.Add(restPath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
RestPathMap[restPath.FirstMatchHashKey] = new List<RestPath>() { restPath };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public RestPath GetRestPathForRequest(string httpMethod, string pathInfo)
|
|
||||||
{
|
|
||||||
var matchUsingPathParts = RestPath.GetPathPartsForMatching(pathInfo);
|
|
||||||
|
|
||||||
List<RestPath> firstMatches;
|
|
||||||
|
|
||||||
var yieldedHashMatches = RestPath.GetFirstMatchHashKeys(matchUsingPathParts);
|
|
||||||
foreach (var potentialHashMatch in yieldedHashMatches)
|
|
||||||
{
|
|
||||||
if (!this.RestPathMap.TryGetValue(potentialHashMatch, out firstMatches))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var bestScore = -1;
|
|
||||||
RestPath bestMatch = null;
|
|
||||||
foreach (var restPath in firstMatches)
|
|
||||||
{
|
|
||||||
var score = restPath.MatchScore(httpMethod, matchUsingPathParts);
|
|
||||||
if (score > bestScore)
|
|
||||||
{
|
|
||||||
bestScore = score;
|
|
||||||
bestMatch = restPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bestScore > 0 && bestMatch != null)
|
|
||||||
{
|
|
||||||
return bestMatch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var yieldedWildcardMatches = RestPath.GetFirstMatchWildCardHashKeys(matchUsingPathParts);
|
|
||||||
foreach (var potentialHashMatch in yieldedWildcardMatches)
|
|
||||||
{
|
|
||||||
if (!this.RestPathMap.TryGetValue(potentialHashMatch, out firstMatches))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var bestScore = -1;
|
|
||||||
RestPath bestMatch = null;
|
|
||||||
foreach (var restPath in firstMatches)
|
|
||||||
{
|
|
||||||
var score = restPath.MatchScore(httpMethod, matchUsingPathParts);
|
|
||||||
if (score > bestScore)
|
|
||||||
{
|
|
||||||
bestScore = score;
|
|
||||||
bestMatch = restPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bestScore > 0 && bestMatch != null)
|
|
||||||
{
|
|
||||||
return bestMatch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<object> Execute(HttpListenerHost httpHost, object requestDto, IRequest req)
|
|
||||||
{
|
|
||||||
var requestType = requestDto.GetType();
|
|
||||||
req.OperationName = requestType.Name;
|
|
||||||
|
|
||||||
var serviceType = httpHost.GetServiceTypeByRequest(requestType);
|
|
||||||
|
|
||||||
var service = httpHost.CreateInstance(serviceType);
|
|
||||||
|
|
||||||
if (service is IRequiresRequest serviceRequiresContext)
|
|
||||||
{
|
|
||||||
serviceRequiresContext.Request = req;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Executes the service and returns the result
|
|
||||||
return ServiceExecGeneral.Execute(serviceType, req, service, requestDto, requestType.GetMethodName());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,230 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Linq.Expressions;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using MediaBrowser.Model.Services;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Services
|
|
||||||
{
|
|
||||||
public static class ServiceExecExtensions
|
|
||||||
{
|
|
||||||
public static string[] AllVerbs = new[] {
|
|
||||||
"OPTIONS", "GET", "HEAD", "POST", "PUT", "DELETE", "TRACE", "CONNECT", // RFC 2616
|
|
||||||
"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", // RFC 2518
|
|
||||||
"VERSION-CONTROL", "REPORT", "CHECKOUT", "CHECKIN", "UNCHECKOUT",
|
|
||||||
"MKWORKSPACE", "UPDATE", "LABEL", "MERGE", "BASELINE-CONTROL", "MKACTIVITY", // RFC 3253
|
|
||||||
"ORDERPATCH", // RFC 3648
|
|
||||||
"ACL", // RFC 3744
|
|
||||||
"PATCH", // https://datatracker.ietf.org/doc/draft-dusseault-http-patch/
|
|
||||||
"SEARCH", // https://datatracker.ietf.org/doc/draft-reschke-webdav-search/
|
|
||||||
"BCOPY", "BDELETE", "BMOVE", "BPROPFIND", "BPROPPATCH", "NOTIFY",
|
|
||||||
"POLL", "SUBSCRIBE", "UNSUBSCRIBE"
|
|
||||||
};
|
|
||||||
|
|
||||||
public static List<MethodInfo> GetActions(this Type serviceType)
|
|
||||||
{
|
|
||||||
var list = new List<MethodInfo>();
|
|
||||||
|
|
||||||
foreach (var mi in serviceType.GetRuntimeMethods())
|
|
||||||
{
|
|
||||||
if (!mi.IsPublic)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mi.IsStatic)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mi.GetParameters().Length != 1)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var actionName = mi.Name;
|
|
||||||
if (!AllVerbs.Contains(actionName, StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
list.Add(mi);
|
|
||||||
}
|
|
||||||
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class ServiceExecGeneral
|
|
||||||
{
|
|
||||||
private static Dictionary<string, ServiceMethod> execMap = new Dictionary<string, ServiceMethod>();
|
|
||||||
|
|
||||||
public static void CreateServiceRunnersFor(Type requestType, List<ServiceMethod> actions)
|
|
||||||
{
|
|
||||||
foreach (var actionCtx in actions)
|
|
||||||
{
|
|
||||||
if (execMap.ContainsKey(actionCtx.Id))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
execMap[actionCtx.Id] = actionCtx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Task<object> Execute(Type serviceType, IRequest request, object instance, object requestDto, string requestName)
|
|
||||||
{
|
|
||||||
var actionName = request.Verb ?? "POST";
|
|
||||||
|
|
||||||
if (execMap.TryGetValue(ServiceMethod.Key(serviceType, actionName, requestName), out ServiceMethod actionContext))
|
|
||||||
{
|
|
||||||
if (actionContext.RequestFilters != null)
|
|
||||||
{
|
|
||||||
foreach (var requestFilter in actionContext.RequestFilters)
|
|
||||||
{
|
|
||||||
requestFilter.RequestFilter(request, request.Response, requestDto);
|
|
||||||
if (request.Response.HasStarted)
|
|
||||||
{
|
|
||||||
Task.FromResult<object>(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = actionContext.ServiceAction(instance, requestDto);
|
|
||||||
|
|
||||||
if (response is Task taskResponse)
|
|
||||||
{
|
|
||||||
return GetTaskResult(taskResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.FromResult(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
var expectedMethodName = actionName.Substring(0, 1) + actionName.Substring(1).ToLowerInvariant();
|
|
||||||
throw new NotImplementedException(
|
|
||||||
string.Format(
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
"Could not find method named {1}({0}) or Any({0}) on Service {2}",
|
|
||||||
requestDto.GetType().GetMethodName(),
|
|
||||||
expectedMethodName,
|
|
||||||
serviceType.GetMethodName()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<object> GetTaskResult(Task task)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (task is Task<object> taskObject)
|
|
||||||
{
|
|
||||||
return await taskObject.ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
await task.ConfigureAwait(false);
|
|
||||||
|
|
||||||
var type = task.GetType().GetTypeInfo();
|
|
||||||
if (!type.IsGenericType)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var resultProperty = type.GetDeclaredProperty("Result");
|
|
||||||
if (resultProperty == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = resultProperty.GetValue(task);
|
|
||||||
|
|
||||||
// hack alert
|
|
||||||
if (result.GetType().Name.IndexOf("voidtaskresult", StringComparison.OrdinalIgnoreCase) != -1)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
catch (TypeAccessException)
|
|
||||||
{
|
|
||||||
return null; // return null for void Task's
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static List<ServiceMethod> Reset(Type serviceType)
|
|
||||||
{
|
|
||||||
var actions = new List<ServiceMethod>();
|
|
||||||
|
|
||||||
foreach (var mi in serviceType.GetActions())
|
|
||||||
{
|
|
||||||
var actionName = mi.Name;
|
|
||||||
var args = mi.GetParameters();
|
|
||||||
|
|
||||||
var requestType = args[0].ParameterType;
|
|
||||||
var actionCtx = new ServiceMethod
|
|
||||||
{
|
|
||||||
Id = ServiceMethod.Key(serviceType, actionName, requestType.GetMethodName())
|
|
||||||
};
|
|
||||||
|
|
||||||
actionCtx.ServiceAction = CreateExecFn(serviceType, requestType, mi);
|
|
||||||
|
|
||||||
var reqFilters = new List<IHasRequestFilter>();
|
|
||||||
|
|
||||||
foreach (var attr in mi.GetCustomAttributes(true))
|
|
||||||
{
|
|
||||||
if (attr is IHasRequestFilter hasReqFilter)
|
|
||||||
{
|
|
||||||
reqFilters.Add(hasReqFilter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reqFilters.Count > 0)
|
|
||||||
{
|
|
||||||
actionCtx.RequestFilters = reqFilters.OrderBy(i => i.Priority).ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
actions.Add(actionCtx);
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ActionInvokerFn CreateExecFn(Type serviceType, Type requestType, MethodInfo mi)
|
|
||||||
{
|
|
||||||
var serviceParam = Expression.Parameter(typeof(object), "serviceObj");
|
|
||||||
var serviceStrong = Expression.Convert(serviceParam, serviceType);
|
|
||||||
|
|
||||||
var requestDtoParam = Expression.Parameter(typeof(object), "requestDto");
|
|
||||||
var requestDtoStrong = Expression.Convert(requestDtoParam, requestType);
|
|
||||||
|
|
||||||
Expression callExecute = Expression.Call(
|
|
||||||
serviceStrong, mi, requestDtoStrong);
|
|
||||||
|
|
||||||
if (mi.ReturnType != typeof(void))
|
|
||||||
{
|
|
||||||
var executeFunc = Expression.Lambda<ActionInvokerFn>(
|
|
||||||
callExecute,
|
|
||||||
serviceParam,
|
|
||||||
requestDtoParam).Compile();
|
|
||||||
|
|
||||||
return executeFunc;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var executeFunc = Expression.Lambda<VoidActionInvokerFn>(
|
|
||||||
callExecute,
|
|
||||||
serviceParam,
|
|
||||||
requestDtoParam).Compile();
|
|
||||||
|
|
||||||
return (service, request) =>
|
|
||||||
{
|
|
||||||
executeFunc(service, request);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,212 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Net.Mime;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Emby.Server.Implementations.HttpServer;
|
|
||||||
using MediaBrowser.Common.Extensions;
|
|
||||||
using MediaBrowser.Model.Services;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Services
|
|
||||||
{
|
|
||||||
public class ServiceHandler
|
|
||||||
{
|
|
||||||
private RestPath _restPath;
|
|
||||||
|
|
||||||
private string _responseContentType;
|
|
||||||
|
|
||||||
internal ServiceHandler(RestPath restPath, string responseContentType)
|
|
||||||
{
|
|
||||||
_restPath = restPath;
|
|
||||||
_responseContentType = responseContentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static Task<object> CreateContentTypeRequest(HttpListenerHost host, IRequest httpReq, Type requestType, string contentType)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(contentType) && httpReq.ContentLength > 0)
|
|
||||||
{
|
|
||||||
var deserializer = RequestHelper.GetRequestReader(host, contentType);
|
|
||||||
if (deserializer != null)
|
|
||||||
{
|
|
||||||
return deserializer.Invoke(requestType, httpReq.InputStream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.FromResult(host.CreateInstance(requestType));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GetSanitizedPathInfo(string pathInfo, out string contentType)
|
|
||||||
{
|
|
||||||
contentType = null;
|
|
||||||
var pos = pathInfo.LastIndexOf('.');
|
|
||||||
if (pos != -1)
|
|
||||||
{
|
|
||||||
var format = pathInfo.AsSpan().Slice(pos + 1);
|
|
||||||
contentType = GetFormatContentType(format);
|
|
||||||
if (contentType != null)
|
|
||||||
{
|
|
||||||
pathInfo = pathInfo.Substring(0, pos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pathInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetFormatContentType(ReadOnlySpan<char> format)
|
|
||||||
{
|
|
||||||
if (format.Equals("json", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
return MediaTypeNames.Application.Json;
|
|
||||||
}
|
|
||||||
else if (format.Equals("xml", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
return MediaTypeNames.Application.Xml;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ProcessRequestAsync(HttpListenerHost httpHost, IRequest httpReq, HttpResponse httpRes, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
httpReq.Items["__route"] = _restPath;
|
|
||||||
|
|
||||||
if (_responseContentType != null)
|
|
||||||
{
|
|
||||||
httpReq.ResponseContentType = _responseContentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = await CreateRequest(httpHost, httpReq, _restPath).ConfigureAwait(false);
|
|
||||||
|
|
||||||
httpHost.ApplyRequestFilters(httpReq, httpRes, request);
|
|
||||||
|
|
||||||
httpRes.HttpContext.SetServiceStackRequest(httpReq);
|
|
||||||
var response = await httpHost.ServiceController.Execute(httpHost, request, httpReq).ConfigureAwait(false);
|
|
||||||
|
|
||||||
// Apply response filters
|
|
||||||
foreach (var responseFilter in httpHost.ResponseFilters)
|
|
||||||
{
|
|
||||||
responseFilter(httpReq, httpRes, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ResponseHelper.WriteToResponse(httpRes, httpReq, response, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<object> CreateRequest(HttpListenerHost host, IRequest httpReq, RestPath restPath)
|
|
||||||
{
|
|
||||||
var requestType = restPath.RequestType;
|
|
||||||
|
|
||||||
if (RequireqRequestStream(requestType))
|
|
||||||
{
|
|
||||||
// Used by IRequiresRequestStream
|
|
||||||
var requestParams = GetRequestParams(httpReq.Response.HttpContext.Request);
|
|
||||||
var request = ServiceHandler.CreateRequest(httpReq, restPath, requestParams, host.CreateInstance(requestType));
|
|
||||||
|
|
||||||
var rawReq = (IRequiresRequestStream)request;
|
|
||||||
rawReq.RequestStream = httpReq.InputStream;
|
|
||||||
return rawReq;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var requestParams = GetFlattenedRequestParams(httpReq.Response.HttpContext.Request);
|
|
||||||
|
|
||||||
var requestDto = await CreateContentTypeRequest(host, httpReq, restPath.RequestType, httpReq.ContentType).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return CreateRequest(httpReq, restPath, requestParams, requestDto);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool RequireqRequestStream(Type requestType)
|
|
||||||
{
|
|
||||||
var requiresRequestStreamTypeInfo = typeof(IRequiresRequestStream).GetTypeInfo();
|
|
||||||
|
|
||||||
return requiresRequestStreamTypeInfo.IsAssignableFrom(requestType.GetTypeInfo());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static object CreateRequest(IRequest httpReq, RestPath restPath, Dictionary<string, string> requestParams, object requestDto)
|
|
||||||
{
|
|
||||||
var pathInfo = !restPath.IsWildCardPath
|
|
||||||
? GetSanitizedPathInfo(httpReq.PathInfo, out _)
|
|
||||||
: httpReq.PathInfo;
|
|
||||||
|
|
||||||
return restPath.CreateRequest(pathInfo, requestParams, requestDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Duplicate Params are given a unique key by appending a #1 suffix
|
|
||||||
/// </summary>
|
|
||||||
private static Dictionary<string, string> GetRequestParams(HttpRequest request)
|
|
||||||
{
|
|
||||||
var map = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
foreach (var pair in request.Query)
|
|
||||||
{
|
|
||||||
var values = pair.Value;
|
|
||||||
if (values.Count == 1)
|
|
||||||
{
|
|
||||||
map[pair.Key] = values[0];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
for (var i = 0; i < values.Count; i++)
|
|
||||||
{
|
|
||||||
map[pair.Key + (i == 0 ? string.Empty : "#" + i)] = values[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((IsMethod(request.Method, "POST") || IsMethod(request.Method, "PUT"))
|
|
||||||
&& request.HasFormContentType)
|
|
||||||
{
|
|
||||||
foreach (var pair in request.Form)
|
|
||||||
{
|
|
||||||
var values = pair.Value;
|
|
||||||
if (values.Count == 1)
|
|
||||||
{
|
|
||||||
map[pair.Key] = values[0];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
for (var i = 0; i < values.Count; i++)
|
|
||||||
{
|
|
||||||
map[pair.Key + (i == 0 ? string.Empty : "#" + i)] = values[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsMethod(string method, string expected)
|
|
||||||
=> string.Equals(method, expected, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Duplicate params have their values joined together in a comma-delimited string.
|
|
||||||
/// </summary>
|
|
||||||
private static Dictionary<string, string> GetFlattenedRequestParams(HttpRequest request)
|
|
||||||
{
|
|
||||||
var map = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
foreach (var pair in request.Query)
|
|
||||||
{
|
|
||||||
map[pair.Key] = pair.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((IsMethod(request.Method, "POST") || IsMethod(request.Method, "PUT"))
|
|
||||||
&& request.HasFormContentType)
|
|
||||||
{
|
|
||||||
foreach (var pair in request.Form)
|
|
||||||
{
|
|
||||||
map[pair.Key] = pair.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Services
|
|
||||||
{
|
|
||||||
public class ServiceMethod
|
|
||||||
{
|
|
||||||
public string Id { get; set; }
|
|
||||||
|
|
||||||
public ActionInvokerFn ServiceAction { get; set; }
|
|
||||||
|
|
||||||
public MediaBrowser.Model.Services.IHasRequestFilter[] RequestFilters { get; set; }
|
|
||||||
|
|
||||||
public static string Key(Type serviceType, string method, string requestDtoName)
|
|
||||||
{
|
|
||||||
return serviceType.FullName + " " + method.ToUpperInvariant() + " " + requestDtoName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,550 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Services
|
|
||||||
{
|
|
||||||
public class RestPath
|
|
||||||
{
|
|
||||||
private const string WildCard = "*";
|
|
||||||
private const char WildCardChar = '*';
|
|
||||||
private const string PathSeperator = "/";
|
|
||||||
private const char PathSeperatorChar = '/';
|
|
||||||
private const char ComponentSeperator = '.';
|
|
||||||
private const string VariablePrefix = "{";
|
|
||||||
|
|
||||||
private readonly bool[] componentsWithSeparators;
|
|
||||||
|
|
||||||
private readonly string restPath;
|
|
||||||
public bool IsWildCardPath { get; private set; }
|
|
||||||
|
|
||||||
private readonly string[] literalsToMatch;
|
|
||||||
|
|
||||||
private readonly string[] variablesNames;
|
|
||||||
|
|
||||||
private readonly bool[] isWildcard;
|
|
||||||
private readonly int wildcardCount = 0;
|
|
||||||
|
|
||||||
internal static string[] IgnoreAttributesNamed = new[]
|
|
||||||
{
|
|
||||||
nameof(JsonIgnoreAttribute)
|
|
||||||
};
|
|
||||||
|
|
||||||
private static Type _excludeType = typeof(Stream);
|
|
||||||
|
|
||||||
public int VariableArgsCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The number of segments separated by '/' determinable by path.Split('/').Length
|
|
||||||
/// e.g. /path/to/here.ext == 3
|
|
||||||
/// </summary>
|
|
||||||
public int PathComponentsCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the total number of segments after subparts have been exploded ('.')
|
|
||||||
/// e.g. /path/to/here.ext == 4.
|
|
||||||
/// </summary>
|
|
||||||
public int TotalComponentsCount { get; set; }
|
|
||||||
|
|
||||||
public string[] Verbs { get; private set; }
|
|
||||||
|
|
||||||
public Type RequestType { get; private set; }
|
|
||||||
|
|
||||||
public Type ServiceType { get; private set; }
|
|
||||||
|
|
||||||
public string Path => this.restPath;
|
|
||||||
|
|
||||||
public string Summary { get; private set; }
|
|
||||||
|
|
||||||
public string Description { get; private set; }
|
|
||||||
|
|
||||||
public bool IsHidden { get; private set; }
|
|
||||||
|
|
||||||
public static string[] GetPathPartsForMatching(string pathInfo)
|
|
||||||
{
|
|
||||||
return pathInfo.ToLowerInvariant().Split(new[] { PathSeperatorChar }, StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static List<string> GetFirstMatchHashKeys(string[] pathPartsForMatching)
|
|
||||||
{
|
|
||||||
var hashPrefix = pathPartsForMatching.Length + PathSeperator;
|
|
||||||
return GetPotentialMatchesWithPrefix(hashPrefix, pathPartsForMatching);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static List<string> GetFirstMatchWildCardHashKeys(string[] pathPartsForMatching)
|
|
||||||
{
|
|
||||||
const string HashPrefix = WildCard + PathSeperator;
|
|
||||||
return GetPotentialMatchesWithPrefix(HashPrefix, pathPartsForMatching);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<string> GetPotentialMatchesWithPrefix(string hashPrefix, string[] pathPartsForMatching)
|
|
||||||
{
|
|
||||||
var list = new List<string>();
|
|
||||||
|
|
||||||
foreach (var part in pathPartsForMatching)
|
|
||||||
{
|
|
||||||
list.Add(hashPrefix + part);
|
|
||||||
|
|
||||||
if (part.IndexOf(ComponentSeperator, StringComparison.Ordinal) == -1)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var subParts = part.Split(ComponentSeperator);
|
|
||||||
foreach (var subPart in subParts)
|
|
||||||
{
|
|
||||||
list.Add(hashPrefix + subPart);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public RestPath(Func<Type, object> createInstanceFn, Func<Type, Func<string, object>> getParseFn, Type requestType, Type serviceType, string path, string verbs, bool isHidden = false, string summary = null, string description = null)
|
|
||||||
{
|
|
||||||
this.RequestType = requestType;
|
|
||||||
this.ServiceType = serviceType;
|
|
||||||
this.Summary = summary;
|
|
||||||
this.IsHidden = isHidden;
|
|
||||||
this.Description = description;
|
|
||||||
this.restPath = path;
|
|
||||||
|
|
||||||
this.Verbs = string.IsNullOrWhiteSpace(verbs) ? ServiceExecExtensions.AllVerbs : verbs.ToUpperInvariant().Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
|
|
||||||
var componentsList = new List<string>();
|
|
||||||
|
|
||||||
// We only split on '.' if the restPath has them. Allows for /{action}.{type}
|
|
||||||
var hasSeparators = new List<bool>();
|
|
||||||
foreach (var component in this.restPath.Split(PathSeperatorChar))
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(component))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (component.IndexOf(VariablePrefix, StringComparison.OrdinalIgnoreCase) != -1
|
|
||||||
&& component.IndexOf(ComponentSeperator, StringComparison.Ordinal) != -1)
|
|
||||||
{
|
|
||||||
hasSeparators.Add(true);
|
|
||||||
componentsList.AddRange(component.Split(ComponentSeperator));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
hasSeparators.Add(false);
|
|
||||||
componentsList.Add(component);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var components = componentsList.ToArray();
|
|
||||||
this.TotalComponentsCount = components.Length;
|
|
||||||
|
|
||||||
this.literalsToMatch = new string[this.TotalComponentsCount];
|
|
||||||
this.variablesNames = new string[this.TotalComponentsCount];
|
|
||||||
this.isWildcard = new bool[this.TotalComponentsCount];
|
|
||||||
this.componentsWithSeparators = hasSeparators.ToArray();
|
|
||||||
this.PathComponentsCount = this.componentsWithSeparators.Length;
|
|
||||||
string firstLiteralMatch = null;
|
|
||||||
|
|
||||||
for (var i = 0; i < components.Length; i++)
|
|
||||||
{
|
|
||||||
var component = components[i];
|
|
||||||
|
|
||||||
if (component.StartsWith(VariablePrefix, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
var variableName = component.Substring(1, component.Length - 2);
|
|
||||||
if (variableName[variableName.Length - 1] == WildCardChar)
|
|
||||||
{
|
|
||||||
this.isWildcard[i] = true;
|
|
||||||
variableName = variableName.Substring(0, variableName.Length - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.variablesNames[i] = variableName;
|
|
||||||
this.VariableArgsCount++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
this.literalsToMatch[i] = component.ToLowerInvariant();
|
|
||||||
|
|
||||||
if (firstLiteralMatch == null)
|
|
||||||
{
|
|
||||||
firstLiteralMatch = this.literalsToMatch[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < components.Length - 1; i++)
|
|
||||||
{
|
|
||||||
if (!this.isWildcard[i])
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.literalsToMatch[i + 1] == null)
|
|
||||||
{
|
|
||||||
throw new ArgumentException(
|
|
||||||
"A wildcard path component must be at the end of the path or followed by a literal path component.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.wildcardCount = this.isWildcard.Length;
|
|
||||||
this.IsWildCardPath = this.wildcardCount > 0;
|
|
||||||
|
|
||||||
this.FirstMatchHashKey = !this.IsWildCardPath
|
|
||||||
? this.PathComponentsCount + PathSeperator + firstLiteralMatch
|
|
||||||
: WildCardChar + PathSeperator + firstLiteralMatch;
|
|
||||||
|
|
||||||
this.typeDeserializer = new StringMapTypeDeserializer(createInstanceFn, getParseFn, this.RequestType);
|
|
||||||
|
|
||||||
_propertyNamesMap = new HashSet<string>(
|
|
||||||
GetSerializableProperties(RequestType).Select(x => x.Name),
|
|
||||||
StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static IEnumerable<PropertyInfo> GetSerializableProperties(Type type)
|
|
||||||
{
|
|
||||||
foreach (var prop in GetPublicProperties(type))
|
|
||||||
{
|
|
||||||
if (prop.GetMethod == null
|
|
||||||
|| _excludeType == prop.PropertyType)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var ignored = false;
|
|
||||||
foreach (var attr in prop.GetCustomAttributes(true))
|
|
||||||
{
|
|
||||||
if (IgnoreAttributesNamed.Contains(attr.GetType().Name))
|
|
||||||
{
|
|
||||||
ignored = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ignored)
|
|
||||||
{
|
|
||||||
yield return prop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerable<PropertyInfo> GetPublicProperties(Type type)
|
|
||||||
{
|
|
||||||
if (type.IsInterface)
|
|
||||||
{
|
|
||||||
var propertyInfos = new List<PropertyInfo>();
|
|
||||||
var considered = new List<Type>()
|
|
||||||
{
|
|
||||||
type
|
|
||||||
};
|
|
||||||
var queue = new Queue<Type>();
|
|
||||||
queue.Enqueue(type);
|
|
||||||
|
|
||||||
while (queue.Count > 0)
|
|
||||||
{
|
|
||||||
var subType = queue.Dequeue();
|
|
||||||
foreach (var subInterface in subType.GetTypeInfo().ImplementedInterfaces)
|
|
||||||
{
|
|
||||||
if (considered.Contains(subInterface))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
considered.Add(subInterface);
|
|
||||||
queue.Enqueue(subInterface);
|
|
||||||
}
|
|
||||||
|
|
||||||
var newPropertyInfos = GetTypesPublicProperties(subType)
|
|
||||||
.Where(x => !propertyInfos.Contains(x));
|
|
||||||
|
|
||||||
propertyInfos.InsertRange(0, newPropertyInfos);
|
|
||||||
}
|
|
||||||
|
|
||||||
return propertyInfos;
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetTypesPublicProperties(type)
|
|
||||||
.Where(x => x.GetIndexParameters().Length == 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerable<PropertyInfo> GetTypesPublicProperties(Type subType)
|
|
||||||
{
|
|
||||||
foreach (var pi in subType.GetRuntimeProperties())
|
|
||||||
{
|
|
||||||
var mi = pi.GetMethod ?? pi.SetMethod;
|
|
||||||
if (mi != null && mi.IsStatic)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
yield return pi;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provide for quick lookups based on hashes that can be determined from a request url.
|
|
||||||
/// </summary>
|
|
||||||
public string FirstMatchHashKey { get; private set; }
|
|
||||||
|
|
||||||
private readonly StringMapTypeDeserializer typeDeserializer;
|
|
||||||
|
|
||||||
private readonly HashSet<string> _propertyNamesMap;
|
|
||||||
|
|
||||||
public int MatchScore(string httpMethod, string[] withPathInfoParts)
|
|
||||||
{
|
|
||||||
var isMatch = IsMatch(httpMethod, withPathInfoParts, out var wildcardMatchCount);
|
|
||||||
if (!isMatch)
|
|
||||||
{
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Routes with least wildcard matches get the highest score
|
|
||||||
var score = Math.Max(100 - wildcardMatchCount, 1) * 1000
|
|
||||||
// Routes with less variable (and more literal) matches
|
|
||||||
+ Math.Max(10 - VariableArgsCount, 1) * 100;
|
|
||||||
|
|
||||||
// Exact verb match is better than ANY
|
|
||||||
if (Verbs.Length == 1 && string.Equals(httpMethod, Verbs[0], StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
score += 10;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
score += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// For performance withPathInfoParts should already be a lower case string
|
|
||||||
/// to minimize redundant matching operations.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsMatch(string httpMethod, string[] withPathInfoParts, out int wildcardMatchCount)
|
|
||||||
{
|
|
||||||
wildcardMatchCount = 0;
|
|
||||||
|
|
||||||
if (withPathInfoParts.Length != this.PathComponentsCount && !this.IsWildCardPath)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Verbs.Contains(httpMethod, StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ExplodeComponents(ref withPathInfoParts))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.TotalComponentsCount != withPathInfoParts.Length && !this.IsWildCardPath)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
int pathIx = 0;
|
|
||||||
for (var i = 0; i < this.TotalComponentsCount; i++)
|
|
||||||
{
|
|
||||||
if (this.isWildcard[i])
|
|
||||||
{
|
|
||||||
if (i < this.TotalComponentsCount - 1)
|
|
||||||
{
|
|
||||||
// Continue to consume up until a match with the next literal
|
|
||||||
while (pathIx < withPathInfoParts.Length
|
|
||||||
&& !string.Equals(withPathInfoParts[pathIx], this.literalsToMatch[i + 1], StringComparison.InvariantCultureIgnoreCase))
|
|
||||||
{
|
|
||||||
pathIx++;
|
|
||||||
wildcardMatchCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure there are still enough parts left to match the remainder
|
|
||||||
if ((withPathInfoParts.Length - pathIx) < (this.TotalComponentsCount - i - 1))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// A wildcard at the end matches the remainder of path
|
|
||||||
wildcardMatchCount += withPathInfoParts.Length - pathIx;
|
|
||||||
pathIx = withPathInfoParts.Length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var literalToMatch = this.literalsToMatch[i];
|
|
||||||
if (literalToMatch == null)
|
|
||||||
{
|
|
||||||
// Matching an ordinary (non-wildcard) variable consumes a single part
|
|
||||||
pathIx++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (withPathInfoParts.Length <= pathIx
|
|
||||||
|| !string.Equals(withPathInfoParts[pathIx], literalToMatch, StringComparison.InvariantCultureIgnoreCase))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pathIx++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pathIx == withPathInfoParts.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool ExplodeComponents(ref string[] withPathInfoParts)
|
|
||||||
{
|
|
||||||
var totalComponents = new List<string>();
|
|
||||||
for (var i = 0; i < withPathInfoParts.Length; i++)
|
|
||||||
{
|
|
||||||
var component = withPathInfoParts[i];
|
|
||||||
if (string.IsNullOrEmpty(component))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.PathComponentsCount != this.TotalComponentsCount
|
|
||||||
&& this.componentsWithSeparators[i])
|
|
||||||
{
|
|
||||||
var subComponents = component.Split(ComponentSeperator);
|
|
||||||
if (subComponents.Length < 2)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalComponents.AddRange(subComponents);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
totalComponents.Add(component);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
withPathInfoParts = totalComponents.ToArray();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public object CreateRequest(string pathInfo, Dictionary<string, string> queryStringAndFormData, object fromInstance)
|
|
||||||
{
|
|
||||||
var requestComponents = pathInfo.Split(new[] { PathSeperatorChar }, StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
|
|
||||||
ExplodeComponents(ref requestComponents);
|
|
||||||
|
|
||||||
if (requestComponents.Length != this.TotalComponentsCount)
|
|
||||||
{
|
|
||||||
var isValidWildCardPath = this.IsWildCardPath
|
|
||||||
&& requestComponents.Length >= this.TotalComponentsCount - this.wildcardCount;
|
|
||||||
|
|
||||||
if (!isValidWildCardPath)
|
|
||||||
{
|
|
||||||
throw new ArgumentException(
|
|
||||||
string.Format(
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
"Path Mismatch: Request Path '{0}' has invalid number of components compared to: '{1}'",
|
|
||||||
pathInfo,
|
|
||||||
this.restPath));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var requestKeyValuesMap = new Dictionary<string, string>();
|
|
||||||
var pathIx = 0;
|
|
||||||
for (var i = 0; i < this.TotalComponentsCount; i++)
|
|
||||||
{
|
|
||||||
var variableName = this.variablesNames[i];
|
|
||||||
if (variableName == null)
|
|
||||||
{
|
|
||||||
pathIx++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this._propertyNamesMap.Contains(variableName))
|
|
||||||
{
|
|
||||||
if (string.Equals("ignore", variableName, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
pathIx++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ArgumentException("Could not find property "
|
|
||||||
+ variableName + " on " + RequestType.GetMethodName());
|
|
||||||
}
|
|
||||||
|
|
||||||
var value = requestComponents.Length > pathIx ? requestComponents[pathIx] : null; // wildcard has arg mismatch
|
|
||||||
if (value != null && this.isWildcard[i])
|
|
||||||
{
|
|
||||||
if (i == this.TotalComponentsCount - 1)
|
|
||||||
{
|
|
||||||
// Wildcard at end of path definition consumes all the rest
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.Append(value);
|
|
||||||
for (var j = pathIx + 1; j < requestComponents.Length; j++)
|
|
||||||
{
|
|
||||||
sb.Append(PathSeperatorChar)
|
|
||||||
.Append(requestComponents[j]);
|
|
||||||
}
|
|
||||||
|
|
||||||
value = sb.ToString();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Wildcard in middle of path definition consumes up until it
|
|
||||||
// hits a match for the next element in the definition (which must be a literal)
|
|
||||||
// It may consume 0 or more path parts
|
|
||||||
var stopLiteral = i == this.TotalComponentsCount - 1 ? null : this.literalsToMatch[i + 1];
|
|
||||||
if (!string.Equals(requestComponents[pathIx], stopLiteral, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder(value);
|
|
||||||
pathIx++;
|
|
||||||
while (!string.Equals(requestComponents[pathIx], stopLiteral, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
sb.Append(PathSeperatorChar)
|
|
||||||
.Append(requestComponents[pathIx++]);
|
|
||||||
}
|
|
||||||
|
|
||||||
value = sb.ToString();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Variable consumes single path item
|
|
||||||
pathIx++;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestKeyValuesMap[variableName] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queryStringAndFormData != null)
|
|
||||||
{
|
|
||||||
// Query String and form data can override variable path matches
|
|
||||||
// path variables < query string < form data
|
|
||||||
foreach (var name in queryStringAndFormData)
|
|
||||||
{
|
|
||||||
requestKeyValuesMap[name.Key] = name.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.typeDeserializer.PopulateFromMap(fromInstance, requestKeyValuesMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class RestPathMap : SortedDictionary<string, List<RestPath>>
|
|
||||||
{
|
|
||||||
public RestPathMap() : base(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,118 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Reflection;
|
|
||||||
using MediaBrowser.Common.Extensions;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Services
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Serializer cache of delegates required to create a type from a string map (e.g. for REST urls)
|
|
||||||
/// </summary>
|
|
||||||
public class StringMapTypeDeserializer
|
|
||||||
{
|
|
||||||
internal class PropertySerializerEntry
|
|
||||||
{
|
|
||||||
public PropertySerializerEntry(Action<object, object> propertySetFn, Func<string, object> propertyParseStringFn, Type propertyType)
|
|
||||||
{
|
|
||||||
PropertySetFn = propertySetFn;
|
|
||||||
PropertyParseStringFn = propertyParseStringFn;
|
|
||||||
PropertyType = propertyType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Action<object, object> PropertySetFn { get; private set; }
|
|
||||||
|
|
||||||
public Func<string, object> PropertyParseStringFn { get; private set; }
|
|
||||||
|
|
||||||
public Type PropertyType { get; private set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly Type type;
|
|
||||||
private readonly Dictionary<string, PropertySerializerEntry> propertySetterMap
|
|
||||||
= new Dictionary<string, PropertySerializerEntry>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public Func<string, object> GetParseFn(Type propertyType)
|
|
||||||
{
|
|
||||||
if (propertyType == typeof(string))
|
|
||||||
{
|
|
||||||
return s => s;
|
|
||||||
}
|
|
||||||
|
|
||||||
return _GetParseFn(propertyType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly Func<Type, object> _CreateInstanceFn;
|
|
||||||
private readonly Func<Type, Func<string, object>> _GetParseFn;
|
|
||||||
|
|
||||||
public StringMapTypeDeserializer(Func<Type, object> createInstanceFn, Func<Type, Func<string, object>> getParseFn, Type type)
|
|
||||||
{
|
|
||||||
_CreateInstanceFn = createInstanceFn;
|
|
||||||
_GetParseFn = getParseFn;
|
|
||||||
this.type = type;
|
|
||||||
|
|
||||||
foreach (var propertyInfo in RestPath.GetSerializableProperties(type))
|
|
||||||
{
|
|
||||||
var propertySetFn = TypeAccessor.GetSetPropertyMethod(propertyInfo);
|
|
||||||
var propertyType = propertyInfo.PropertyType;
|
|
||||||
var propertyParseStringFn = GetParseFn(propertyType);
|
|
||||||
var propertySerializer = new PropertySerializerEntry(propertySetFn, propertyParseStringFn, propertyType);
|
|
||||||
|
|
||||||
propertySetterMap[propertyInfo.Name] = propertySerializer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public object PopulateFromMap(object instance, IDictionary<string, string> keyValuePairs)
|
|
||||||
{
|
|
||||||
PropertySerializerEntry propertySerializerEntry = null;
|
|
||||||
|
|
||||||
if (instance == null)
|
|
||||||
{
|
|
||||||
instance = _CreateInstanceFn(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var pair in keyValuePairs)
|
|
||||||
{
|
|
||||||
string propertyName = pair.Key;
|
|
||||||
string propertyTextValue = pair.Value;
|
|
||||||
|
|
||||||
if (propertyTextValue == null
|
|
||||||
|| !propertySetterMap.TryGetValue(propertyName, out propertySerializerEntry)
|
|
||||||
|| propertySerializerEntry.PropertySetFn == null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (propertySerializerEntry.PropertyType == typeof(bool))
|
|
||||||
{
|
|
||||||
// InputExtensions.cs#530 MVC Checkbox helper emits extra hidden input field, generating 2 values, first is the real value
|
|
||||||
propertyTextValue = StringExtensions.LeftPart(propertyTextValue, ',').ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
var value = propertySerializerEntry.PropertyParseStringFn(propertyTextValue);
|
|
||||||
if (value == null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
propertySerializerEntry.PropertySetFn(instance, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class TypeAccessor
|
|
||||||
{
|
|
||||||
public static Action<object, object> GetSetPropertyMethod(PropertyInfo propertyInfo)
|
|
||||||
{
|
|
||||||
if (!propertyInfo.CanWrite || propertyInfo.GetIndexParameters().Length > 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var setMethodInfo = propertyInfo.SetMethod;
|
|
||||||
return (instance, value) => setMethodInfo.Invoke(instance, new[] { value });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using MediaBrowser.Common.Extensions;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Services
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Donated by Ivan Korneliuk from his post:
|
|
||||||
/// http://korneliuk.blogspot.com/2012/08/servicestack-reusing-dtos.html
|
|
||||||
///
|
|
||||||
/// Modified to only allow using routes matching the supplied HTTP Verb.
|
|
||||||
/// </summary>
|
|
||||||
public static class UrlExtensions
|
|
||||||
{
|
|
||||||
public static string GetMethodName(this Type type)
|
|
||||||
{
|
|
||||||
var typeName = type.FullName != null // can be null, e.g. generic types
|
|
||||||
? StringExtensions.LeftPart(type.FullName, "[[", StringComparison.Ordinal).ToString() // Generic Fullname
|
|
||||||
.Replace(type.Namespace + ".", string.Empty, StringComparison.Ordinal) // Trim Namespaces
|
|
||||||
.Replace("+", ".", StringComparison.Ordinal) // Convert nested into normal type
|
|
||||||
: type.Name;
|
|
||||||
|
|
||||||
return type.IsGenericParameter ? "'" + typeName : typeName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,248 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Net;
|
|
||||||
using System.Net.Mime;
|
|
||||||
using MediaBrowser.Common.Extensions;
|
|
||||||
using MediaBrowser.Common.Net;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.AspNetCore.Http.Extensions;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Primitives;
|
|
||||||
using Microsoft.Net.Http.Headers;
|
|
||||||
using IHttpRequest = MediaBrowser.Model.Services.IHttpRequest;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.SocketSharp
|
|
||||||
{
|
|
||||||
public class WebSocketSharpRequest : IHttpRequest
|
|
||||||
{
|
|
||||||
private const string FormUrlEncoded = "application/x-www-form-urlencoded";
|
|
||||||
private const string MultiPartFormData = "multipart/form-data";
|
|
||||||
private const string Soap11 = "text/xml; charset=utf-8";
|
|
||||||
|
|
||||||
private string _remoteIp;
|
|
||||||
private Dictionary<string, object> _items;
|
|
||||||
private string _responseContentType;
|
|
||||||
|
|
||||||
public WebSocketSharpRequest(HttpRequest httpRequest, HttpResponse httpResponse, string operationName)
|
|
||||||
{
|
|
||||||
this.OperationName = operationName;
|
|
||||||
this.Request = httpRequest;
|
|
||||||
this.Response = httpResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Accept => StringValues.IsNullOrEmpty(Request.Headers[HeaderNames.Accept]) ? null : Request.Headers[HeaderNames.Accept].ToString();
|
|
||||||
|
|
||||||
public string Authorization => StringValues.IsNullOrEmpty(Request.Headers[HeaderNames.Authorization]) ? null : Request.Headers[HeaderNames.Authorization].ToString();
|
|
||||||
|
|
||||||
public HttpRequest Request { get; }
|
|
||||||
|
|
||||||
public HttpResponse Response { get; }
|
|
||||||
|
|
||||||
public string OperationName { get; set; }
|
|
||||||
|
|
||||||
public string RawUrl => Request.GetEncodedPathAndQuery();
|
|
||||||
|
|
||||||
public string AbsoluteUri => Request.GetDisplayUrl().TrimEnd('/');
|
|
||||||
|
|
||||||
public string RemoteIp
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
if (_remoteIp != null)
|
|
||||||
{
|
|
||||||
return _remoteIp;
|
|
||||||
}
|
|
||||||
|
|
||||||
IPAddress ip;
|
|
||||||
|
|
||||||
// "Real" remote ip might be in X-Forwarded-For of X-Real-Ip
|
|
||||||
// (if the server is behind a reverse proxy for example)
|
|
||||||
if (!IPAddress.TryParse(GetHeader(CustomHeaderNames.XForwardedFor), out ip))
|
|
||||||
{
|
|
||||||
if (!IPAddress.TryParse(GetHeader(CustomHeaderNames.XRealIP), out ip))
|
|
||||||
{
|
|
||||||
ip = Request.HttpContext.Connection.RemoteIpAddress;
|
|
||||||
|
|
||||||
// Default to the loopback address if no RemoteIpAddress is specified (i.e. during integration tests)
|
|
||||||
ip ??= IPAddress.Loopback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return _remoteIp = NormalizeIp(ip).ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string[] AcceptTypes => Request.Headers.GetCommaSeparatedValues(HeaderNames.Accept);
|
|
||||||
|
|
||||||
public Dictionary<string, object> Items => _items ?? (_items = new Dictionary<string, object>());
|
|
||||||
|
|
||||||
public string ResponseContentType
|
|
||||||
{
|
|
||||||
get =>
|
|
||||||
_responseContentType
|
|
||||||
?? (_responseContentType = GetResponseContentType(Request));
|
|
||||||
set => _responseContentType = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string PathInfo => Request.Path.Value;
|
|
||||||
|
|
||||||
public string UserAgent => Request.Headers[HeaderNames.UserAgent];
|
|
||||||
|
|
||||||
public IHeaderDictionary Headers => Request.Headers;
|
|
||||||
|
|
||||||
public IQueryCollection QueryString => Request.Query;
|
|
||||||
|
|
||||||
public bool IsLocal =>
|
|
||||||
(Request.HttpContext.Connection.LocalIpAddress == null
|
|
||||||
&& Request.HttpContext.Connection.RemoteIpAddress == null)
|
|
||||||
|| Request.HttpContext.Connection.LocalIpAddress.Equals(Request.HttpContext.Connection.RemoteIpAddress);
|
|
||||||
|
|
||||||
public string HttpMethod => Request.Method;
|
|
||||||
|
|
||||||
public string Verb => HttpMethod;
|
|
||||||
|
|
||||||
public string ContentType => Request.ContentType;
|
|
||||||
|
|
||||||
public Uri UrlReferrer => Request.GetTypedHeaders().Referer;
|
|
||||||
|
|
||||||
public Stream InputStream => Request.Body;
|
|
||||||
|
|
||||||
public long ContentLength => Request.ContentLength ?? 0;
|
|
||||||
|
|
||||||
private string GetHeader(string name) => Request.Headers[name].ToString();
|
|
||||||
|
|
||||||
private static IPAddress NormalizeIp(IPAddress ip)
|
|
||||||
{
|
|
||||||
if (ip.IsIPv4MappedToIPv6)
|
|
||||||
{
|
|
||||||
return ip.MapToIPv4();
|
|
||||||
}
|
|
||||||
|
|
||||||
return ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GetResponseContentType(HttpRequest httpReq)
|
|
||||||
{
|
|
||||||
var specifiedContentType = GetQueryStringContentType(httpReq);
|
|
||||||
if (!string.IsNullOrEmpty(specifiedContentType))
|
|
||||||
{
|
|
||||||
return specifiedContentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const string ServerDefaultContentType = MediaTypeNames.Application.Json;
|
|
||||||
|
|
||||||
var acceptContentTypes = httpReq.Headers.GetCommaSeparatedValues(HeaderNames.Accept);
|
|
||||||
string defaultContentType = null;
|
|
||||||
if (HasAnyOfContentTypes(httpReq, FormUrlEncoded, MultiPartFormData))
|
|
||||||
{
|
|
||||||
defaultContentType = ServerDefaultContentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
var acceptsAnything = false;
|
|
||||||
var hasDefaultContentType = defaultContentType != null;
|
|
||||||
if (acceptContentTypes != null)
|
|
||||||
{
|
|
||||||
foreach (ReadOnlySpan<char> acceptsType in acceptContentTypes)
|
|
||||||
{
|
|
||||||
ReadOnlySpan<char> contentType = acceptsType;
|
|
||||||
var index = contentType.IndexOf(';');
|
|
||||||
if (index != -1)
|
|
||||||
{
|
|
||||||
contentType = contentType.Slice(0, index);
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType = contentType.Trim();
|
|
||||||
acceptsAnything = contentType.Equals("*/*", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
if (acceptsAnything)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (acceptsAnything)
|
|
||||||
{
|
|
||||||
if (hasDefaultContentType)
|
|
||||||
{
|
|
||||||
return defaultContentType;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return ServerDefaultContentType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (acceptContentTypes == null && httpReq.ContentType == Soap11)
|
|
||||||
{
|
|
||||||
return Soap11;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We could also send a '406 Not Acceptable', but this is allowed also
|
|
||||||
return ServerDefaultContentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool HasAnyOfContentTypes(HttpRequest request, params string[] contentTypes)
|
|
||||||
{
|
|
||||||
if (contentTypes == null || request.ContentType == null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var contentType in contentTypes)
|
|
||||||
{
|
|
||||||
if (IsContentType(request, contentType))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool IsContentType(HttpRequest request, string contentType)
|
|
||||||
{
|
|
||||||
return request.ContentType.StartsWith(contentType, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetQueryStringContentType(HttpRequest httpReq)
|
|
||||||
{
|
|
||||||
ReadOnlySpan<char> format = httpReq.Query["format"].ToString();
|
|
||||||
if (format == ReadOnlySpan<char>.Empty)
|
|
||||||
{
|
|
||||||
const int FormatMaxLength = 4;
|
|
||||||
ReadOnlySpan<char> pi = httpReq.Path.ToString();
|
|
||||||
if (pi == null || pi.Length <= FormatMaxLength)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pi[0] == '/')
|
|
||||||
{
|
|
||||||
pi = pi.Slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
format = pi.LeftPart('/');
|
|
||||||
if (format.Length > FormatMaxLength)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
format = format.LeftPart('.');
|
|
||||||
if (format.Contains("json", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return "application/json";
|
|
||||||
}
|
|
||||||
else if (format.Contains("xml", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return "application/xml";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,154 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jellyfin.Api.Constants;
|
||||||
|
using Jellyfin.Api.Helpers;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Controller.QuickConnect;
|
||||||
|
using MediaBrowser.Model.QuickConnect;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Jellyfin.Api.Controllers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Quick connect controller.
|
||||||
|
/// </summary>
|
||||||
|
public class QuickConnectController : BaseJellyfinApiController
|
||||||
|
{
|
||||||
|
private readonly IQuickConnect _quickConnect;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="QuickConnectController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="quickConnect">Instance of the <see cref="IQuickConnect"/> interface.</param>
|
||||||
|
public QuickConnectController(IQuickConnect quickConnect)
|
||||||
|
{
|
||||||
|
_quickConnect = quickConnect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current quick connect state.
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200">Quick connect state returned.</response>
|
||||||
|
/// <returns>The current <see cref="QuickConnectState"/>.</returns>
|
||||||
|
[HttpGet("Status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<QuickConnectState> GetStatus()
|
||||||
|
{
|
||||||
|
_quickConnect.ExpireRequests();
|
||||||
|
return _quickConnect.State;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initiate a new quick connect request.
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200">Quick connect request successfully created.</response>
|
||||||
|
/// <response code="401">Quick connect is not active on this server.</response>
|
||||||
|
/// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns>
|
||||||
|
[HttpGet("Initiate")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<QuickConnectResult> Initiate()
|
||||||
|
{
|
||||||
|
return _quickConnect.TryConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to retrieve authentication information.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="secret">Secret previously returned from the Initiate endpoint.</param>
|
||||||
|
/// <response code="200">Quick connect result returned.</response>
|
||||||
|
/// <response code="404">Unknown quick connect secret.</response>
|
||||||
|
/// <returns>An updated <see cref="QuickConnectResult"/>.</returns>
|
||||||
|
[HttpGet("Connect")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public ActionResult<QuickConnectResult> Connect([FromQuery, Required] string secret)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _quickConnect.CheckRequestStatus(secret);
|
||||||
|
}
|
||||||
|
catch (ResourceNotFoundException)
|
||||||
|
{
|
||||||
|
return NotFound("Unknown secret");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Temporarily activates quick connect for five minutes.
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="204">Quick connect has been temporarily activated.</response>
|
||||||
|
/// <response code="403">Quick connect is unavailable on this server.</response>
|
||||||
|
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
|
||||||
|
[HttpPost("Activate")]
|
||||||
|
[Authorize(Policy = Policies.DefaultAuthorization)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public ActionResult Activate()
|
||||||
|
{
|
||||||
|
if (_quickConnect.State == QuickConnectState.Unavailable)
|
||||||
|
{
|
||||||
|
return Forbid("Quick connect is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
_quickConnect.Activate();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enables or disables quick connect.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="status">New <see cref="QuickConnectState"/>.</param>
|
||||||
|
/// <response code="204">Quick connect state set successfully.</response>
|
||||||
|
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
|
||||||
|
[HttpPost("Available")]
|
||||||
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public ActionResult Available([FromQuery] QuickConnectState status = QuickConnectState.Available)
|
||||||
|
{
|
||||||
|
_quickConnect.SetState(status);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authorizes a pending quick connect request.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="code">Quick connect code to authorize.</param>
|
||||||
|
/// <response code="200">Quick connect result authorized successfully.</response>
|
||||||
|
/// <response code="403">Unknown user id.</response>
|
||||||
|
/// <returns>Boolean indicating if the authorization was successful.</returns>
|
||||||
|
[HttpPost("Authorize")]
|
||||||
|
[Authorize(Policy = Policies.DefaultAuthorization)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public ActionResult<bool> Authorize([FromQuery, Required] string code)
|
||||||
|
{
|
||||||
|
var userId = ClaimHelpers.GetUserId(Request.HttpContext.User);
|
||||||
|
if (!userId.HasValue)
|
||||||
|
{
|
||||||
|
return Forbid("Unknown user id");
|
||||||
|
}
|
||||||
|
|
||||||
|
return _quickConnect.AuthorizeRequest(userId.Value, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deauthorize all quick connect devices for the current user.
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200">All quick connect devices were deleted.</response>
|
||||||
|
/// <returns>The number of devices that were deleted.</returns>
|
||||||
|
[HttpPost("Deauthorize")]
|
||||||
|
[Authorize(Policy = Policies.DefaultAuthorization)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<int> Deauthorize()
|
||||||
|
{
|
||||||
|
var userId = ClaimHelpers.GetUserId(Request.HttpContext.User);
|
||||||
|
if (!userId.HasValue)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _quickConnect.DeleteAllDevices(userId.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ConfigurationExtensions = MediaBrowser.Controller.Extensions.ConfigurationExtensions;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Middleware
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Redirect requests without baseurl prefix to the baseurl prefixed URL.
|
||||||
|
/// </summary>
|
||||||
|
public class BaseUrlRedirectionMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<BaseUrlRedirectionMiddleware> _logger;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="BaseUrlRedirectionMiddleware"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="next">The next delegate in the pipeline.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="configuration">The application configuration.</param>
|
||||||
|
public BaseUrlRedirectionMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
ILogger<BaseUrlRedirectionMiddleware> logger,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
_configuration = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the middleware action.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpContext">The current HTTP context.</param>
|
||||||
|
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||||
|
/// <returns>The async task.</returns>
|
||||||
|
public async Task Invoke(HttpContext httpContext, IServerConfigurationManager serverConfigurationManager)
|
||||||
|
{
|
||||||
|
var localPath = httpContext.Request.Path.ToString();
|
||||||
|
var baseUrlPrefix = serverConfigurationManager.Configuration.BaseUrl;
|
||||||
|
|
||||||
|
if (string.Equals(localPath, baseUrlPrefix + "/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(localPath, baseUrlPrefix, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(localPath, "/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.IsNullOrEmpty(localPath)
|
||||||
|
|| !localPath.StartsWith(baseUrlPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Always redirect back to the default path if the base prefix is invalid or missing
|
||||||
|
_logger.LogDebug("Normalizing an URL at {LocalPath}", localPath);
|
||||||
|
httpContext.Response.Redirect(baseUrlPrefix + "/" + _configuration[ConfigurationExtensions.DefaultRedirectKey]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(httpContext).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,76 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Common.Net;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Middleware
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the IP of requests coming from local networks wrt. remote access.
|
||||||
|
/// </summary>
|
||||||
|
public class IpBasedAccessValidationMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="IpBasedAccessValidationMiddleware"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="next">The next delegate in the pipeline.</param>
|
||||||
|
public IpBasedAccessValidationMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the middleware action.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpContext">The current HTTP context.</param>
|
||||||
|
/// <param name="networkManager">The network manager.</param>
|
||||||
|
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||||
|
/// <returns>The async task.</returns>
|
||||||
|
public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager)
|
||||||
|
{
|
||||||
|
if (httpContext.Request.IsLocal())
|
||||||
|
{
|
||||||
|
await _next(httpContext).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var remoteIp = httpContext.Request.RemoteIp();
|
||||||
|
|
||||||
|
if (serverConfigurationManager.Configuration.EnableRemoteAccess)
|
||||||
|
{
|
||||||
|
var addressFilter = serverConfigurationManager.Configuration.RemoteIPFilter.Where(i => !string.IsNullOrWhiteSpace(i)).ToArray();
|
||||||
|
|
||||||
|
if (addressFilter.Length > 0 && !networkManager.IsInLocalNetwork(remoteIp))
|
||||||
|
{
|
||||||
|
if (serverConfigurationManager.Configuration.IsRemoteIPFilterBlacklist)
|
||||||
|
{
|
||||||
|
if (networkManager.IsAddressInSubnets(remoteIp, addressFilter))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!networkManager.IsAddressInSubnets(remoteIp, addressFilter))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!networkManager.IsInLocalNetwork(remoteIp))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(httpContext).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,76 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Common.Net;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Middleware
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the LAN host IP based on application configuration.
|
||||||
|
/// </summary>
|
||||||
|
public class LanFilteringMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="LanFilteringMiddleware"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="next">The next delegate in the pipeline.</param>
|
||||||
|
public LanFilteringMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the middleware action.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpContext">The current HTTP context.</param>
|
||||||
|
/// <param name="networkManager">The network manager.</param>
|
||||||
|
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||||
|
/// <returns>The async task.</returns>
|
||||||
|
public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager)
|
||||||
|
{
|
||||||
|
var currentHost = httpContext.Request.Host.ToString();
|
||||||
|
var hosts = serverConfigurationManager
|
||||||
|
.Configuration
|
||||||
|
.LocalNetworkAddresses
|
||||||
|
.Select(NormalizeConfiguredLocalAddress)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (hosts.Count == 0)
|
||||||
|
{
|
||||||
|
await _next(httpContext).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentHost ??= string.Empty;
|
||||||
|
|
||||||
|
if (networkManager.IsInPrivateAddressSpace(currentHost))
|
||||||
|
{
|
||||||
|
hosts.Add("localhost");
|
||||||
|
hosts.Add("127.0.0.1");
|
||||||
|
|
||||||
|
if (hosts.All(i => currentHost.IndexOf(i, StringComparison.OrdinalIgnoreCase) == -1))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(httpContext).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeConfiguredLocalAddress(string address)
|
||||||
|
{
|
||||||
|
var add = address.AsSpan().Trim('/');
|
||||||
|
int index = add.IndexOf('/');
|
||||||
|
if (index != -1)
|
||||||
|
{
|
||||||
|
add = add.Slice(index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return add.TrimStart('/').ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
using System.Net.Mime;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Model.Globalization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Middleware
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Shows a custom message during server startup.
|
||||||
|
/// </summary>
|
||||||
|
public class ServerStartupMessageMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ServerStartupMessageMiddleware"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="next">The next delegate in the pipeline.</param>
|
||||||
|
public ServerStartupMessageMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the middleware action.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpContext">The current HTTP context.</param>
|
||||||
|
/// <param name="serverApplicationHost">The server application host.</param>
|
||||||
|
/// <param name="localizationManager">The localization manager.</param>
|
||||||
|
/// <returns>The async task.</returns>
|
||||||
|
public async Task Invoke(
|
||||||
|
HttpContext httpContext,
|
||||||
|
IServerApplicationHost serverApplicationHost,
|
||||||
|
ILocalizationManager localizationManager)
|
||||||
|
{
|
||||||
|
if (serverApplicationHost.CoreStartupHasCompleted)
|
||||||
|
{
|
||||||
|
await _next(httpContext).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = localizationManager.GetLocalizedString("StartupEmbyServerIsLoading");
|
||||||
|
httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||||
|
httpContext.Response.ContentType = MediaTypeNames.Text.Html;
|
||||||
|
await httpContext.Response.WriteAsync(message, httpContext.RequestAborted).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Net;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Middleware
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Handles WebSocket requests.
|
||||||
|
/// </summary>
|
||||||
|
public class WebSocketHandlerMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="WebSocketHandlerMiddleware"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="next">The next delegate in the pipeline.</param>
|
||||||
|
public WebSocketHandlerMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the middleware action.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpContext">The current HTTP context.</param>
|
||||||
|
/// <param name="webSocketManager">The WebSocket connection manager.</param>
|
||||||
|
/// <returns>The async task.</returns>
|
||||||
|
public async Task Invoke(HttpContext httpContext, IWebSocketManager webSocketManager)
|
||||||
|
{
|
||||||
|
if (!httpContext.WebSockets.IsWebSocketRequest)
|
||||||
|
{
|
||||||
|
await _next(httpContext).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await webSocketManager.WebSocketRequestHandler(httpContext).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,76 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using MediaBrowser.Model.Services;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Controller.Net
|
|
||||||
{
|
|
||||||
public class AuthenticatedAttribute : Attribute, IHasRequestFilter, IAuthenticationAttributes
|
|
||||||
{
|
|
||||||
public static IAuthService AuthService { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the roles.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The roles.</value>
|
|
||||||
public string Roles { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether [escape parental control].
|
|
||||||
/// </summary>
|
|
||||||
/// <value><c>true</c> if [escape parental control]; otherwise, <c>false</c>.</value>
|
|
||||||
public bool EscapeParentalControl { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether [allow before startup wizard].
|
|
||||||
/// </summary>
|
|
||||||
/// <value><c>true</c> if [allow before startup wizard]; otherwise, <c>false</c>.</value>
|
|
||||||
public bool AllowBeforeStartupWizard { get; set; }
|
|
||||||
|
|
||||||
public bool AllowLocal { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The request filter is executed before the service.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The http request wrapper.</param>
|
|
||||||
/// <param name="response">The http response wrapper.</param>
|
|
||||||
/// <param name="requestDto">The request DTO.</param>
|
|
||||||
public void RequestFilter(IRequest request, HttpResponse response, object requestDto)
|
|
||||||
{
|
|
||||||
AuthService.Authenticate(request, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Order in which Request Filters are executed.
|
|
||||||
/// <0 Executed before global request filters
|
|
||||||
/// >0 Executed after global request filters
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The priority.</value>
|
|
||||||
public int Priority => 0;
|
|
||||||
|
|
||||||
public string[] GetRoles()
|
|
||||||
{
|
|
||||||
return (Roles ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IgnoreLegacyAuth { get; set; }
|
|
||||||
|
|
||||||
public bool AllowLocalOnly { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IAuthenticationAttributes
|
|
||||||
{
|
|
||||||
bool EscapeParentalControl { get; }
|
|
||||||
|
|
||||||
bool AllowBeforeStartupWizard { get; }
|
|
||||||
|
|
||||||
bool AllowLocal { get; }
|
|
||||||
|
|
||||||
bool AllowLocalOnly { get; }
|
|
||||||
|
|
||||||
string[] GetRoles();
|
|
||||||
|
|
||||||
bool IgnoreLegacyAuth { get; }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
using MediaBrowser.Model.Services;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Controller.Net
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Interface IHasResultFactory
|
|
||||||
/// Services that require a ResultFactory should implement this
|
|
||||||
/// </summary>
|
|
||||||
public interface IHasResultFactory : IRequiresRequest
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the result factory.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The result factory.</value>
|
|
||||||
IHttpResultFactory ResultFactory { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,82 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using MediaBrowser.Model.Services;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Controller.Net
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Interface IHttpResultFactory.
|
|
||||||
/// </summary>
|
|
||||||
public interface IHttpResultFactory
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the result.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="content">The content.</param>
|
|
||||||
/// <param name="contentType">Type of the content.</param>
|
|
||||||
/// <param name="responseHeaders">The response headers.</param>
|
|
||||||
/// <returns>System.Object.</returns>
|
|
||||||
object GetResult(string content, string contentType, IDictionary<string, string> responseHeaders = null);
|
|
||||||
|
|
||||||
object GetResult(IRequest requestContext, byte[] content, string contentType, IDictionary<string, string> responseHeaders = null);
|
|
||||||
|
|
||||||
object GetResult(IRequest requestContext, Stream content, string contentType, IDictionary<string, string> responseHeaders = null);
|
|
||||||
|
|
||||||
object GetResult(IRequest requestContext, string content, string contentType, IDictionary<string, string> responseHeaders = null);
|
|
||||||
|
|
||||||
object GetRedirectResult(string url);
|
|
||||||
|
|
||||||
object GetResult<T>(IRequest requestContext, T result, IDictionary<string, string> responseHeaders = null)
|
|
||||||
where T : class;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the static result.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="requestContext">The request context.</param>
|
|
||||||
/// <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>
|
|
||||||
/// <param name="responseHeaders">The response headers.</param>
|
|
||||||
/// <param name="isHeadRequest">if set to <c>true</c> [is head request].</param>
|
|
||||||
/// <returns>System.Object.</returns>
|
|
||||||
Task<object> GetStaticResult(IRequest requestContext,
|
|
||||||
Guid cacheKey,
|
|
||||||
DateTime? lastDateModified,
|
|
||||||
TimeSpan? cacheDuration,
|
|
||||||
string contentType, Func<Task<Stream>> factoryFn,
|
|
||||||
IDictionary<string, string> responseHeaders = null,
|
|
||||||
bool isHeadRequest = false);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the static result.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="requestContext">The request context.</param>
|
|
||||||
/// <param name="options">The options.</param>
|
|
||||||
/// <returns>System.Object.</returns>
|
|
||||||
Task<object> GetStaticResult(IRequest requestContext, StaticResultOptions options);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the static file result.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="requestContext">The request context.</param>
|
|
||||||
/// <param name="path">The path.</param>
|
|
||||||
/// <param name="fileShare">The file share.</param>
|
|
||||||
/// <returns>System.Object.</returns>
|
|
||||||
Task<object> GetStaticFileResult(IRequest requestContext, string path, FileShare fileShare = FileShare.Read);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the static file result.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="requestContext">The request context.</param>
|
|
||||||
/// <param name="options">The options.</param>
|
|
||||||
/// <returns>System.Object.</returns>
|
|
||||||
Task<object> GetStaticFileResult(IRequest requestContext,
|
|
||||||
StaticFileResultOptions options);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,50 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Jellyfin.Data.Events;
|
|
||||||
using MediaBrowser.Model.Services;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Controller.Net
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Interface IHttpServer.
|
|
||||||
/// </summary>
|
|
||||||
public interface IHttpServer
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the URL prefix.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The URL prefix.</value>
|
|
||||||
string[] UrlPrefixes { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when [web socket connected].
|
|
||||||
/// </summary>
|
|
||||||
event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Inits this instance.
|
|
||||||
/// </summary>
|
|
||||||
void Init(IEnumerable<Type> serviceTypes, IEnumerable<IWebSocketListener> listener, IEnumerable<string> urlPrefixes);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// If set, all requests will respond with this message.
|
|
||||||
/// </summary>
|
|
||||||
string GlobalResponse { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The HTTP request handler.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="context"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
Task RequestHandler(HttpContext context);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the default CORS headers.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="req"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
IDictionary<string, string> GetDefaultCorsHeaders(IRequest req);
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,32 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Events;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.Net
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Interface IHttpServer.
|
||||||
|
/// </summary>
|
||||||
|
public interface IWebSocketManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Occurs when [web socket connected].
|
||||||
|
/// </summary>
|
||||||
|
event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inits this instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="listeners">The websocket listeners.</param>
|
||||||
|
void Init(IEnumerable<IWebSocketListener> listeners);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The HTTP request handler.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The current HTTP context.</param>
|
||||||
|
/// <returns>The task.</returns>
|
||||||
|
Task WebSocketRequestHandler(HttpContext context);
|
||||||
|
}
|
||||||
|
}
|
@ -1,44 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Controller.Net
|
|
||||||
{
|
|
||||||
public class StaticResultOptions
|
|
||||||
{
|
|
||||||
public string ContentType { get; set; }
|
|
||||||
|
|
||||||
public TimeSpan? CacheDuration { get; set; }
|
|
||||||
|
|
||||||
public DateTime? DateLastModified { get; set; }
|
|
||||||
|
|
||||||
public Func<Task<Stream>> ContentFactory { get; set; }
|
|
||||||
|
|
||||||
public bool IsHeadRequest { get; set; }
|
|
||||||
|
|
||||||
public IDictionary<string, string> ResponseHeaders { get; set; }
|
|
||||||
|
|
||||||
public Action OnComplete { get; set; }
|
|
||||||
|
|
||||||
public Action OnError { get; set; }
|
|
||||||
|
|
||||||
public string Path { get; set; }
|
|
||||||
|
|
||||||
public long? ContentLength { get; set; }
|
|
||||||
|
|
||||||
public FileShare FileShare { get; set; }
|
|
||||||
|
|
||||||
public StaticResultOptions()
|
|
||||||
{
|
|
||||||
ResponseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
FileShare = FileShare.Read;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class StaticFileResultOptions : StaticResultOptions
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using MediaBrowser.Model.QuickConnect;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.QuickConnect
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Quick connect standard interface.
|
||||||
|
/// </summary>
|
||||||
|
public interface IQuickConnect
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the length of user facing codes.
|
||||||
|
/// </summary>
|
||||||
|
int CodeLength { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of internal access tokens.
|
||||||
|
/// </summary>
|
||||||
|
string TokenName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current state of quick connect.
|
||||||
|
/// </summary>
|
||||||
|
QuickConnectState State { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the time (in minutes) before quick connect will automatically deactivate.
|
||||||
|
/// </summary>
|
||||||
|
int Timeout { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assert that quick connect is currently active and throws an exception if it is not.
|
||||||
|
/// </summary>
|
||||||
|
void AssertActive();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Temporarily activates quick connect for a short amount of time.
|
||||||
|
/// </summary>
|
||||||
|
void Activate();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes the state of quick connect.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="newState">New state to change to.</param>
|
||||||
|
void SetState(QuickConnectState newState);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initiates a new quick connect request.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A quick connect result with tokens to proceed or throws an exception if not active.</returns>
|
||||||
|
QuickConnectResult TryConnect();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the status of an individual request.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="secret">Unique secret identifier of the request.</param>
|
||||||
|
/// <returns>Quick connect result.</returns>
|
||||||
|
QuickConnectResult CheckRequestStatus(string secret);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authorizes a quick connect request to connect as the calling user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">User id.</param>
|
||||||
|
/// <param name="code">Identifying code for the request.</param>
|
||||||
|
/// <returns>A boolean indicating if the authorization completed successfully.</returns>
|
||||||
|
bool AuthorizeRequest(Guid userId, string code);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expire quick connect requests that are over the time limit. If <paramref name="expireAll"/> is true, all requests are unconditionally expired.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="expireAll">If true, all requests will be expired.</param>
|
||||||
|
void ExpireRequests(bool expireAll = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes all quick connect access tokens for the provided user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">Guid of the user to delete tokens for.</param>
|
||||||
|
/// <returns>A count of the deleted tokens.</returns>
|
||||||
|
int DeleteAllDevices(Guid user);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a short code to display to the user to uniquely identify this request.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A short, unique alphanumeric string.</returns>
|
||||||
|
string GenerateCode();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Model.QuickConnect
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Stores the result of an incoming quick connect request.
|
||||||
|
/// </summary>
|
||||||
|
public class QuickConnectResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether this request is authorized.
|
||||||
|
/// </summary>
|
||||||
|
public bool Authenticated => !string.IsNullOrEmpty(Authentication);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the secret value used to uniquely identify this request. Can be used to retrieve authentication information.
|
||||||
|
/// </summary>
|
||||||
|
public string? Secret { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the user facing code used so the user can quickly differentiate this request from others.
|
||||||
|
/// </summary>
|
||||||
|
public string? Code { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the private access token.
|
||||||
|
/// </summary>
|
||||||
|
public string? Authentication { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets an error message.
|
||||||
|
/// </summary>
|
||||||
|
public string? Error { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the DateTime that this request was created.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? DateAdded { get; set; }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
namespace MediaBrowser.Model.QuickConnect
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Quick connect state.
|
||||||
|
/// </summary>
|
||||||
|
public enum QuickConnectState
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This feature has not been opted into and is unavailable until the server administrator chooses to opt-in.
|
||||||
|
/// </summary>
|
||||||
|
Unavailable = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The feature is enabled for use on the server but is not currently accepting connection requests.
|
||||||
|
/// </summary>
|
||||||
|
Available = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The feature is actively accepting connection requests.
|
||||||
|
/// </summary>
|
||||||
|
Active = 2
|
||||||
|
}
|
||||||
|
}
|
@ -1,65 +0,0 @@
|
|||||||
#nullable disable
|
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Model.Services
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Identifies a single API endpoint.
|
|
||||||
/// </summary>
|
|
||||||
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = true)]
|
|
||||||
public class ApiMemberAttribute : Attribute
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets verb to which applies attribute. By default applies to all verbs.
|
|
||||||
/// </summary>
|
|
||||||
public string Verb { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets parameter type: It can be only one of the following: path, query, body, form, or header.
|
|
||||||
/// </summary>
|
|
||||||
public string ParameterType { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets unique name for the parameter. Each name must be unique, even if they are associated with different paramType values.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// Other notes on the name field:
|
|
||||||
/// If paramType is body, the name is used only for UI and codegeneration.
|
|
||||||
/// If paramType is path, the name field must correspond to the associated path segment from the path field in the api object.
|
|
||||||
/// If paramType is query, the name field corresponds to the query param name.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
public string Name { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the human-readable description for the parameter.
|
|
||||||
/// </summary>
|
|
||||||
public string Description { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// For path, query, and header paramTypes, this field must be a primitive. For body, this can be a complex or container datatype.
|
|
||||||
/// </summary>
|
|
||||||
public string DataType { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// For path, this is always true. Otherwise, this field tells the client whether or not the field must be supplied.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsRequired { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// For query params, this specifies that a comma-separated list of values can be passed to the API. For path and body types, this field cannot be true.
|
|
||||||
/// </summary>
|
|
||||||
public bool AllowMultiple { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets route to which applies attribute, matches using StartsWith. By default applies to all routes.
|
|
||||||
/// </summary>
|
|
||||||
public string Route { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to exclude this property from being included in the ModelSchema.
|
|
||||||
/// </summary>
|
|
||||||
public bool ExcludeInSchema { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System.IO;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Model.Services
|
|
||||||
{
|
|
||||||
public interface IAsyncStreamWriter
|
|
||||||
{
|
|
||||||
Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Model.Services
|
|
||||||
{
|
|
||||||
public interface IHasHeaders
|
|
||||||
{
|
|
||||||
IDictionary<string, string> Headers { get; }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Model.Services
|
|
||||||
{
|
|
||||||
public interface IHasRequestFilter
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the order in which Request Filters are executed.
|
|
||||||
/// <0 Executed before global request filters.
|
|
||||||
/// >0 Executed after global request filters.
|
|
||||||
/// </summary>
|
|
||||||
int Priority { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The request filter is executed before the service.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="req">The http request wrapper.</param>
|
|
||||||
/// <param name="res">The http response wrapper.</param>
|
|
||||||
/// <param name="requestDto">The request DTO.</param>
|
|
||||||
void RequestFilter(IRequest req, HttpResponse res, object requestDto);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
namespace MediaBrowser.Model.Services
|
|
||||||
{
|
|
||||||
public interface IHttpRequest : IRequest
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the HTTP Verb.
|
|
||||||
/// </summary>
|
|
||||||
string HttpMethod { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the value of the Accept HTTP Request Header.
|
|
||||||
/// </summary>
|
|
||||||
string Accept { get; }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
#nullable disable
|
|
||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System.Net;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Model.Services
|
|
||||||
{
|
|
||||||
public interface IHttpResult : IHasHeaders
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The HTTP Response Status.
|
|
||||||
/// </summary>
|
|
||||||
int Status { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The HTTP Response Status Code.
|
|
||||||
/// </summary>
|
|
||||||
HttpStatusCode StatusCode { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The HTTP Response ContentType.
|
|
||||||
/// </summary>
|
|
||||||
string ContentType { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO.
|
|
||||||
/// </summary>
|
|
||||||
object Response { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Holds the request call context.
|
|
||||||
/// </summary>
|
|
||||||
IRequest RequestContext { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,93 +0,0 @@
|
|||||||
#nullable disable
|
|
||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Model.Services
|
|
||||||
{
|
|
||||||
public interface IRequest
|
|
||||||
{
|
|
||||||
HttpResponse Response { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The name of the service being called (e.g. Request DTO Name)
|
|
||||||
/// </summary>
|
|
||||||
string OperationName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The Verb / HttpMethod or Action for this request
|
|
||||||
/// </summary>
|
|
||||||
string Verb { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The request ContentType.
|
|
||||||
/// </summary>
|
|
||||||
string ContentType { get; }
|
|
||||||
|
|
||||||
bool IsLocal { get; }
|
|
||||||
|
|
||||||
string UserAgent { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The expected Response ContentType for this request.
|
|
||||||
/// </summary>
|
|
||||||
string ResponseContentType { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attach any data to this request that all filters and services can access.
|
|
||||||
/// </summary>
|
|
||||||
Dictionary<string, object> Items { get; }
|
|
||||||
|
|
||||||
IHeaderDictionary Headers { get; }
|
|
||||||
|
|
||||||
IQueryCollection QueryString { get; }
|
|
||||||
|
|
||||||
string RawUrl { get; }
|
|
||||||
|
|
||||||
string AbsoluteUri { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The Remote Ip as reported by X-Forwarded-For, X-Real-IP or Request.UserHostAddress
|
|
||||||
/// </summary>
|
|
||||||
string RemoteIp { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The value of the Authorization Header used to send the Api Key, null if not available.
|
|
||||||
/// </summary>
|
|
||||||
string Authorization { get; }
|
|
||||||
|
|
||||||
string[] AcceptTypes { get; }
|
|
||||||
|
|
||||||
string PathInfo { get; }
|
|
||||||
|
|
||||||
Stream InputStream { get; }
|
|
||||||
|
|
||||||
long ContentLength { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The value of the Referrer, null if not available.
|
|
||||||
/// </summary>
|
|
||||||
Uri UrlReferrer { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IHttpFile
|
|
||||||
{
|
|
||||||
string Name { get; }
|
|
||||||
|
|
||||||
string FileName { get; }
|
|
||||||
|
|
||||||
long ContentLength { get; }
|
|
||||||
|
|
||||||
string ContentType { get; }
|
|
||||||
|
|
||||||
Stream InputStream { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IRequiresRequest
|
|
||||||
{
|
|
||||||
IRequest Request { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,14 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System.IO;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Model.Services
|
|
||||||
{
|
|
||||||
public interface IRequiresRequestStream
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The raw Http Request Input Stream.
|
|
||||||
/// </summary>
|
|
||||||
Stream RequestStream { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
namespace MediaBrowser.Model.Services
|
|
||||||
{
|
|
||||||
// marker interface
|
|
||||||
public interface IService
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IReturn { }
|
|
||||||
|
|
||||||
public interface IReturn<T> : IReturn { }
|
|
||||||
|
|
||||||
public interface IReturnVoid : IReturn { }
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System.IO;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Model.Services
|
|
||||||
{
|
|
||||||
public interface IStreamWriter
|
|
||||||
{
|
|
||||||
void WriteTo(Stream responseStream);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,147 +0,0 @@
|
|||||||
#nullable disable
|
|
||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using MediaBrowser.Model.Dto;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Model.Services
|
|
||||||
{
|
|
||||||
// Remove this garbage class, it's just a bastard copy of NameValueCollection
|
|
||||||
public class QueryParamCollection : List<NameValuePair>
|
|
||||||
{
|
|
||||||
public QueryParamCollection()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
private static StringComparison GetStringComparison()
|
|
||||||
{
|
|
||||||
return StringComparison.OrdinalIgnoreCase;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds a new query parameter.
|
|
||||||
/// </summary>
|
|
||||||
public void Add(string key, string value)
|
|
||||||
{
|
|
||||||
Add(new NameValuePair(key, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Set(string key, string value)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(value))
|
|
||||||
{
|
|
||||||
var parameters = GetItems(key);
|
|
||||||
|
|
||||||
foreach (var p in parameters)
|
|
||||||
{
|
|
||||||
Remove(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var pair in this)
|
|
||||||
{
|
|
||||||
var stringComparison = GetStringComparison();
|
|
||||||
|
|
||||||
if (string.Equals(key, pair.Name, stringComparison))
|
|
||||||
{
|
|
||||||
pair.Value = value;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Add(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string Get(string name)
|
|
||||||
{
|
|
||||||
var stringComparison = GetStringComparison();
|
|
||||||
|
|
||||||
foreach (var pair in this)
|
|
||||||
{
|
|
||||||
if (string.Equals(pair.Name, name, stringComparison))
|
|
||||||
{
|
|
||||||
return pair.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<NameValuePair> GetItems(string name)
|
|
||||||
{
|
|
||||||
var stringComparison = GetStringComparison();
|
|
||||||
|
|
||||||
var list = new List<NameValuePair>();
|
|
||||||
|
|
||||||
foreach (var pair in this)
|
|
||||||
{
|
|
||||||
if (string.Equals(pair.Name, name, stringComparison))
|
|
||||||
{
|
|
||||||
list.Add(pair);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public virtual List<string> GetValues(string name)
|
|
||||||
{
|
|
||||||
var stringComparison = GetStringComparison();
|
|
||||||
|
|
||||||
var list = new List<string>();
|
|
||||||
|
|
||||||
foreach (var pair in this)
|
|
||||||
{
|
|
||||||
if (string.Equals(pair.Name, name, stringComparison))
|
|
||||||
{
|
|
||||||
list.Add(pair.Value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IEnumerable<string> Keys
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var keys = new string[this.Count];
|
|
||||||
|
|
||||||
for (var i = 0; i < keys.Length; i++)
|
|
||||||
{
|
|
||||||
keys[i] = this[i].Name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a query parameter value by name. A query may contain multiple values of the same name
|
|
||||||
/// (i.e. "x=1&x=2"), in which case the value is an array, which works for both getting and setting.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="name">The query parameter name.</param>
|
|
||||||
/// <returns>The query parameter value or array of values.</returns>
|
|
||||||
public string this[string name]
|
|
||||||
{
|
|
||||||
get => Get(name);
|
|
||||||
set => Set(name, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetQueryStringValue(NameValuePair pair)
|
|
||||||
{
|
|
||||||
return pair.Name + "=" + pair.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
var vals = this.Select(GetQueryStringValue).ToArray();
|
|
||||||
|
|
||||||
return string.Join("&", vals);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,163 +0,0 @@
|
|||||||
#nullable disable
|
|
||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Model.Services
|
|
||||||
{
|
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
|
|
||||||
public class RouteAttribute : Attribute
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// <para>Initializes an instance of the <see cref="RouteAttribute"/> class.</para>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="path">
|
|
||||||
/// <para>The path template to map to the request. See
|
|
||||||
/// <see cref="Path">RouteAttribute.Path</see>
|
|
||||||
/// for details on the correct format.</para>
|
|
||||||
/// </param>
|
|
||||||
public RouteAttribute(string path)
|
|
||||||
: this(path, null)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <para>Initializes an instance of the <see cref="RouteAttribute"/> class.</para>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="path">
|
|
||||||
/// <para>The path template to map to the request. See
|
|
||||||
/// <see cref="Path">RouteAttribute.Path</see>
|
|
||||||
/// for details on the correct format.</para>
|
|
||||||
/// </param>
|
|
||||||
/// <param name="verbs">A comma-delimited list of HTTP verbs supported by the
|
|
||||||
/// service. If unspecified, all verbs are assumed to be supported.</param>
|
|
||||||
public RouteAttribute(string path, string verbs)
|
|
||||||
{
|
|
||||||
Path = path;
|
|
||||||
Verbs = verbs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the path template to be mapped to the request.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>
|
|
||||||
/// A <see cref="String"/> value providing the path mapped to
|
|
||||||
/// the request. Never <see langword="null"/>.
|
|
||||||
/// </value>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>Some examples of valid paths are:</para>
|
|
||||||
///
|
|
||||||
/// <list>
|
|
||||||
/// <item>"/Inventory"</item>
|
|
||||||
/// <item>"/Inventory/{Category}/{ItemId}"</item>
|
|
||||||
/// <item>"/Inventory/{ItemPath*}"</item>
|
|
||||||
/// </list>
|
|
||||||
///
|
|
||||||
/// <para>Variables are specified within "{}"
|
|
||||||
/// brackets. Each variable in the path is mapped to the same-named property
|
|
||||||
/// on the request DTO. At runtime, ServiceStack will parse the
|
|
||||||
/// request URL, extract the variable values, instantiate the request DTO,
|
|
||||||
/// and assign the variable values into the corresponding request properties,
|
|
||||||
/// prior to passing the request DTO to the service object for processing.</para>
|
|
||||||
///
|
|
||||||
/// <para>It is not necessary to specify all request properties as
|
|
||||||
/// variables in the path. For unspecified properties, callers may provide
|
|
||||||
/// values in the query string. For example: the URL
|
|
||||||
/// "http://services/Inventory?Category=Books&ItemId=12345" causes the same
|
|
||||||
/// request DTO to be processed as "http://services/Inventory/Books/12345",
|
|
||||||
/// provided that the paths "/Inventory" (which supports the first URL) and
|
|
||||||
/// "/Inventory/{Category}/{ItemId}" (which supports the second URL)
|
|
||||||
/// are both mapped to the request DTO.</para>
|
|
||||||
///
|
|
||||||
/// <para>Please note that while it is possible to specify property values
|
|
||||||
/// in the query string, it is generally considered to be less RESTful and
|
|
||||||
/// less desirable than to specify them as variables in the path. Using the
|
|
||||||
/// query string to specify property values may also interfere with HTTP
|
|
||||||
/// caching.</para>
|
|
||||||
///
|
|
||||||
/// <para>The final variable in the path may contain a "*" suffix
|
|
||||||
/// to grab all remaining segments in the path portion of the request URL and assign
|
|
||||||
/// them to a single property on the request DTO.
|
|
||||||
/// For example, if the path "/Inventory/{ItemPath*}" is mapped to the request DTO,
|
|
||||||
/// then the request URL "http://services/Inventory/Books/12345" will result
|
|
||||||
/// in a request DTO whose ItemPath property contains "Books/12345".
|
|
||||||
/// You may only specify one such variable in the path, and it must be positioned at
|
|
||||||
/// the end of the path.</para>
|
|
||||||
/// </remarks>
|
|
||||||
public string Path { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets short summary of what the route does.
|
|
||||||
/// </summary>
|
|
||||||
public string Summary { get; set; }
|
|
||||||
|
|
||||||
public string Description { get; set; }
|
|
||||||
|
|
||||||
public bool IsHidden { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets longer text to explain the behaviour of the route.
|
|
||||||
/// </summary>
|
|
||||||
public string Notes { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a comma-delimited list of HTTP verbs supported by the service, such as
|
|
||||||
/// "GET,PUT,POST,DELETE".
|
|
||||||
/// </summary>
|
|
||||||
/// <value>
|
|
||||||
/// A <see cref="String"/> providing a comma-delimited list of HTTP verbs supported
|
|
||||||
/// by the service, <see langword="null"/> or empty if all verbs are supported.
|
|
||||||
/// </value>
|
|
||||||
public string Verbs { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Used to rank the precedences of route definitions in reverse routing.
|
|
||||||
/// i.e. Priorities below 0 are auto-generated have less precedence.
|
|
||||||
/// </summary>
|
|
||||||
public int Priority { get; set; }
|
|
||||||
|
|
||||||
protected bool Equals(RouteAttribute other)
|
|
||||||
{
|
|
||||||
return base.Equals(other)
|
|
||||||
&& string.Equals(Path, other.Path)
|
|
||||||
&& string.Equals(Summary, other.Summary)
|
|
||||||
&& string.Equals(Notes, other.Notes)
|
|
||||||
&& string.Equals(Verbs, other.Verbs)
|
|
||||||
&& Priority == other.Priority;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool Equals(object obj)
|
|
||||||
{
|
|
||||||
if (ReferenceEquals(null, obj))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ReferenceEquals(this, obj))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (obj.GetType() != this.GetType())
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Equals((RouteAttribute)obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override int GetHashCode()
|
|
||||||
{
|
|
||||||
unchecked
|
|
||||||
{
|
|
||||||
var hashCode = base.GetHashCode();
|
|
||||||
hashCode = (hashCode * 397) ^ (Path != null ? Path.GetHashCode() : 0);
|
|
||||||
hashCode = (hashCode * 397) ^ (Summary != null ? Summary.GetHashCode() : 0);
|
|
||||||
hashCode = (hashCode * 397) ^ (Notes != null ? Notes.GetHashCode() : 0);
|
|
||||||
hashCode = (hashCode * 397) ^ (Verbs != null ? Verbs.GetHashCode() : 0);
|
|
||||||
hashCode = (hashCode * 397) ^ Priority;
|
|
||||||
return hashCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue