New: Use ASP.NET Core instead of Nancy

This commit is contained in:
ta264
2021-03-11 20:56:24 +00:00
parent d348232e0d
commit 58ddbcd77e
158 changed files with 2747 additions and 3544 deletions
@@ -1,31 +0,0 @@
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using Nancy;
using Nancy.Responses.Negotiation;
using NzbDrone.Common.Serializer;
namespace Readarr.Http.Extensions
{
public class NancyJsonSerializer : ISerializer
{
protected readonly JsonSerializerOptions _serializerSettings;
public NancyJsonSerializer()
{
_serializerSettings = STJson.GetSerializerSettings();
}
public bool CanSerialize(MediaRange contentType)
{
return contentType == "application/json";
}
public void Serialize<TModel>(MediaRange contentType, TModel model, Stream outputStream)
{
STJson.Serialize(model, outputStream, _serializerSettings);
}
public IEnumerable<string> Extensions { get; private set; }
}
}
@@ -1,40 +0,0 @@
using Nancy;
using Nancy.Bootstrapper;
using Readarr.Http.Frontend;
namespace Readarr.Http.Extensions.Pipelines
{
public class CacheHeaderPipeline : IRegisterNancyPipeline
{
private readonly ICacheableSpecification _cacheableSpecification;
public CacheHeaderPipeline(ICacheableSpecification cacheableSpecification)
{
_cacheableSpecification = cacheableSpecification;
}
public int Order => 0;
public void Register(IPipelines pipelines)
{
pipelines.AfterRequest.AddItemToStartOfPipeline(Handle);
}
private void Handle(NancyContext context)
{
if (context.Request.Method == "OPTIONS")
{
return;
}
if (_cacheableSpecification.IsCacheable(context))
{
context.Response.Headers.EnableCache();
}
else
{
context.Response.Headers.DisableCache();
}
}
}
}
@@ -1,80 +0,0 @@
using System.Linq;
using Nancy;
using Nancy.Bootstrapper;
using NzbDrone.Common.Extensions;
namespace Readarr.Http.Extensions.Pipelines
{
public class CorsPipeline : IRegisterNancyPipeline
{
public int Order => 0;
public void Register(IPipelines pipelines)
{
pipelines.BeforeRequest.AddItemToEndOfPipeline(HandleRequest);
pipelines.AfterRequest.AddItemToEndOfPipeline(HandleResponse);
}
private Response HandleRequest(NancyContext context)
{
if (context == null || context.Request.Method != "OPTIONS")
{
return null;
}
var response = new Response()
.WithStatusCode(HttpStatusCode.OK)
.WithContentType("");
ApplyResponseHeaders(response, context.Request);
return response;
}
private void HandleResponse(NancyContext context)
{
if (context == null || context.Response.Headers.ContainsKey(AccessControlHeaders.AllowOrigin))
{
return;
}
ApplyResponseHeaders(context.Response, context.Request);
}
private static void ApplyResponseHeaders(Response response, Request request)
{
if (request.IsApiRequest())
{
// Allow Cross-Origin access to the API since it's protected with the apikey, and nothing else.
ApplyCorsResponseHeaders(response, request, "*", "GET, OPTIONS, PATCH, POST, PUT, DELETE");
}
else if (request.IsSharedContentRequest())
{
// Allow Cross-Origin access to specific shared content such as mediacovers and images.
ApplyCorsResponseHeaders(response, request, "*", "GET, OPTIONS");
}
// Disallow Cross-Origin access for any other route.
}
private static void ApplyCorsResponseHeaders(Response response, Request request, string allowOrigin, string allowedMethods)
{
response.Headers.Add(AccessControlHeaders.AllowOrigin, allowOrigin);
if (request.Method == "OPTIONS")
{
if (response.Headers.ContainsKey("Allow"))
{
allowedMethods = response.Headers["Allow"];
}
response.Headers.Add(AccessControlHeaders.AllowMethods, allowedMethods);
if (request.Headers[AccessControlHeaders.RequestHeaders].Any())
{
var requestedHeaders = request.Headers[AccessControlHeaders.RequestHeaders].Join(", ");
response.Headers.Add(AccessControlHeaders.AllowHeaders, requestedHeaders);
}
}
}
}
}
@@ -1,104 +0,0 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using Nancy;
using Nancy.Bootstrapper;
using NLog;
using NzbDrone.Common.EnvironmentInfo;
namespace Readarr.Http.Extensions.Pipelines
{
public class GzipCompressionPipeline : IRegisterNancyPipeline
{
private readonly Logger _logger;
public int Order => 0;
private readonly Action<Action<Stream>, Stream> _writeGZipStream;
public GzipCompressionPipeline(Logger logger)
{
_logger = logger;
// On Mono GZipStream/DeflateStream leaks memory if an exception is thrown, use an intermediate buffer in that case.
_writeGZipStream = PlatformInfo.IsMono ? WriteGZipStreamMono : (Action<Action<Stream>, Stream>)WriteGZipStream;
}
public void Register(IPipelines pipelines)
{
pipelines.AfterRequest.AddItemToEndOfPipeline(CompressResponse);
}
private void CompressResponse(NancyContext context)
{
var request = context.Request;
var response = context.Response;
try
{
if (
response.Contents != Response.NoBody
&& !response.ContentType.Contains("image")
&& !response.ContentType.Contains("font")
&& request.Headers.AcceptEncoding.Any(x => x.Contains("gzip"))
&& !AlreadyGzipEncoded(response)
&& !ContentLengthIsTooSmall(response))
{
var contents = response.Contents;
response.Headers["Content-Encoding"] = "gzip";
response.Contents = responseStream => _writeGZipStream(contents, responseStream);
}
}
catch (Exception ex)
{
_logger.Error(ex, "Unable to gzip response");
throw;
}
}
private static void WriteGZipStreamMono(Action<Stream> innerContent, Stream targetStream)
{
using (var membuffer = new MemoryStream())
{
WriteGZipStream(innerContent, membuffer);
membuffer.Position = 0;
membuffer.CopyTo(targetStream);
}
}
private static void WriteGZipStream(Action<Stream> innerContent, Stream targetStream)
{
using (var gzip = new GZipStream(targetStream, CompressionMode.Compress, true))
using (var buffered = new BufferedStream(gzip, 8192))
{
innerContent.Invoke(buffered);
}
}
private static bool ContentLengthIsTooSmall(Response response)
{
var contentLength = response.Headers.TryGetValue("Content-Length", out var value) ? value : null;
if (contentLength != null && long.Parse(contentLength) < 1024)
{
return true;
}
return false;
}
private static bool AlreadyGzipEncoded(Response response)
{
var contentEncoding = response.Headers.TryGetValue("Content-Encoding", out var value) ? value : null;
if (contentEncoding == "gzip")
{
return true;
}
return false;
}
}
}
@@ -1,11 +0,0 @@
using Nancy.Bootstrapper;
namespace Readarr.Http.Extensions.Pipelines
{
public interface IRegisterNancyPipeline
{
int Order { get; }
void Register(IPipelines pipelines);
}
}
@@ -1,35 +0,0 @@
using Nancy;
using Nancy.Bootstrapper;
using Readarr.Http.Frontend;
namespace Readarr.Http.Extensions.Pipelines
{
public class IfModifiedPipeline : IRegisterNancyPipeline
{
private readonly ICacheableSpecification _cacheableSpecification;
public IfModifiedPipeline(ICacheableSpecification cacheableSpecification)
{
_cacheableSpecification = cacheableSpecification;
}
public int Order => 0;
public void Register(IPipelines pipelines)
{
pipelines.BeforeRequest.AddItemToStartOfPipeline(Handle);
}
private Response Handle(NancyContext context)
{
if (_cacheableSpecification.IsCacheable(context) && context.Request.Headers.IfModifiedSince.HasValue)
{
var response = new Response { ContentType = MimeTypes.GetMimeType(context.Request.Path), StatusCode = HttpStatusCode.NotModified };
response.Headers.EnableCache();
return response;
}
return null;
}
}
}
@@ -1,24 +0,0 @@
using Nancy;
using Nancy.Bootstrapper;
using NzbDrone.Common.EnvironmentInfo;
namespace Readarr.Http.Extensions.Pipelines
{
public class ReadarrVersionPipeline : IRegisterNancyPipeline
{
public int Order => 0;
public void Register(IPipelines pipelines)
{
pipelines.AfterRequest.AddItemToStartOfPipeline(Handle);
}
private void Handle(NancyContext context)
{
if (!context.Response.Headers.ContainsKey("X-ApplicationVersion"))
{
context.Response.Headers.Add("X-ApplicationVersion", BuildInfo.Version.ToString());
}
}
}
}
@@ -1,105 +0,0 @@
using System;
using System.Threading;
using Nancy;
using Nancy.Bootstrapper;
using NLog;
using NzbDrone.Common.Extensions;
using Readarr.Http.ErrorManagement;
using Readarr.Http.Extensions;
using Readarr.Http.Extensions.Pipelines;
namespace NzbDrone.Api.Extensions.Pipelines
{
public class RequestLoggingPipeline : IRegisterNancyPipeline
{
private static readonly Logger _loggerHttp = LogManager.GetLogger("Http");
private static readonly Logger _loggerApi = LogManager.GetLogger("Api");
private static int _requestSequenceID;
private readonly ReadarrErrorPipeline _errorPipeline;
public RequestLoggingPipeline(ReadarrErrorPipeline errorPipeline)
{
_errorPipeline = errorPipeline;
}
public int Order => 100;
public void Register(IPipelines pipelines)
{
pipelines.BeforeRequest.AddItemToStartOfPipeline(LogStart);
pipelines.AfterRequest.AddItemToEndOfPipeline(LogEnd);
pipelines.OnError.AddItemToEndOfPipeline(LogError);
}
private Response LogStart(NancyContext context)
{
var id = Interlocked.Increment(ref _requestSequenceID);
context.Items["ApiRequestSequenceID"] = id;
context.Items["ApiRequestStartTime"] = DateTime.UtcNow;
var reqPath = GetRequestPathAndQuery(context.Request);
_loggerHttp.Trace("Req: {0} [{1}] {2} (from {3})", id, context.Request.Method, reqPath, GetOrigin(context));
return null;
}
private void LogEnd(NancyContext context)
{
var id = (int)context.Items["ApiRequestSequenceID"];
var startTime = (DateTime)context.Items["ApiRequestStartTime"];
var endTime = DateTime.UtcNow;
var duration = endTime - startTime;
var reqPath = GetRequestPathAndQuery(context.Request);
_loggerHttp.Trace("Res: {0} [{1}] {2}: {3}.{4} ({5} ms)", id, context.Request.Method, reqPath, (int)context.Response.StatusCode, context.Response.StatusCode, (int)duration.TotalMilliseconds);
if (context.Request.IsApiRequest())
{
_loggerApi.Debug("[{0}] {1}: {2}.{3} ({4} ms)", context.Request.Method, reqPath, (int)context.Response.StatusCode, context.Response.StatusCode, (int)duration.TotalMilliseconds);
}
}
private Response LogError(NancyContext context, Exception exception)
{
var response = _errorPipeline.HandleException(context, exception);
context.Response = response;
LogEnd(context);
context.Response = null;
return response;
}
private static string GetRequestPathAndQuery(Request request)
{
if (request.Url.Query.IsNotNullOrWhiteSpace())
{
return string.Concat(request.Url.Path, request.Url.Query);
}
else
{
return request.Url.Path;
}
}
private static string GetOrigin(NancyContext context)
{
if (context.Request.Headers.UserAgent.IsNullOrWhiteSpace())
{
return context.GetRemoteIP();
}
else
{
return $"{context.GetRemoteIP()} {context.Request.Headers.UserAgent}";
}
}
}
}
@@ -1,45 +0,0 @@
using Nancy;
using Nancy.Bootstrapper;
using Nancy.Responses;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
namespace Readarr.Http.Extensions.Pipelines
{
public class UrlBasePipeline : IRegisterNancyPipeline
{
private readonly string _urlBase;
public UrlBasePipeline(IConfigFileProvider configFileProvider)
{
_urlBase = configFileProvider.UrlBase;
}
public int Order => 99;
public void Register(IPipelines pipelines)
{
if (_urlBase.IsNotNullOrWhiteSpace())
{
pipelines.BeforeRequest.AddItemToStartOfPipeline(Handle);
}
}
private Response Handle(NancyContext context)
{
var basePath = context.Request.Url.BasePath;
if (basePath.IsNullOrWhiteSpace())
{
return new RedirectResponse($"{_urlBase}{context.Request.Path}{context.Request.Url.Query}");
}
if (_urlBase != basePath)
{
return new NotFoundResponse();
}
return null;
}
}
}
@@ -1,61 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using Nancy;
using Nancy.Responses;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Serializer;
namespace Readarr.Http.Extensions
{
public static class ReqResExtensions
{
private static readonly NancyJsonSerializer NancySerializer = new NancyJsonSerializer();
public static readonly string LastModified = BuildInfo.BuildDateTime.ToString("r");
public static T FromJson<T>(this Stream body)
where T : class, new()
{
return FromJson<T>(body, typeof(T));
}
public static T FromJson<T>(this Stream body, Type type)
{
return (T)FromJson(body, type);
}
public static object FromJson(this Stream body, Type type)
{
body.Position = 0;
return STJson.Deserialize(body, type);
}
public static JsonResponse<TModel> AsResponse<TModel>(this TModel model, NancyContext context, HttpStatusCode statusCode = HttpStatusCode.OK)
{
var response = new JsonResponse<TModel>(model, NancySerializer, context.Environment) { StatusCode = statusCode };
response.Headers.DisableCache();
return response;
}
public static IDictionary<string, string> DisableCache(this IDictionary<string, string> headers)
{
headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0";
headers["Pragma"] = "no-cache";
headers["Expires"] = "0";
return headers;
}
public static IDictionary<string, string> EnableCache(this IDictionary<string, string> headers)
{
headers["Cache-Control"] = "max-age=31536000 , public";
headers["Expires"] = "Sat, 29 Jun 2020 00:00:00 GMT";
headers["Last-Modified"] = LastModified;
headers["Age"] = "193266";
return headers;
}
}
}
+137 -70
View File
@@ -1,119 +1,173 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using Nancy;
using Microsoft.AspNetCore.Http;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Exceptions;
namespace Readarr.Http.Extensions
{
public static class RequestExtensions
{
public static bool IsApiRequest(this Request request)
// See src/Readarr.Api.V1/Queue/QueueModule.cs
private static readonly HashSet<string> VALID_SORT_KEYS = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
return request.Path.StartsWith("/api/", StringComparison.InvariantCultureIgnoreCase);
"authors.sortname", //Workaround authors table properties not being added on isValidSortKey call
"timeleft",
"estimatedCompletionTime",
"protocol",
"indexer",
"downloadClient",
"quality",
"status",
"title",
"progress"
};
private static readonly HashSet<string> EXCLUDED_KEYS = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase)
{
"page",
"pageSize",
"sortKey",
"sortDirection",
"filterKey",
"filterValue",
};
public static bool IsApiRequest(this HttpRequest request)
{
return request.Path.StartsWithSegments("/api", StringComparison.InvariantCultureIgnoreCase);
}
public static bool IsFeedRequest(this Request request)
{
return request.Path.StartsWith("/feed/", StringComparison.InvariantCultureIgnoreCase);
}
public static bool IsSignalRRequest(this Request request)
{
return request.Path.StartsWith("/signalr/", StringComparison.InvariantCultureIgnoreCase);
}
public static bool IsLocalRequest(this Request request)
{
return request.UserHostAddress.Equals("localhost") ||
request.UserHostAddress.Equals("127.0.0.1") ||
request.UserHostAddress.Equals("::1");
}
public static bool IsLoginRequest(this Request request)
{
return request.Path.Equals("/login", StringComparison.InvariantCultureIgnoreCase);
}
public static bool IsContentRequest(this Request request)
{
return request.Path.StartsWith("/Content/", StringComparison.InvariantCultureIgnoreCase);
}
public static bool GetBooleanQueryParameter(this Request request, string parameter, bool defaultValue = false)
public static bool GetBooleanQueryParameter(this HttpRequest request, string parameter, bool defaultValue = false)
{
var parameterValue = request.Query[parameter];
if (parameterValue.HasValue)
if (parameterValue.Any())
{
return bool.Parse(parameterValue.Value);
return bool.Parse(parameterValue.ToString());
}
return defaultValue;
}
public static bool IsSharedContentRequest(this Request request)
public static PagingResource<TResource> ReadPagingResourceFromRequest<TResource>(this HttpRequest request)
{
return request.Path.StartsWith("/MediaCover/", StringComparison.InvariantCultureIgnoreCase) ||
request.Path.StartsWith("/Content/Images/", StringComparison.InvariantCultureIgnoreCase);
}
public static int GetIntegerQueryParameter(this Request request, string parameter, int defaultValue = 0)
{
var parameterValue = request.Query[parameter];
if (parameterValue.HasValue)
if (!int.TryParse(request.Query["PageSize"].ToString(), out var pageSize))
{
return int.Parse(parameterValue.Value);
pageSize = 10;
}
return defaultValue;
}
public static Guid GetGuidQueryParameter(this Request request, string parameter, Guid defaultValue = default)
{
var parameterValue = request.Query[parameter];
if (parameterValue.HasValue)
if (!int.TryParse(request.Query["Page"].ToString(), out var page))
{
return Guid.Parse(parameterValue.Value);
page = 1;
}
return defaultValue;
}
public static int? GetNullableIntegerQueryParameter(this Request request, string parameter, int? defaultValue = null)
{
var parameterValue = request.Query[parameter];
if (parameterValue.HasValue)
var pagingResource = new PagingResource<TResource>
{
return int.Parse(parameterValue.Value);
PageSize = pageSize,
Page = page,
Filters = new List<PagingResourceFilter>()
};
if (request.Query["SortKey"].Any())
{
var sortKey = request.Query["SortKey"].ToString();
if (!VALID_SORT_KEYS.Contains(sortKey) &&
!TableMapping.Mapper.IsValidSortKey(sortKey))
{
throw new BadRequestException($"Invalid sort key {sortKey}");
}
pagingResource.SortKey = sortKey;
if (request.Query["SortDirection"].Any())
{
pagingResource.SortDirection = request.Query["SortDirection"].ToString()
.Equals("ascending", StringComparison.InvariantCultureIgnoreCase)
? SortDirection.Ascending
: SortDirection.Descending;
}
}
return defaultValue;
// For backwards compatibility with v2
if (request.Query["FilterKey"].Any())
{
var filter = new PagingResourceFilter
{
Key = request.Query["FilterKey"].ToString()
};
if (request.Query["FilterValue"].Any())
{
filter.Value = request.Query["FilterValue"].ToString();
}
pagingResource.Filters.Add(filter);
}
// v3 uses filters in key=value format
foreach (var pair in request.Query)
{
if (EXCLUDED_KEYS.Contains(pair.Key))
{
continue;
}
pagingResource.Filters.Add(new PagingResourceFilter
{
Key = pair.Key,
Value = pair.Value.ToString()
});
}
return pagingResource;
}
public static string GetRemoteIP(this NancyContext context)
public static PagingResource<TResource> ApplyToPage<TResource, TModel>(this PagingSpec<TModel> pagingSpec, Func<PagingSpec<TModel>, PagingSpec<TModel>> function, Converter<TModel, TResource> mapper)
{
if (context == null || context.Request == null)
pagingSpec = function(pagingSpec);
return new PagingResource<TResource>
{
Page = pagingSpec.Page,
PageSize = pagingSpec.PageSize,
SortDirection = pagingSpec.SortDirection,
SortKey = pagingSpec.SortKey,
TotalRecords = pagingSpec.TotalRecords,
Records = pagingSpec.Records.ConvertAll(mapper)
};
}
public static string GetRemoteIP(this HttpContext context)
{
return context?.Request?.GetRemoteIP() ?? "Unknown";
}
public static string GetRemoteIP(this HttpRequest request)
{
if (request == null)
{
return "Unknown";
}
var remoteAddress = context.Request.UserHostAddress;
IPAddress remoteIP;
var remoteIP = request.HttpContext.Connection.RemoteIpAddress;
var remoteAddress = remoteIP.ToString();
// Only check if forwarded by a local network reverse proxy
if (IPAddress.TryParse(remoteAddress, out remoteIP) && remoteIP.IsLocalAddress())
if (remoteIP.IsLocalAddress())
{
var realIPHeader = context.Request.Headers["X-Real-IP"];
var realIPHeader = request.Headers["X-Real-IP"];
if (realIPHeader.Any())
{
return realIPHeader.First().ToString();
}
var forwardedForHeader = context.Request.Headers["X-Forwarded-For"];
var forwardedForHeader = request.Headers["X-Forwarded-For"];
if (forwardedForHeader.Any())
{
// Get the first address that was forwarded by a local IP to prevent remote clients faking another proxy
@@ -136,5 +190,18 @@ namespace Readarr.Http.Extensions
return remoteAddress;
}
public static void DisableCache(this IHeaderDictionary headers)
{
headers["Cache-Control"] = "no-cache, no-store";
headers["Expires"] = "-1";
headers["Pragma"] = "no-cache";
}
public static void EnableCache(this IHeaderDictionary headers)
{
headers["Cache-Control"] = "max-age=31536000, public";
headers["Last-Modified"] = BuildInfo.BuildDateTime.ToString("r");
}
}
}