New: Lidarr to Readarr

This commit is contained in:
Qstick
2020-02-29 15:51:29 -05:00
parent 7359c2a9fa
commit 3b7eb01918
565 changed files with 1669 additions and 4272 deletions

View File

@@ -0,0 +1,50 @@
using System;
using Nancy;
using Nancy.Authentication.Forms;
using Nancy.Extensions;
using Nancy.ModelBinding;
using NzbDrone.Core.Configuration;
namespace Readarr.Http.Authentication
{
public class AuthenticationModule : NancyModule
{
private readonly IAuthenticationService _authService;
private readonly IConfigFileProvider _configFileProvider;
public AuthenticationModule(IAuthenticationService authService, IConfigFileProvider configFileProvider)
{
_authService = authService;
_configFileProvider = configFileProvider;
Post("/login", x => Login(this.Bind<LoginResource>()));
Get("/logout", x => Logout());
}
private Response Login(LoginResource resource)
{
var user = _authService.Login(Context, resource.Username, resource.Password);
if (user == null)
{
var returnUrl = (string)Request.Query.returnUrl;
return Context.GetRedirect($"~/login?returnUrl={returnUrl}&loginFailed=true");
}
DateTime? expiry = null;
if (resource.RememberMe)
{
expiry = DateTime.UtcNow.AddDays(7);
}
return this.LoginAndRedirect(user.Identifier, expiry, _configFileProvider.UrlBase + "/");
}
private Response Logout()
{
_authService.Logout(Context);
return this.LogoutAndRedirect(_configFileProvider.UrlBase + "/");
}
}
}

View File

@@ -0,0 +1,234 @@
using System;
using System.Linq;
using System.Security.Claims;
using System.Security.Principal;
using Nancy;
using Nancy.Authentication.Basic;
using Nancy.Authentication.Forms;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration;
using Readarr.Http.Extensions;
namespace Readarr.Http.Authentication
{
public interface IAuthenticationService : IUserValidator, IUserMapper
{
void SetContext(NancyContext context);
void LogUnauthorized(NancyContext context);
User Login(NancyContext context, string username, string password);
void Logout(NancyContext context);
bool IsAuthenticated(NancyContext context);
}
public class AuthenticationService : IAuthenticationService
{
private const string AnonymousUser = "Anonymous";
private static readonly Logger _authLogger = LogManager.GetLogger("Auth");
private readonly IUserService _userService;
private static string API_KEY;
private static AuthenticationType AUTH_METHOD;
[ThreadStatic]
private static NancyContext _context;
public AuthenticationService(IConfigFileProvider configFileProvider, IUserService userService)
{
_userService = userService;
API_KEY = configFileProvider.ApiKey;
AUTH_METHOD = configFileProvider.AuthenticationMethod;
}
public void SetContext(NancyContext context)
{
// Validate and GetUserIdentifier don't have access to the NancyContext so get it from the pipeline earlier
_context = context;
}
public User Login(NancyContext context, string username, string password)
{
if (AUTH_METHOD == AuthenticationType.None)
{
return null;
}
var user = _userService.FindUser(username, password);
if (user != null)
{
LogSuccess(context, username);
return user;
}
LogFailure(context, username);
return null;
}
public void Logout(NancyContext context)
{
if (AUTH_METHOD == AuthenticationType.None)
{
return;
}
if (context.CurrentUser != null)
{
LogLogout(context, context.CurrentUser.Identity.Name);
}
}
public ClaimsPrincipal Validate(string username, string password)
{
if (AUTH_METHOD == AuthenticationType.None)
{
return new ClaimsPrincipal(new GenericIdentity(AnonymousUser));
}
var user = _userService.FindUser(username, password);
if (user != null)
{
if (AUTH_METHOD != AuthenticationType.Basic)
{
// Don't log success for basic auth
LogSuccess(_context, username);
}
return new ClaimsPrincipal(new GenericIdentity(user.Username));
}
LogFailure(_context, username);
return null;
}
public ClaimsPrincipal GetUserFromIdentifier(Guid identifier, NancyContext context)
{
if (AUTH_METHOD == AuthenticationType.None)
{
return new ClaimsPrincipal(new GenericIdentity(AnonymousUser));
}
var user = _userService.FindUser(identifier);
if (user != null)
{
return new ClaimsPrincipal(new GenericIdentity(user.Username));
}
LogInvalidated(_context);
return null;
}
public bool IsAuthenticated(NancyContext context)
{
var apiKey = GetApiKey(context);
if (context.Request.IsApiRequest())
{
return ValidApiKey(apiKey);
}
if (AUTH_METHOD == AuthenticationType.None)
{
return true;
}
if (context.Request.IsFeedRequest())
{
if (ValidUser(context) || ValidApiKey(apiKey))
{
return true;
}
return false;
}
if (context.Request.IsLoginRequest())
{
return true;
}
if (context.Request.IsContentRequest())
{
return true;
}
if (ValidUser(context))
{
return true;
}
return false;
}
private bool ValidUser(NancyContext context)
{
if (context.CurrentUser != null)
{
return true;
}
return false;
}
private bool ValidApiKey(string apiKey)
{
if (API_KEY.Equals(apiKey))
{
return true;
}
return false;
}
private string GetApiKey(NancyContext context)
{
var apiKeyHeader = context.Request.Headers["X-Api-Key"].FirstOrDefault();
var apiKeyQueryString = context.Request.Query["ApiKey"];
if (!apiKeyHeader.IsNullOrWhiteSpace())
{
return apiKeyHeader;
}
if (apiKeyQueryString.HasValue)
{
return apiKeyQueryString.Value;
}
return context.Request.Headers.Authorization;
}
public void LogUnauthorized(NancyContext context)
{
_authLogger.Info("Auth-Unauthorized ip {0} url '{1}'", context.Request.UserHostAddress, context.Request.Url.ToString());
}
private void LogInvalidated(NancyContext context)
{
_authLogger.Info("Auth-Invalidated ip {0}", context.Request.UserHostAddress);
}
private void LogFailure(NancyContext context, string username)
{
_authLogger.Warn("Auth-Failure ip {0} username '{1}'", context.Request.UserHostAddress, username);
}
private void LogSuccess(NancyContext context, string username)
{
_authLogger.Info("Auth-Success ip {0} username '{1}'", context.Request.UserHostAddress, username);
}
private void LogLogout(NancyContext context, string username)
{
_authLogger.Info("Auth-Logout ip {0} username '{1}'", context.Request.UserHostAddress, username);
}
}
}

View File

@@ -0,0 +1,143 @@
using System;
using System.Text;
using Nancy;
using Nancy.Authentication.Basic;
using Nancy.Authentication.Forms;
using Nancy.Bootstrapper;
using Nancy.Cookies;
using Nancy.Cryptography;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration;
using Readarr.Http.Extensions;
using Readarr.Http.Extensions.Pipelines;
namespace Readarr.Http.Authentication
{
public class EnableAuthInNancy : IRegisterNancyPipeline
{
private readonly IAuthenticationService _authenticationService;
private readonly IConfigService _configService;
private readonly IConfigFileProvider _configFileProvider;
private FormsAuthenticationConfiguration _formsAuthConfig;
public EnableAuthInNancy(IAuthenticationService authenticationService,
IConfigService configService,
IConfigFileProvider configFileProvider)
{
_authenticationService = authenticationService;
_configService = configService;
_configFileProvider = configFileProvider;
}
public int Order => 10;
public void Register(IPipelines pipelines)
{
if (_configFileProvider.AuthenticationMethod == AuthenticationType.Forms)
{
RegisterFormsAuth(pipelines);
pipelines.AfterRequest.AddItemToEndOfPipeline(SlidingAuthenticationForFormsAuth);
}
else if (_configFileProvider.AuthenticationMethod == AuthenticationType.Basic)
{
pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, BuildInfo.AppName));
pipelines.BeforeRequest.AddItemToStartOfPipeline(CaptureContext);
}
pipelines.BeforeRequest.AddItemToEndOfPipeline(RequiresAuthentication);
pipelines.AfterRequest.AddItemToEndOfPipeline(RemoveLoginHooksForApiCalls);
}
private Response CaptureContext(NancyContext context)
{
_authenticationService.SetContext(context);
return null;
}
private Response RequiresAuthentication(NancyContext context)
{
Response response = null;
if (!_authenticationService.IsAuthenticated(context))
{
_authenticationService.LogUnauthorized(context);
response = new Response { StatusCode = HttpStatusCode.Unauthorized };
}
return response;
}
private void RegisterFormsAuth(IPipelines pipelines)
{
FormsAuthentication.FormsAuthenticationCookieName = "ReadarrAuth";
var cryptographyConfiguration = new CryptographyConfiguration(
new AesEncryptionProvider(new PassphraseKeyGenerator(_configService.RijndaelPassphrase, Encoding.ASCII.GetBytes(_configService.RijndaelSalt))),
new DefaultHmacProvider(new PassphraseKeyGenerator(_configService.HmacPassphrase, Encoding.ASCII.GetBytes(_configService.HmacSalt))));
_formsAuthConfig = new FormsAuthenticationConfiguration
{
RedirectUrl = _configFileProvider.UrlBase + "/login",
UserMapper = _authenticationService,
Path = GetCookiePath(),
CryptographyConfiguration = cryptographyConfiguration
};
FormsAuthentication.Enable(pipelines, _formsAuthConfig);
}
private void RemoveLoginHooksForApiCalls(NancyContext context)
{
if (context.Request.IsApiRequest())
{
if ((context.Response.StatusCode == HttpStatusCode.SeeOther &&
context.Response.Headers["Location"].StartsWith($"{_configFileProvider.UrlBase}/login", StringComparison.InvariantCultureIgnoreCase)) ||
context.Response.StatusCode == HttpStatusCode.Unauthorized)
{
context.Response = new { Error = "Unauthorized" }.AsResponse(context, HttpStatusCode.Unauthorized);
}
}
}
private void SlidingAuthenticationForFormsAuth(NancyContext context)
{
if (context.CurrentUser == null)
{
return;
}
var formsAuthCookieName = FormsAuthentication.FormsAuthenticationCookieName;
if (!context.Request.Path.Equals("/logout") &&
context.Request.Cookies.ContainsKey(formsAuthCookieName))
{
var formsAuthCookieValue = context.Request.Cookies[formsAuthCookieName];
if (FormsAuthentication.DecryptAndValidateAuthenticationCookie(formsAuthCookieValue, _formsAuthConfig).IsNotNullOrWhiteSpace())
{
var formsAuthCookie = new NancyCookie(formsAuthCookieName, formsAuthCookieValue, true, false, DateTime.UtcNow.AddDays(7))
{
Path = GetCookiePath()
};
context.Response.WithCookie(formsAuthCookie);
}
}
}
private string GetCookiePath()
{
var urlBase = _configFileProvider.UrlBase;
if (urlBase.IsNullOrWhiteSpace())
{
return "/";
}
return urlBase;
}
}
}

View File

@@ -0,0 +1,9 @@
namespace Readarr.Http.Authentication
{
public class LoginResource
{
public string Username { get; set; }
public string Password { get; set; }
public bool RememberMe { get; set; }
}
}

View File

@@ -0,0 +1,25 @@
using System.Collections.Generic;
namespace Readarr.Http.ClientSchema
{
public class Field
{
public int Order { get; set; }
public string Name { get; set; }
public string Label { get; set; }
public string Unit { get; set; }
public string HelpText { get; set; }
public string HelpLink { get; set; }
public object Value { get; set; }
public string Type { get; set; }
public bool Advanced { get; set; }
public List<SelectOption> SelectOptions { get; set; }
public string Section { get; set; }
public string Hidden { get; set; }
public Field Clone()
{
return (Field)MemberwiseClone();
}
}
}

View File

@@ -0,0 +1,12 @@
using System;
namespace Readarr.Http.ClientSchema
{
public class FieldMapping
{
public Field Field { get; set; }
public Type PropertyType { get; set; }
public Func<object, object> GetterFunc { get; set; }
public Action<object, object> SetterFunc { get; set; }
}
}

View File

@@ -0,0 +1,216 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json.Linq;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Reflection;
using NzbDrone.Core.Annotations;
namespace Readarr.Http.ClientSchema
{
public static class SchemaBuilder
{
private static Dictionary<Type, FieldMapping[]> _mappings = new Dictionary<Type, FieldMapping[]>();
public static List<Field> ToSchema(object model)
{
Ensure.That(model, () => model).IsNotNull();
var mappings = GetFieldMappings(model.GetType());
var result = new List<Field>(mappings.Length);
foreach (var mapping in mappings)
{
var field = mapping.Field.Clone();
field.Value = mapping.GetterFunc(model);
result.Add(field);
}
return result.OrderBy(r => r.Order).ToList();
}
public static object ReadFromSchema(List<Field> fields, Type targetType)
{
Ensure.That(targetType, () => targetType).IsNotNull();
var mappings = GetFieldMappings(targetType);
var target = Activator.CreateInstance(targetType);
foreach (var mapping in mappings)
{
var field = fields.Find(f => f.Name == mapping.Field.Name);
mapping.SetterFunc(target, field.Value);
}
return target;
}
public static T ReadFromSchema<T>(List<Field> fields)
{
return (T)ReadFromSchema(fields, typeof(T));
}
// Ideally this function should begin a System.Linq.Expression expression tree since it's faster.
// But it's probably not needed till performance issues pop up.
public static FieldMapping[] GetFieldMappings(Type type)
{
lock (_mappings)
{
FieldMapping[] result;
if (!_mappings.TryGetValue(type, out result))
{
result = GetFieldMapping(type, "", v => v);
// Renumber al the field Orders since nested settings will have dupe Orders.
for (int i = 0; i < result.Length; i++)
{
result[i].Field.Order = i;
}
_mappings[type] = result;
}
return result;
}
}
private static FieldMapping[] GetFieldMapping(Type type, string prefix, Func<object, object> targetSelector)
{
var result = new List<FieldMapping>();
foreach (var property in GetProperties(type))
{
var propertyInfo = property.Item1;
if (propertyInfo.PropertyType.IsSimpleType())
{
var fieldAttribute = property.Item2;
var field = new Field
{
Name = prefix + GetCamelCaseName(propertyInfo.Name),
Label = fieldAttribute.Label,
Unit = fieldAttribute.Unit,
HelpText = fieldAttribute.HelpText,
HelpLink = fieldAttribute.HelpLink,
Order = fieldAttribute.Order,
Advanced = fieldAttribute.Advanced,
Type = fieldAttribute.Type.ToString().FirstCharToLower(),
Section = fieldAttribute.Section
};
if (fieldAttribute.Type == FieldType.Select)
{
field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions);
}
if (fieldAttribute.Hidden != HiddenType.Visible)
{
field.Hidden = fieldAttribute.Hidden.ToString().FirstCharToLower();
}
var valueConverter = GetValueConverter(propertyInfo.PropertyType);
result.Add(new FieldMapping
{
Field = field,
PropertyType = propertyInfo.PropertyType,
GetterFunc = t => propertyInfo.GetValue(targetSelector(t), null),
SetterFunc = (t, v) => propertyInfo.SetValue(targetSelector(t), valueConverter(v), null)
});
}
else
{
result.AddRange(GetFieldMapping(propertyInfo.PropertyType, GetCamelCaseName(propertyInfo.Name) + ".", t => propertyInfo.GetValue(targetSelector(t), null)));
}
}
return result.ToArray();
}
private static Tuple<PropertyInfo, FieldDefinitionAttribute>[] GetProperties(Type type)
{
return type.GetProperties()
.Select(v => Tuple.Create(v, v.GetAttribute<FieldDefinitionAttribute>(false)))
.Where(v => v.Item2 != null)
.OrderBy(v => v.Item2.Order)
.ToArray();
}
private static List<SelectOption> GetSelectOptions(Type selectOptions)
{
var options = from Enum e in Enum.GetValues(selectOptions)
select new SelectOption { Value = Convert.ToInt32(e), Name = e.ToString() };
return options.OrderBy(o => o.Value).ToList();
}
private static Func<object, object> GetValueConverter(Type propertyType)
{
if (propertyType == typeof(int))
{
return fieldValue => fieldValue?.ToString().ParseInt32() ?? 0;
}
else if (propertyType == typeof(long))
{
return fieldValue => fieldValue?.ToString().ParseInt64() ?? 0;
}
else if (propertyType == typeof(double))
{
return fieldValue => fieldValue?.ToString().ParseDouble() ?? 0.0;
}
else if (propertyType == typeof(int?))
{
return fieldValue => fieldValue?.ToString().ParseInt32();
}
else if (propertyType == typeof(long?))
{
return fieldValue => fieldValue?.ToString().ParseInt64();
}
else if (propertyType == typeof(double?))
{
return fieldValue => fieldValue?.ToString().ParseDouble();
}
else if (propertyType == typeof(IEnumerable<int>))
{
return fieldValue =>
{
if (fieldValue.GetType() == typeof(JArray))
{
return ((JArray)fieldValue).Select(s => s.Value<int>());
}
else
{
return fieldValue.ToString().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => Convert.ToInt32(s));
}
};
}
else if (propertyType == typeof(IEnumerable<string>))
{
return fieldValue =>
{
if (fieldValue.GetType() == typeof(JArray))
{
return ((JArray)fieldValue).Select(s => s.Value<string>());
}
else
{
return fieldValue.ToString().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
}
};
}
else
{
return fieldValue => fieldValue;
}
}
private static string GetCamelCaseName(string name)
{
return char.ToLowerInvariant(name[0]) + name.Substring(1);
}
}
}

View File

@@ -0,0 +1,8 @@
namespace Readarr.Http.ClientSchema
{
public class SelectOption
{
public int Value { get; set; }
public string Name { get; set; }
}
}

View File

@@ -0,0 +1,41 @@
using Nancy;
using Nancy.ErrorHandling;
using Readarr.Http.Extensions;
namespace Readarr.Http.ErrorManagement
{
public class ErrorHandler : IStatusCodeHandler
{
public bool HandlesStatusCode(HttpStatusCode statusCode, NancyContext context)
{
return true;
}
public void Handle(HttpStatusCode statusCode, NancyContext context)
{
if (statusCode == HttpStatusCode.SeeOther || statusCode == HttpStatusCode.OK)
{
return;
}
if (statusCode == HttpStatusCode.Continue)
{
context.Response = new Response { StatusCode = statusCode };
return;
}
if (statusCode == HttpStatusCode.Unauthorized)
{
return;
}
if (context.Response.ContentType == "text/html" || context.Response.ContentType == "text/plain")
{
context.Response = new ErrorModel
{
Message = statusCode.ToString()
}.AsResponse(context, statusCode);
}
}
}
}

View File

@@ -0,0 +1,21 @@
using Readarr.Http.Exceptions;
namespace Readarr.Http.ErrorManagement
{
public class ErrorModel
{
public string Message { get; set; }
public string Description { get; set; }
public object Content { get; set; }
public ErrorModel(ApiException exception)
{
Message = exception.Message;
Content = exception.Content;
}
public ErrorModel()
{
}
}
}

View File

@@ -0,0 +1,92 @@
using System;
using System.Data.SQLite;
using FluentValidation;
using Nancy;
using NLog;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Exceptions;
using Readarr.Http.Exceptions;
using Readarr.Http.Extensions;
using HttpStatusCode = Nancy.HttpStatusCode;
namespace Readarr.Http.ErrorManagement
{
public class ReadarrErrorPipeline
{
private readonly Logger _logger;
public ReadarrErrorPipeline(Logger logger)
{
_logger = logger;
}
public Response HandleException(NancyContext context, Exception exception)
{
_logger.Trace("Handling Exception");
if (exception is ApiException apiException)
{
_logger.Warn(apiException, "API Error");
return apiException.ToErrorResponse(context);
}
if (exception is ValidationException validationException)
{
_logger.Warn("Invalid request {0}", validationException.Message);
return validationException.Errors.AsResponse(context, HttpStatusCode.BadRequest);
}
if (exception is NzbDroneClientException clientException)
{
return new ErrorModel
{
Message = exception.Message,
Description = exception.ToString()
}.AsResponse(context, (HttpStatusCode)clientException.StatusCode);
}
if (exception is ModelNotFoundException notFoundException)
{
return new ErrorModel
{
Message = exception.Message,
Description = exception.ToString()
}.AsResponse(context, HttpStatusCode.NotFound);
}
if (exception is ModelConflictException conflictException)
{
return new ErrorModel
{
Message = exception.Message,
Description = exception.ToString()
}.AsResponse(context, HttpStatusCode.Conflict);
}
if (exception is SQLiteException sqLiteException)
{
if (context.Request.Method == "PUT" || context.Request.Method == "POST")
{
if (sqLiteException.Message.Contains("constraint failed"))
{
return new ErrorModel
{
Message = exception.Message,
}.AsResponse(context, HttpStatusCode.Conflict);
}
}
_logger.Error(sqLiteException, "[{0} {1}]", context.Request.Method, context.Request.Path);
}
_logger.Fatal(exception, "Request Failed. {0} {1}", context.Request.Method, context.Request.Path);
return new ErrorModel
{
Message = exception.Message,
Description = exception.ToString()
}.AsResponse(context, HttpStatusCode.InternalServerError);
}
}
}

View File

@@ -0,0 +1,39 @@
using System;
using Nancy;
using Nancy.Responses;
using Readarr.Http.ErrorManagement;
using Readarr.Http.Extensions;
namespace Readarr.Http.Exceptions
{
public abstract class ApiException : Exception
{
public object Content { get; private set; }
public HttpStatusCode StatusCode { get; private set; }
protected ApiException(HttpStatusCode statusCode, object content = null)
: base(GetMessage(statusCode, content))
{
StatusCode = statusCode;
Content = content;
}
public JsonResponse<ErrorModel> ToErrorResponse(NancyContext context)
{
return new ErrorModel(this).AsResponse(context, StatusCode);
}
private static string GetMessage(HttpStatusCode statusCode, object content)
{
var result = statusCode.ToString();
if (content != null)
{
result = $"{result}: {content}";
}
return result;
}
}
}

View File

@@ -0,0 +1,16 @@
using System;
namespace Readarr.Http.Exceptions
{
public class InvalidApiKeyException : Exception
{
public InvalidApiKeyException()
{
}
public InvalidApiKeyException(string message)
: base(message)
{
}
}
}

View File

@@ -0,0 +1,12 @@
namespace Readarr.Http.Extensions
{
public static class AccessControlHeaders
{
public const string RequestMethod = "Access-Control-Request-Method";
public const string RequestHeaders = "Access-Control-Request-Headers";
public const string AllowOrigin = "Access-Control-Allow-Origin";
public const string AllowMethods = "Access-Control-Allow-Methods";
public const string AllowHeaders = "Access-Control-Allow-Headers";
}
}

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.IO;
using Nancy;
using Nancy.Responses.Negotiation;
using NzbDrone.Common.Serializer;
namespace Readarr.Http.Extensions
{
public class NancyJsonSerializer : ISerializer
{
public bool CanSerialize(MediaRange contentType)
{
return contentType == "application/json";
}
public void Serialize<TModel>(MediaRange contentType, TModel model, Stream outputStream)
{
Json.Serialize(model, outputStream);
}
public IEnumerable<string> Extensions { get; private set; }
}
}

View File

@@ -0,0 +1,41 @@
using System;
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();
}
}
}
}

View File

@@ -0,0 +1,80 @@
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);
}
}
}
}
}

View File

@@ -0,0 +1,104 @@
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;
}
}
}

View File

@@ -0,0 +1,11 @@
using Nancy.Bootstrapper;
namespace Readarr.Http.Extensions.Pipelines
{
public interface IRegisterNancyPipeline
{
int Order { get; }
void Register(IPipelines pipelines);
}
}

View File

@@ -0,0 +1,36 @@
using System;
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;
}
}
}

View File

@@ -0,0 +1,25 @@
using System;
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());
}
}
}
}

View File

@@ -0,0 +1,93 @@
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}", id, context.Request.Method, reqPath);
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;
}
}
}
}

View File

@@ -0,0 +1,46 @@
using System;
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;
}
}
}

View File

@@ -0,0 +1,63 @@
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)
{
var reader = new StreamReader(body, true);
body.Position = 0;
var value = reader.ReadToEnd();
return Json.Deserialize(value, 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;
}
}
}

View File

@@ -0,0 +1,58 @@
using System;
using Nancy;
namespace Readarr.Http.Extensions
{
public static class RequestExtensions
{
public static bool IsApiRequest(this Request request)
{
return request.Path.StartsWith("/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)
{
var parameterValue = request.Query[parameter];
if (parameterValue.HasValue)
{
return bool.Parse(parameterValue.Value);
}
return defaultValue;
}
public static bool IsSharedContentRequest(this Request request)
{
return request.Path.StartsWith("/MediaCover/", StringComparison.InvariantCultureIgnoreCase) ||
request.Path.StartsWith("/Content/Images/", StringComparison.InvariantCultureIgnoreCase);
}
}
}

View File

@@ -0,0 +1,74 @@
using System;
using Nancy;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
namespace Readarr.Http.Frontend
{
public interface ICacheableSpecification
{
bool IsCacheable(NancyContext context);
}
public class CacheableSpecification : ICacheableSpecification
{
public bool IsCacheable(NancyContext context)
{
if (!RuntimeInfo.IsProduction)
{
return false;
}
if (((DynamicDictionary)context.Request.Query).ContainsKey("h"))
{
return true;
}
if (context.Request.Path.StartsWith("/api", StringComparison.CurrentCultureIgnoreCase))
{
if (context.Request.Path.ContainsIgnoreCase("/MediaCover"))
{
return true;
}
return false;
}
if (context.Request.Path.StartsWith("/signalr", StringComparison.CurrentCultureIgnoreCase))
{
return false;
}
if (context.Request.Path.EndsWith("index.js"))
{
return false;
}
if (context.Request.Path.EndsWith("initialize.js"))
{
return false;
}
if (context.Request.Path.StartsWith("/feed", StringComparison.CurrentCultureIgnoreCase))
{
return false;
}
if (context.Request.Path.StartsWith("/log", StringComparison.CurrentCultureIgnoreCase) &&
context.Request.Path.EndsWith(".txt", StringComparison.CurrentCultureIgnoreCase))
{
return false;
}
if (context.Response != null)
{
if (context.Response.ContentType.Contains("text/html"))
{
return false;
}
}
return true;
}
}
}

View File

@@ -0,0 +1,78 @@
using System.IO;
using System.Text;
using Nancy;
using Nancy.Responses;
using NzbDrone.Common;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Analytics;
using NzbDrone.Core.Configuration;
namespace Readarr.Http.Frontend
{
public class InitializeJsModule : NancyModule
{
private readonly IConfigFileProvider _configFileProvider;
private readonly IAnalyticsService _analyticsService;
private static string _apiKey;
private static string _urlBase;
private string _generatedContent;
public InitializeJsModule(IConfigFileProvider configFileProvider,
IAnalyticsService analyticsService)
{
_configFileProvider = configFileProvider;
_analyticsService = analyticsService;
_apiKey = configFileProvider.ApiKey;
_urlBase = configFileProvider.UrlBase;
Get("/initialize.js", x => Index());
}
private Response Index()
{
// TODO: Move away from window.Readarr and prefetch the information returned here when starting the UI
return new StreamResponse(GetContentStream, "application/javascript");
}
private Stream GetContentStream()
{
var text = GetContent();
var stream = new MemoryStream();
var writer = new StreamWriter(stream);
writer.Write(text);
writer.Flush();
stream.Position = 0;
return stream;
}
private string GetContent()
{
if (RuntimeInfo.IsProduction && _generatedContent != null)
{
return _generatedContent;
}
var builder = new StringBuilder();
builder.AppendLine("window.Readarr = {");
builder.AppendLine($" apiRoot: '{_urlBase}/api/v1',");
builder.AppendLine($" apiKey: '{_apiKey}',");
builder.AppendLine($" release: '{BuildInfo.Release}',");
builder.AppendLine($" version: '{BuildInfo.Version.ToString()}',");
builder.AppendLine($" branch: '{_configFileProvider.Branch.ToLower()}',");
builder.AppendLine($" analytics: {_analyticsService.IsEnabled.ToString().ToLowerInvariant()},");
builder.AppendLine($" userHash: '{HashUtil.AnonymousToken()}',");
builder.AppendLine($" urlBase: '{_urlBase}',");
builder.AppendLine($" isProduction: {RuntimeInfo.IsProduction.ToString().ToLowerInvariant()}");
builder.AppendLine("};");
_generatedContent = builder.ToString();
return _generatedContent;
}
}
}

View File

@@ -0,0 +1,30 @@
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Core.Backup;
namespace Readarr.Http.Frontend.Mappers
{
public class BackupFileMapper : StaticResourceMapperBase
{
private readonly IBackupService _backupService;
public BackupFileMapper(IBackupService backupService, IDiskProvider diskProvider, Logger logger)
: base(diskProvider, logger)
{
_backupService = backupService;
}
public override string Map(string resourceUrl)
{
var path = resourceUrl.Replace("/backup/", "").Replace('/', Path.DirectorySeparatorChar);
return Path.Combine(_backupService.GetBackupFolder(), path);
}
public override bool CanHandle(string resourceUrl)
{
return resourceUrl.StartsWith("/backup/") && BackupService.BackupFileRegex.IsMatch(resourceUrl);
}
}
}

View File

@@ -0,0 +1,34 @@
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
namespace Readarr.Http.Frontend.Mappers
{
public class BrowserConfig : StaticResourceMapperBase
{
private readonly IAppFolderInfo _appFolderInfo;
private readonly IConfigFileProvider _configFileProvider;
public BrowserConfig(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger)
: base(diskProvider, logger)
{
_appFolderInfo = appFolderInfo;
_configFileProvider = configFileProvider;
}
public override string Map(string resourceUrl)
{
var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar);
path = path.Trim(Path.DirectorySeparatorChar);
return Path.ChangeExtension(Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path), "xml");
}
public override bool CanHandle(string resourceUrl)
{
return resourceUrl.StartsWith("/Content/Images/Icons/browserconfig");
}
}
}

View File

@@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.Crypto;
using NzbDrone.Common.Extensions;
namespace Readarr.Http.Frontend.Mappers
{
public interface ICacheBreakerProvider
{
string AddCacheBreakerToPath(string resourceUrl);
}
public class CacheBreakerProvider : ICacheBreakerProvider
{
private readonly IEnumerable<IMapHttpRequestsToDisk> _diskMappers;
private readonly IHashProvider _hashProvider;
public CacheBreakerProvider(IEnumerable<IMapHttpRequestsToDisk> diskMappers, IHashProvider hashProvider)
{
_diskMappers = diskMappers;
_hashProvider = hashProvider;
}
public string AddCacheBreakerToPath(string resourceUrl)
{
if (!ShouldBreakCache(resourceUrl))
{
return resourceUrl;
}
var mapper = _diskMappers.Single(m => m.CanHandle(resourceUrl));
var pathToFile = mapper.Map(resourceUrl);
var hash = _hashProvider.ComputeMd5(pathToFile).ToBase64();
return resourceUrl + "?h=" + hash.Trim('=');
}
private static bool ShouldBreakCache(string path)
{
return !path.EndsWith(".ics") && !path.EndsWith("main");
}
}
}

View File

@@ -0,0 +1,40 @@
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
namespace Readarr.Http.Frontend.Mappers
{
public class FaviconMapper : StaticResourceMapperBase
{
private readonly IAppFolderInfo _appFolderInfo;
private readonly IConfigFileProvider _configFileProvider;
public FaviconMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger)
: base(diskProvider, logger)
{
_appFolderInfo = appFolderInfo;
_configFileProvider = configFileProvider;
}
public override string Map(string resourceUrl)
{
var fileName = "favicon.ico";
if (BuildInfo.IsDebug)
{
fileName = "favicon-debug.ico";
}
var path = Path.Combine("Content", "Images", "Icons", fileName);
return Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path);
}
public override bool CanHandle(string resourceUrl)
{
return resourceUrl.Equals("/favicon.ico");
}
}
}

View File

@@ -0,0 +1,82 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using Nancy;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
namespace Readarr.Http.Frontend.Mappers
{
public abstract class HtmlMapperBase : StaticResourceMapperBase
{
private readonly IDiskProvider _diskProvider;
private readonly Func<ICacheBreakerProvider> _cacheBreakProviderFactory;
private static readonly Regex ReplaceRegex = new Regex(@"(?:(?<attribute>href|src)=\"")(?<path>.*?(?<extension>css|js|png|ico|ics|svg|json))(?:\"")(?:\s(?<nohash>data-no-hash))?", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private string _generatedContent;
protected HtmlMapperBase(IDiskProvider diskProvider,
Func<ICacheBreakerProvider> cacheBreakProviderFactory,
Logger logger)
: base(diskProvider, logger)
{
_diskProvider = diskProvider;
_cacheBreakProviderFactory = cacheBreakProviderFactory;
}
protected string HtmlPath;
protected string UrlBase;
protected override Stream GetContentStream(string filePath)
{
var text = GetHtmlText();
var stream = new MemoryStream();
var writer = new StreamWriter(stream);
writer.Write(text);
writer.Flush();
stream.Position = 0;
return stream;
}
public override Response GetResponse(string resourceUrl)
{
var response = base.GetResponse(resourceUrl);
response.Headers["X-UA-Compatible"] = "IE=edge";
return response;
}
protected string GetHtmlText()
{
if (RuntimeInfo.IsProduction && _generatedContent != null)
{
return _generatedContent;
}
var text = _diskProvider.ReadAllText(HtmlPath);
var cacheBreakProvider = _cacheBreakProviderFactory();
text = ReplaceRegex.Replace(text, match =>
{
string url;
if (match.Groups["nohash"].Success)
{
url = match.Groups["path"].Value;
}
else
{
url = cacheBreakProvider.AddCacheBreakerToPath(match.Groups["path"].Value);
}
return string.Format("{0}=\"{1}{2}\"", match.Groups["attribute"].Value, UrlBase, url);
});
_generatedContent = text;
return _generatedContent;
}
}
}

View File

@@ -0,0 +1,11 @@
using Nancy;
namespace Readarr.Http.Frontend.Mappers
{
public interface IMapHttpRequestsToDisk
{
string Map(string resourceUrl);
bool CanHandle(string resourceUrl);
Response GetResponse(string resourceUrl);
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
namespace Readarr.Http.Frontend.Mappers
{
public class IndexHtmlMapper : HtmlMapperBase
{
private readonly IConfigFileProvider _configFileProvider;
public IndexHtmlMapper(IAppFolderInfo appFolderInfo,
IDiskProvider diskProvider,
IConfigFileProvider configFileProvider,
Func<ICacheBreakerProvider> cacheBreakProviderFactory,
Logger logger)
: base(diskProvider, cacheBreakProviderFactory, logger)
{
_configFileProvider = configFileProvider;
HtmlPath = Path.Combine(appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, "index.html");
UrlBase = configFileProvider.UrlBase;
}
public override string Map(string resourceUrl)
{
return HtmlPath;
}
public override bool CanHandle(string resourceUrl)
{
resourceUrl = resourceUrl.ToLowerInvariant();
return !resourceUrl.StartsWith("/content") &&
!resourceUrl.StartsWith("/mediacover") &&
!resourceUrl.Contains(".") &&
!resourceUrl.StartsWith("/login");
}
}
}

View File

@@ -0,0 +1,32 @@
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
namespace Readarr.Http.Frontend.Mappers
{
public class UpdateLogFileMapper : StaticResourceMapperBase
{
private readonly IAppFolderInfo _appFolderInfo;
public UpdateLogFileMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, Logger logger)
: base(diskProvider, logger)
{
_appFolderInfo = appFolderInfo;
}
public override string Map(string resourceUrl)
{
var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar);
path = Path.GetFileName(path);
return Path.Combine(_appFolderInfo.GetUpdateLogFolder(), path);
}
public override bool CanHandle(string resourceUrl)
{
return resourceUrl.StartsWith("/updatelogfile/") && resourceUrl.EndsWith(".txt");
}
}
}

View File

@@ -0,0 +1,33 @@
using System;
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
namespace Readarr.Http.Frontend.Mappers
{
public class LoginHtmlMapper : HtmlMapperBase
{
public LoginHtmlMapper(IAppFolderInfo appFolderInfo,
IDiskProvider diskProvider,
Func<ICacheBreakerProvider> cacheBreakProviderFactory,
IConfigFileProvider configFileProvider,
Logger logger)
: base(diskProvider, cacheBreakProviderFactory, logger)
{
HtmlPath = Path.Combine(appFolderInfo.StartUpFolder, configFileProvider.UiFolder, "login.html");
UrlBase = configFileProvider.UrlBase;
}
public override string Map(string resourceUrl)
{
return HtmlPath;
}
public override bool CanHandle(string resourceUrl)
{
return resourceUrl.StartsWith("/login");
}
}
}

View File

@@ -0,0 +1,34 @@
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
namespace Readarr.Http.Frontend.Mappers
{
public class ManifestMapper : StaticResourceMapperBase
{
private readonly IAppFolderInfo _appFolderInfo;
private readonly IConfigFileProvider _configFileProvider;
public ManifestMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger)
: base(diskProvider, logger)
{
_appFolderInfo = appFolderInfo;
_configFileProvider = configFileProvider;
}
public override string Map(string resourceUrl)
{
var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar);
path = path.Trim(Path.DirectorySeparatorChar);
return Path.ChangeExtension(Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path), "json");
}
public override bool CanHandle(string resourceUrl)
{
return resourceUrl.StartsWith("/Content/Images/Icons/manifest");
}
}
}

View File

@@ -0,0 +1,49 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
namespace Readarr.Http.Frontend.Mappers
{
public class MediaCoverMapper : StaticResourceMapperBase
{
private static readonly Regex RegexResizedImage = new Regex(@"-\d+(?=\.(jpg|png|gif)($|\?))", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private readonly IAppFolderInfo _appFolderInfo;
private readonly IDiskProvider _diskProvider;
public MediaCoverMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, Logger logger)
: base(diskProvider, logger)
{
_appFolderInfo = appFolderInfo;
_diskProvider = diskProvider;
}
public override string Map(string resourceUrl)
{
var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar);
path = path.Trim(Path.DirectorySeparatorChar);
var resourcePath = Path.Combine(_appFolderInfo.GetAppDataPath(), path);
if (!_diskProvider.FileExists(resourcePath) || _diskProvider.GetFileSize(resourcePath) == 0)
{
var baseResourcePath = RegexResizedImage.Replace(resourcePath, "");
if (baseResourcePath != resourcePath)
{
return baseResourcePath;
}
}
return resourcePath;
}
public override bool CanHandle(string resourceUrl)
{
return resourceUrl.StartsWith("/MediaCover", StringComparison.InvariantCultureIgnoreCase);
}
}
}

View File

@@ -0,0 +1,33 @@
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
namespace Readarr.Http.Frontend.Mappers
{
public class RobotsTxtMapper : StaticResourceMapperBase
{
private readonly IAppFolderInfo _appFolderInfo;
private readonly IConfigFileProvider _configFileProvider;
public RobotsTxtMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger)
: base(diskProvider, logger)
{
_appFolderInfo = appFolderInfo;
_configFileProvider = configFileProvider;
}
public override string Map(string resourceUrl)
{
var path = Path.Combine("Content", "robots.txt");
return Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path);
}
public override bool CanHandle(string resourceUrl)
{
return resourceUrl.Equals("/robots.txt");
}
}
}

View File

@@ -0,0 +1,48 @@
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
namespace Readarr.Http.Frontend.Mappers
{
public class StaticResourceMapper : StaticResourceMapperBase
{
private readonly IAppFolderInfo _appFolderInfo;
private readonly IConfigFileProvider _configFileProvider;
public StaticResourceMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger)
: base(diskProvider, logger)
{
_appFolderInfo = appFolderInfo;
_configFileProvider = configFileProvider;
}
public override string Map(string resourceUrl)
{
var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar);
path = path.Trim(Path.DirectorySeparatorChar);
return Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path);
}
public override bool CanHandle(string resourceUrl)
{
resourceUrl = resourceUrl.ToLowerInvariant();
if (resourceUrl.StartsWith("/content/images/icons/manifest") ||
resourceUrl.StartsWith("/content/images/icons/browserconfig"))
{
return false;
}
return resourceUrl.StartsWith("/content") ||
(resourceUrl.EndsWith(".js") && !resourceUrl.EndsWith("initialize.js")) ||
resourceUrl.EndsWith(".map") ||
resourceUrl.EndsWith(".css") ||
(resourceUrl.EndsWith(".ico") && !resourceUrl.Equals("/favicon.ico")) ||
resourceUrl.EndsWith(".swf") ||
resourceUrl.EndsWith("oauth.html");
}
}
}

View File

@@ -0,0 +1,51 @@
using System;
using System.IO;
using Nancy;
using Nancy.Responses;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
namespace Readarr.Http.Frontend.Mappers
{
public abstract class StaticResourceMapperBase : IMapHttpRequestsToDisk
{
private readonly IDiskProvider _diskProvider;
private readonly Logger _logger;
private readonly StringComparison _caseSensitive;
private static readonly NotFoundResponse NotFoundResponse = new NotFoundResponse();
protected StaticResourceMapperBase(IDiskProvider diskProvider, Logger logger)
{
_diskProvider = diskProvider;
_logger = logger;
_caseSensitive = RuntimeInfo.IsProduction ? DiskProviderBase.PathStringComparison : StringComparison.OrdinalIgnoreCase;
}
public abstract string Map(string resourceUrl);
public abstract bool CanHandle(string resourceUrl);
public virtual Response GetResponse(string resourceUrl)
{
var filePath = Map(resourceUrl);
if (_diskProvider.FileExists(filePath, _caseSensitive))
{
var response = new StreamResponse(() => GetContentStream(filePath), MimeTypes.GetMimeType(filePath));
return new MaterialisingResponse(response);
}
_logger.Warn("File {0} not found", filePath);
return NotFoundResponse;
}
protected virtual Stream GetContentStream(string filePath)
{
return File.OpenRead(filePath);
}
}
}

View File

@@ -0,0 +1,32 @@
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
namespace Readarr.Http.Frontend.Mappers
{
public class LogFileMapper : StaticResourceMapperBase
{
private readonly IAppFolderInfo _appFolderInfo;
public LogFileMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, Logger logger)
: base(diskProvider, logger)
{
_appFolderInfo = appFolderInfo;
}
public override string Map(string resourceUrl)
{
var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar);
path = Path.GetFileName(path);
return Path.Combine(_appFolderInfo.GetLogFolder(), path);
}
public override bool CanHandle(string resourceUrl)
{
return resourceUrl.StartsWith("/logfile/") && resourceUrl.EndsWith(".txt");
}
}
}

View File

@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Nancy;
using NLog;
using Readarr.Http.Frontend.Mappers;
namespace Readarr.Http.Frontend
{
public class StaticResourceModule : NancyModule
{
private readonly IEnumerable<IMapHttpRequestsToDisk> _requestMappers;
private readonly Logger _logger;
public StaticResourceModule(IEnumerable<IMapHttpRequestsToDisk> requestMappers, Logger logger)
{
_requestMappers = requestMappers;
_logger = logger;
Get("/{resource*}", x => Index());
Get("/", x => Index());
}
private Response Index()
{
var path = Request.Url.Path;
if (
string.IsNullOrWhiteSpace(path) ||
path.StartsWith("/api", StringComparison.CurrentCultureIgnoreCase) ||
path.StartsWith("/signalr", StringComparison.CurrentCultureIgnoreCase))
{
return new NotFoundResponse();
}
var mapper = _requestMappers.SingleOrDefault(m => m.CanHandle(path));
if (mapper != null)
{
return mapper.GetResponse(path);
}
_logger.Warn("Couldn't find handler for {0}", path);
return new NotFoundResponse();
}
}
}

View File

@@ -0,0 +1,54 @@
using System;
using System.Linq;
using System.Reflection;
using NzbDrone.Common.Reflection;
using Readarr.Http.REST;
namespace Readarr.Http.Mapping
{
public static class MappingValidation
{
public static void ValidateMapping(Type modelType, Type resourceType)
{
var errors = modelType.GetSimpleProperties().Where(c => !c.GetGetMethod().IsStatic).Select(p => GetError(resourceType, p)).Where(c => c != null).ToList();
if (errors.Any())
{
throw new ResourceMappingException(errors);
}
PrintExtraProperties(modelType, resourceType);
}
private static void PrintExtraProperties(Type modelType, Type resourceType)
{
var resourceBaseProperties = typeof(RestResource).GetProperties().Select(c => c.Name);
var resourceProperties = resourceType.GetProperties().Select(c => c.Name).Except(resourceBaseProperties);
var modelProperties = modelType.GetProperties().Select(c => c.Name);
var extra = resourceProperties.Except(modelProperties);
foreach (var extraProp in extra)
{
Console.WriteLine("Extra: [{0}]", extraProp);
}
}
private static string GetError(Type resourceType, PropertyInfo modelProperty)
{
var resourceProperty = resourceType.GetProperties().FirstOrDefault(c => c.Name == modelProperty.Name);
if (resourceProperty == null)
{
return string.Format("public {0} {1} {{ get; set; }}", modelProperty.PropertyType.Name, modelProperty.Name);
}
if (resourceProperty.PropertyType != modelProperty.PropertyType && !typeof(RestResource).IsAssignableFrom(resourceProperty.PropertyType))
{
return string.Format("Expected {0}.{1} to have type of {2} but found {3}", resourceType.Name, resourceProperty.Name, modelProperty.PropertyType, resourceProperty.PropertyType);
}
return null;
}
}
}

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Readarr.Http.Mapping
{
public class ResourceMappingException : ApplicationException
{
public ResourceMappingException(IEnumerable<string> error)
: base(Environment.NewLine + string.Join(Environment.NewLine, error.OrderBy(c => c)))
{
}
}
}

View File

@@ -0,0 +1,41 @@
using System.Collections.Generic;
using NzbDrone.Core.Datastore;
namespace Readarr.Http
{
public class PagingResource<TResource>
{
public int Page { get; set; }
public int PageSize { get; set; }
public string SortKey { get; set; }
public SortDirection SortDirection { get; set; }
public List<PagingResourceFilter> Filters { get; set; }
public int TotalRecords { get; set; }
public List<TResource> Records { get; set; }
}
public static class PagingResourceMapper
{
public static PagingSpec<TModel> MapToPagingSpec<TResource, TModel>(this PagingResource<TResource> pagingResource, string defaultSortKey = "Id", SortDirection defaultSortDirection = SortDirection.Ascending)
{
var pagingSpec = new PagingSpec<TModel>
{
Page = pagingResource.Page,
PageSize = pagingResource.PageSize,
SortKey = pagingResource.SortKey,
SortDirection = pagingResource.SortDirection,
};
if (pagingResource.SortKey == null)
{
pagingSpec.SortKey = defaultSortKey;
if (pagingResource.SortDirection == SortDirection.Default)
{
pagingSpec.SortDirection = defaultSortDirection;
}
}
return pagingSpec;
}
}
}

View File

@@ -0,0 +1,8 @@
namespace Readarr.Http
{
public class PagingResourceFilter
{
public string Key { get; set; }
public string Value { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
using Nancy;
using Readarr.Http.Exceptions;
namespace Readarr.Http.REST
{
public class BadRequestException : ApiException
{
public BadRequestException(object content = null)
: base(HttpStatusCode.BadRequest, content)
{
}
}
}

View File

@@ -0,0 +1,13 @@
using Nancy;
using Readarr.Http.Exceptions;
namespace Readarr.Http.REST
{
public class MethodNotAllowedException : ApiException
{
public MethodNotAllowedException(object content = null)
: base(HttpStatusCode.MethodNotAllowed, content)
{
}
}
}

View File

@@ -0,0 +1,13 @@
using Nancy;
using Readarr.Http.Exceptions;
namespace Readarr.Http.REST
{
public class NotFoundException : ApiException
{
public NotFoundException(object content = null)
: base(HttpStatusCode.NotFound, content)
{
}
}
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using FluentValidation;
using FluentValidation.Internal;
using FluentValidation.Resources;
using Readarr.Http.ClientSchema;
namespace Readarr.Http.REST
{
public class ResourceValidator<TResource> : AbstractValidator<TResource>
{
public IRuleBuilderInitial<TResource, TProperty> RuleForField<TProperty>(Expression<Func<TResource, IEnumerable<Field>>> fieldListAccessor, string fieldName)
{
var rule = new PropertyRule(fieldListAccessor.GetMember(), c => GetValue(c, fieldListAccessor.Compile(), fieldName), null, () => CascadeMode.Continue, typeof(TProperty), typeof(TResource));
rule.PropertyName = fieldName;
rule.DisplayName = new StaticStringSource(fieldName);
AddRule(rule);
return new RuleBuilder<TResource, TProperty>(rule, this);
}
private static object GetValue(object container, Func<TResource, IEnumerable<Field>> fieldListAccessor, string fieldName)
{
var resource = fieldListAccessor((TResource)container).SingleOrDefault(c => c.Name == fieldName);
if (resource == null)
{
return null;
}
return resource.Value;
}
}
}

View File

@@ -0,0 +1,344 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using Nancy;
using Nancy.Responses.Negotiation;
using Newtonsoft.Json;
using NzbDrone.Core.Datastore;
using Readarr.Http.Extensions;
namespace Readarr.Http.REST
{
public abstract class RestModule<TResource> : NancyModule
where TResource : RestResource, new()
{
private const string ROOT_ROUTE = "/";
private const string ID_ROUTE = @"/(?<id>[\d]{1,10})";
private readonly HashSet<string> _excludedKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase)
{
"page",
"pageSize",
"sortKey",
"sortDirection",
"filterKey",
"filterValue",
};
private Action<int> _deleteResource;
private Func<int, TResource> _getResourceById;
private Func<List<TResource>> _getResourceAll;
private Func<PagingResource<TResource>, PagingResource<TResource>> _getResourcePaged;
private Func<TResource> _getResourceSingle;
private Func<TResource, int> _createResource;
private Action<TResource> _updateResource;
protected ResourceValidator<TResource> PostValidator { get; private set; }
protected ResourceValidator<TResource> PutValidator { get; private set; }
protected ResourceValidator<TResource> SharedValidator { get; private set; }
protected void ValidateId(int id)
{
if (id <= 0)
{
throw new BadRequestException(id + " is not a valid ID");
}
}
protected RestModule(string modulePath)
: base(modulePath)
{
ValidateModule();
PostValidator = new ResourceValidator<TResource>();
PutValidator = new ResourceValidator<TResource>();
SharedValidator = new ResourceValidator<TResource>();
}
private void ValidateModule()
{
if (GetResourceById != null)
{
return;
}
if (CreateResource != null || UpdateResource != null)
{
throw new InvalidOperationException("GetResourceById route must be defined before defining Create/Update routes.");
}
}
protected Action<int> DeleteResource
{
private get
{
return _deleteResource;
}
set
{
_deleteResource = value;
Delete(ID_ROUTE, options =>
{
ValidateId(options.Id);
DeleteResource((int)options.Id);
return new object();
});
}
}
protected Func<int, TResource> GetResourceById
{
get
{
return _getResourceById;
}
set
{
_getResourceById = value;
Get(ID_ROUTE, options =>
{
ValidateId(options.Id);
try
{
var resource = GetResourceById((int)options.Id);
if (resource == null)
{
return new NotFoundResponse();
}
return resource;
}
catch (ModelNotFoundException)
{
return new NotFoundResponse();
}
});
}
}
protected Func<List<TResource>> GetResourceAll
{
private get
{
return _getResourceAll;
}
set
{
_getResourceAll = value;
Get(ROOT_ROUTE, options =>
{
var resource = GetResourceAll();
return resource;
});
}
}
protected Func<PagingResource<TResource>, PagingResource<TResource>> GetResourcePaged
{
private get
{
return _getResourcePaged;
}
set
{
_getResourcePaged = value;
Get(ROOT_ROUTE, options =>
{
var resource = GetResourcePaged(ReadPagingResourceFromRequest());
return resource;
});
}
}
protected Func<TResource> GetResourceSingle
{
private get
{
return _getResourceSingle;
}
set
{
_getResourceSingle = value;
Get(ROOT_ROUTE, options =>
{
var resource = GetResourceSingle();
return resource;
});
}
}
protected Func<TResource, int> CreateResource
{
private get
{
return _createResource;
}
set
{
_createResource = value;
Post(ROOT_ROUTE, options =>
{
var id = CreateResource(ReadResourceFromRequest());
return ResponseWithCode(GetResourceById(id), HttpStatusCode.Created);
});
}
}
protected Action<TResource> UpdateResource
{
private get
{
return _updateResource;
}
set
{
_updateResource = value;
Put(ROOT_ROUTE, options =>
{
var resource = ReadResourceFromRequest();
UpdateResource(resource);
return ResponseWithCode(GetResourceById(resource.Id), HttpStatusCode.Accepted);
});
Put(ID_ROUTE, options =>
{
var resource = ReadResourceFromRequest();
resource.Id = options.Id;
UpdateResource(resource);
return ResponseWithCode(GetResourceById(resource.Id), HttpStatusCode.Accepted);
});
}
}
protected Negotiator ResponseWithCode(object model, HttpStatusCode statusCode)
{
return Negotiate.WithModel(model).WithStatusCode(statusCode);
}
protected TResource ReadResourceFromRequest(bool skipValidate = false)
{
var resource = new TResource();
try
{
resource = Request.Body.FromJson<TResource>();
}
catch (JsonReaderException ex)
{
throw new BadRequestException(ex.Message);
}
if (resource == null)
{
throw new BadRequestException("Request body can't be empty");
}
var errors = SharedValidator.Validate(resource).Errors.ToList();
if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !skipValidate && !Request.Url.Path.EndsWith("/test", StringComparison.InvariantCultureIgnoreCase))
{
errors.AddRange(PostValidator.Validate(resource).Errors);
}
else if (Request.Method.Equals("PUT", StringComparison.InvariantCultureIgnoreCase))
{
errors.AddRange(PutValidator.Validate(resource).Errors);
}
if (errors.Any())
{
throw new ValidationException(errors);
}
return resource;
}
private PagingResource<TResource> ReadPagingResourceFromRequest()
{
int pageSize;
int.TryParse(Request.Query.PageSize.ToString(), out pageSize);
if (pageSize == 0)
{
pageSize = 10;
}
int page;
int.TryParse(Request.Query.Page.ToString(), out page);
if (page == 0)
{
page = 1;
}
var pagingResource = new PagingResource<TResource>
{
PageSize = pageSize,
Page = page,
Filters = new List<PagingResourceFilter>()
};
if (Request.Query.SortKey != null)
{
pagingResource.SortKey = Request.Query.SortKey.ToString();
// For backwards compatibility with v2
if (Request.Query.SortDir != null)
{
pagingResource.SortDirection = Request.Query.SortDir.ToString()
.Equals("Asc", StringComparison.InvariantCultureIgnoreCase)
? SortDirection.Ascending
: SortDirection.Descending;
}
// v3 uses SortDirection instead of SortDir to be consistent with every other use of it
if (Request.Query.SortDirection != null)
{
pagingResource.SortDirection = Request.Query.SortDirection.ToString()
.Equals("ascending", StringComparison.InvariantCultureIgnoreCase)
? SortDirection.Ascending
: SortDirection.Descending;
}
}
// For backwards compatibility with v2
if (Request.Query.FilterKey != null)
{
var filter = new PagingResourceFilter
{
Key = Request.Query.FilterKey.ToString()
};
if (Request.Query.FilterValue != null)
{
filter.Value = Request.Query.FilterValue?.ToString();
}
pagingResource.Filters.Add(filter);
}
// v3 uses filters in key=value format
foreach (var key in Request.Query)
{
if (_excludedKeys.Contains(key))
{
continue;
}
pagingResource.Filters.Add(new PagingResourceFilter
{
Key = key,
Value = Request.Query[key]
});
}
return pagingResource;
}
}
}

View File

@@ -0,0 +1,13 @@
using Newtonsoft.Json;
namespace Readarr.Http.REST
{
public abstract class RestResource
{
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public int Id { get; set; }
[JsonIgnore]
public virtual string ResourceName => GetType().Name.ToLowerInvariant().Replace("resource", "");
}
}

View File

@@ -0,0 +1,13 @@
using Nancy;
using Readarr.Http.Exceptions;
namespace Readarr.Http.REST
{
public class UnsupportedMediaTypeException : ApiException
{
public UnsupportedMediaTypeException(object content = null)
: base(HttpStatusCode.UnsupportedMediaType, content)
{
}
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net462;netcoreapp3.1</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="8.6.0" />
<PackageReference Include="Nancy" Version="2.0.0" />
<PackageReference Include="Nancy.Authentication.Basic" Version="2.0.0" />
<PackageReference Include="Nancy.Authentication.Forms" Version="2.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="NLog" Version="4.6.8" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NzbDrone.Core\Readarr.Core.csproj" />
<ProjectReference Include="..\NzbDrone.SignalR\Readarr.SignalR.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,75 @@
using System;
using System.Linq;
using Nancy;
using Nancy.Bootstrapper;
using Nancy.Diagnostics;
using Nancy.Responses.Negotiation;
using NLog;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Instrumentation;
using NzbDrone.Core.Instrumentation;
using Readarr.Http.Extensions.Pipelines;
using TinyIoC;
namespace Readarr.Http
{
public class ReadarrBootstrapper : TinyIoCNancyBootstrapper
{
private readonly TinyIoCContainer _tinyIoCContainer;
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(ReadarrBootstrapper));
public ReadarrBootstrapper(TinyIoCContainer tinyIoCContainer)
{
_tinyIoCContainer = tinyIoCContainer;
}
protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines)
{
Logger.Info("Starting Web Server");
if (RuntimeInfo.IsProduction)
{
DiagnosticsHook.Disable(pipelines);
}
RegisterPipelines(pipelines);
container.Resolve<DatabaseTarget>().Register();
}
private void RegisterPipelines(IPipelines pipelines)
{
var pipelineRegistrars = _tinyIoCContainer.ResolveAll<IRegisterNancyPipeline>().OrderBy(v => v.Order).ToList();
foreach (var registerNancyPipeline in pipelineRegistrars)
{
registerNancyPipeline.Register(pipelines);
}
}
protected override TinyIoCContainer GetApplicationContainer()
{
return _tinyIoCContainer;
}
protected override Func<ITypeCatalog, NancyInternalConfiguration> InternalConfiguration
{
get
{
// We don't support Xml Serialization atm
return NancyInternalConfiguration.WithOverrides(x =>
{
x.ResponseProcessors.Remove(typeof(ViewProcessor));
x.ResponseProcessors.Remove(typeof(XmlProcessor));
});
}
}
public override void Configure(Nancy.Configuration.INancyEnvironment environment)
{
environment.Diagnostics(password: @"password");
}
protected override byte[] FavIcon => null;
}
}

View File

@@ -0,0 +1,18 @@
using Nancy;
using Nancy.Responses.Negotiation;
namespace Readarr.Http
{
public abstract class ReadarrModule : NancyModule
{
protected ReadarrModule(string resource)
: base(resource)
{
}
protected Negotiator ResponseWithCode(object model, HttpStatusCode statusCode)
{
return Negotiate.WithModel(model).WithStatusCode(statusCode);
}
}
}

View File

@@ -0,0 +1,57 @@
using System;
using NzbDrone.Core.Datastore;
using Readarr.Http.REST;
using Readarr.Http.Validation;
namespace Readarr.Http
{
public abstract class ReadarrRestModule<TResource> : RestModule<TResource>
where TResource : RestResource, new()
{
protected string Resource { get; private set; }
private static string BaseUrl()
{
var isV1 = typeof(TResource).Namespace.Contains(".V1.");
if (isV1)
{
return "/api/v1/";
}
return "/api/";
}
private static string ResourceName()
{
return new TResource().ResourceName.Trim('/').ToLower();
}
protected ReadarrRestModule()
: this(ResourceName())
{
}
protected ReadarrRestModule(string resource)
: base(BaseUrl() + resource.Trim('/').ToLower())
{
Resource = resource;
PostValidator.RuleFor(r => r.Id).IsZero();
PutValidator.RuleFor(r => r.Id).ValidId();
}
protected PagingResource<TResource> ApplyToPage<TModel>(Func<PagingSpec<TModel>, PagingSpec<TModel>> function, PagingSpec<TModel> pagingSpec, Converter<TModel, TResource> mapper)
{
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)
};
}
}
}

View File

@@ -0,0 +1,99 @@
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.SignalR;
using Readarr.Http.REST;
namespace Readarr.Http
{
public abstract class ReadarrRestModuleWithSignalR<TResource, TModel> : ReadarrRestModule<TResource>, IHandle<ModelEvent<TModel>>
where TResource : RestResource, new()
where TModel : ModelBase, new()
{
private readonly IBroadcastSignalRMessage _signalRBroadcaster;
protected ReadarrRestModuleWithSignalR(IBroadcastSignalRMessage signalRBroadcaster)
{
_signalRBroadcaster = signalRBroadcaster;
}
protected ReadarrRestModuleWithSignalR(IBroadcastSignalRMessage signalRBroadcaster, string resource)
: base(resource)
{
_signalRBroadcaster = signalRBroadcaster;
}
public void Handle(ModelEvent<TModel> message)
{
if (!_signalRBroadcaster.IsConnected)
{
return;
}
if (message.Action == ModelAction.Deleted || message.Action == ModelAction.Sync)
{
BroadcastResourceChange(message.Action);
}
BroadcastResourceChange(message.Action, message.Model.Id);
}
protected void BroadcastResourceChange(ModelAction action, int id)
{
if (!_signalRBroadcaster.IsConnected)
{
return;
}
if (action == ModelAction.Deleted)
{
BroadcastResourceChange(action, new TResource { Id = id });
}
else
{
var resource = GetResourceById(id);
BroadcastResourceChange(action, resource);
}
}
protected void BroadcastResourceChange(ModelAction action, TResource resource)
{
if (!_signalRBroadcaster.IsConnected)
{
return;
}
if (GetType().Namespace.Contains("V1"))
{
var signalRMessage = new SignalRMessage
{
Name = Resource,
Body = new ResourceChangeMessage<TResource>(resource, action),
Action = action
};
_signalRBroadcaster.BroadcastMessage(signalRMessage);
}
}
protected void BroadcastResourceChange(ModelAction action)
{
if (!_signalRBroadcaster.IsConnected)
{
return;
}
if (GetType().Namespace.Contains("V1"))
{
var signalRMessage = new SignalRMessage
{
Name = Resource,
Body = new ResourceChangeMessage<TResource>(action),
Action = action
};
_signalRBroadcaster.BroadcastMessage(signalRMessage);
}
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
using NzbDrone.Core.Datastore.Events;
using Readarr.Http.REST;
namespace Readarr.Http
{
public class ResourceChangeMessage<TResource>
where TResource : RestResource
{
public TResource Resource { get; private set; }
public ModelAction Action { get; private set; }
public ResourceChangeMessage(ModelAction action)
{
if (action != ModelAction.Deleted && action != ModelAction.Sync)
{
throw new InvalidOperationException("Resource message without a resource needs to have Delete or Sync as action");
}
Action = action;
}
public ResourceChangeMessage(TResource resource, ModelAction action)
{
Resource = resource;
Action = action;
}
}
}

View File

@@ -0,0 +1,273 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Nancy;
using Nancy.Bootstrapper;
using Nancy.Configuration;
using Nancy.Diagnostics;
using TinyIoC;
namespace Readarr.Http
{
/// <summary>
/// TinyIoC bootstrapper - registers default route resolver and registers itself as
/// INancyModuleCatalog for resolving modules but behaviour can be overridden if required.
/// </summary>
public class TinyIoCNancyBootstrapper : NancyBootstrapperWithRequestContainerBase<TinyIoCContainer>
{
/// <summary>
/// Default assemblies that are ignored for autoregister
/// </summary>
public static IEnumerable<Func<Assembly, bool>> DefaultAutoRegisterIgnoredAssemblies = new Func<Assembly, bool>[]
{
asm => !asm.FullName.StartsWith("Nancy.", StringComparison.InvariantCulture)
};
/// <summary>
/// Gets the assemblies to ignore when autoregistering the application container
/// Return true from the delegate to ignore that particular assembly, returning false
/// does not mean the assembly *will* be included, a true from another delegate will
/// take precedence.
/// </summary>
protected virtual IEnumerable<Func<Assembly, bool>> AutoRegisterIgnoredAssemblies => DefaultAutoRegisterIgnoredAssemblies;
/// <summary>
/// Configures the container using AutoRegister followed by registration
/// of default INancyModuleCatalog and IRouteResolver.
/// </summary>
/// <param name="container">Container instance</param>
protected override void ConfigureApplicationContainer(TinyIoCContainer container)
{
AutoRegister(container, AutoRegisterIgnoredAssemblies);
}
/// <summary>
/// Resolve INancyEngine
/// </summary>
/// <returns>INancyEngine implementation</returns>
protected override sealed INancyEngine GetEngineInternal()
{
return ApplicationContainer.Resolve<INancyEngine>();
}
// Summary:
// Gets the Nancy.Configuration.INancyEnvironmentConfigurator used by th.
// Returns:
// An Nancy.Configuration.INancyEnvironmentConfigurator instance.
protected override INancyEnvironmentConfigurator GetEnvironmentConfigurator()
{
return ApplicationContainer.Resolve<INancyEnvironmentConfigurator>();
}
// Summary:
// Get the Nancy.Configuration.INancyEnvironment instance.
// Returns:
// An configured Nancy.Configuration.INancyEnvironment instance.
// Remarks:
// The boostrapper must be initialised (Nancy.Bootstrapper.INancyBootstrapper.Initialise)
// prior to calling this.
public override INancyEnvironment GetEnvironment()
{
return ApplicationContainer.Resolve<INancyEnvironment>();
}
// Summary:
// Registers an Nancy.Configuration.INancyEnvironment instance in the container.
// Parameters:
// container:
// The container to register into.
// environment:
// The Nancy.Configuration.INancyEnvironment instance to register.
protected override void RegisterNancyEnvironment(TinyIoCContainer container, INancyEnvironment environment)
{
ApplicationContainer.Register<INancyEnvironment>(environment);
}
/// <summary>
/// Create a default, unconfigured, container
/// </summary>
/// <returns>Container instance</returns>
protected override TinyIoCContainer GetApplicationContainer()
{
return new TinyIoCContainer();
}
/// <summary>
/// Register the bootstrapper's implemented types into the container.
/// This is necessary so a user can pass in a populated container but not have
/// to take the responsibility of registering things like INancyModuleCatalog manually.
/// </summary>
/// <param name="applicationContainer">Application container to register into</param>
protected override sealed void RegisterBootstrapperTypes(TinyIoCContainer applicationContainer)
{
applicationContainer.Register<INancyModuleCatalog>(this);
}
/// <summary>
/// Register the default implementations of internally used types into the container as singletons
/// </summary>
/// <param name="container">Container to register into</param>
/// <param name="typeRegistrations">Type registrations to register</param>
protected override sealed void RegisterTypes(TinyIoCContainer container, IEnumerable<TypeRegistration> typeRegistrations)
{
foreach (var typeRegistration in typeRegistrations)
{
switch (typeRegistration.Lifetime)
{
case Lifetime.Transient:
container.Register(typeRegistration.RegistrationType, typeRegistration.ImplementationType).AsMultiInstance();
break;
case Lifetime.Singleton:
container.Register(typeRegistration.RegistrationType, typeRegistration.ImplementationType).AsSingleton();
break;
case Lifetime.PerRequest:
throw new InvalidOperationException("Unable to directly register a per request lifetime.");
default:
throw new ArgumentOutOfRangeException();
}
}
}
/// <summary>
/// Register the various collections into the container as singletons to later be resolved
/// by IEnumerable{Type} constructor dependencies.
/// </summary>
/// <param name="container">Container to register into</param>
/// <param name="collectionTypeRegistrations">Collection type registrations to register</param>
protected override sealed void RegisterCollectionTypes(TinyIoCContainer container, IEnumerable<CollectionTypeRegistration> collectionTypeRegistrations)
{
foreach (var collectionTypeRegistration in collectionTypeRegistrations)
{
switch (collectionTypeRegistration.Lifetime)
{
case Lifetime.Transient:
container.RegisterMultiple(collectionTypeRegistration.RegistrationType, collectionTypeRegistration.ImplementationTypes).AsMultiInstance();
break;
case Lifetime.Singleton:
container.RegisterMultiple(collectionTypeRegistration.RegistrationType, collectionTypeRegistration.ImplementationTypes).AsSingleton();
break;
case Lifetime.PerRequest:
throw new InvalidOperationException("Unable to directly register a per request lifetime.");
default:
throw new ArgumentOutOfRangeException();
}
}
}
/// <summary>
/// Register the given module types into the container
/// </summary>
/// <param name="container">Container to register into</param>
/// <param name="moduleRegistrationTypes">NancyModule types</param>
protected override sealed void RegisterRequestContainerModules(TinyIoCContainer container, IEnumerable<ModuleRegistration> moduleRegistrationTypes)
{
foreach (var moduleRegistrationType in moduleRegistrationTypes)
{
container.Register(
typeof(INancyModule),
moduleRegistrationType.ModuleType,
moduleRegistrationType.ModuleType.FullName).
AsSingleton();
}
}
/// <summary>
/// Register the given instances into the container
/// </summary>
/// <param name="container">Container to register into</param>
/// <param name="instanceRegistrations">Instance registration types</param>
protected override void RegisterInstances(TinyIoCContainer container, IEnumerable<InstanceRegistration> instanceRegistrations)
{
foreach (var instanceRegistration in instanceRegistrations)
{
container.Register(
instanceRegistration.RegistrationType,
instanceRegistration.Implementation);
}
}
/// <summary>
/// Creates a per request child/nested container
/// </summary>
/// <param name="context">Current context</param>
/// <returns>Request container instance</returns>
protected override TinyIoCContainer CreateRequestContainer(NancyContext context)
{
return ApplicationContainer.GetChildContainer();
}
/// <summary>
/// Gets the diagnostics for initialisation
/// </summary>
/// <returns>IDiagnostics implementation</returns>
protected override IDiagnostics GetDiagnostics()
{
return ApplicationContainer.Resolve<IDiagnostics>();
}
/// <summary>
/// Gets all registered startup tasks
/// </summary>
/// <returns>An <see cref="IEnumerable{T}"/> instance containing <see cref="IApplicationStartup"/> instances. </returns>
protected override IEnumerable<IApplicationStartup> GetApplicationStartupTasks()
{
return ApplicationContainer.ResolveAll<IApplicationStartup>(false);
}
/// <summary>
/// Gets all registered request startup tasks
/// </summary>
/// <returns>An <see cref="IEnumerable{T}"/> instance containing <see cref="IRequestStartup"/> instances.</returns>
protected override IEnumerable<IRequestStartup> RegisterAndGetRequestStartupTasks(TinyIoCContainer container, Type[] requestStartupTypes)
{
container.RegisterMultiple(typeof(IRequestStartup), requestStartupTypes);
return container.ResolveAll<IRequestStartup>(false);
}
/// <summary>
/// Gets all registered application registration tasks
/// </summary>
/// <returns>An <see cref="IEnumerable{T}"/> instance containing <see cref="IRegistrations"/> instances.</returns>
protected override IEnumerable<IRegistrations> GetRegistrationTasks()
{
return ApplicationContainer.ResolveAll<IRegistrations>(false);
}
/// <summary>
/// Retrieve all module instances from the container
/// </summary>
/// <param name="container">Container to use</param>
/// <returns>Collection of NancyModule instances</returns>
protected override sealed IEnumerable<INancyModule> GetAllModules(TinyIoCContainer container)
{
var nancyModules = container.ResolveAll<INancyModule>(false);
return nancyModules;
}
/// <summary>
/// Retrieve a specific module instance from the container
/// </summary>
/// <param name="container">Container to use</param>
/// <param name="moduleType">Type of the module</param>
/// <returns>NancyModule instance</returns>
protected override sealed INancyModule GetModule(TinyIoCContainer container, Type moduleType)
{
container.Register(typeof(INancyModule), moduleType);
return container.Resolve<INancyModule>();
}
/// <summary>
/// Executes auto registation with the given container.
/// </summary>
/// <param name="container">Container instance</param>
private static void AutoRegister(TinyIoCContainer container, IEnumerable<Func<Assembly, bool>> ignoredAssemblies)
{
var assembly = typeof(NancyEngine).Assembly;
container.AutoRegister(AppDomain.CurrentDomain.GetAssemblies().Where(a => !ignoredAssemblies.Any(ia => ia(a))), DuplicateImplementationActions.RegisterMultiple, t => t.Assembly != assembly);
}
}
}

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
using FluentValidation.Validators;
using NzbDrone.Common.Extensions;
namespace Readarr.Http.Validation
{
public class EmptyCollectionValidator<T> : PropertyValidator
{
public EmptyCollectionValidator()
: base("Collection Must Be Empty")
{
}
protected override bool IsValid(PropertyValidatorContext context)
{
if (context.PropertyValue == null)
{
return true;
}
var collection = context.PropertyValue as IEnumerable<T>;
return collection != null && collection.Empty();
}
}
}

View File

@@ -0,0 +1,34 @@
using FluentValidation.Validators;
namespace Readarr.Http.Validation
{
public class RssSyncIntervalValidator : PropertyValidator
{
public RssSyncIntervalValidator()
: base("Must be between 10 and 120 or 0 to disable")
{
}
protected override bool IsValid(PropertyValidatorContext context)
{
if (context.PropertyValue == null)
{
return true;
}
var value = (int)context.PropertyValue;
if (value == 0)
{
return true;
}
if (value >= 10 && value <= 120)
{
return true;
}
return false;
}
}
}

View File

@@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.Text.RegularExpressions;
using FluentValidation;
using FluentValidation.Validators;
namespace Readarr.Http.Validation
{
public static class RuleBuilderExtensions
{
public static IRuleBuilderOptions<T, int> ValidId<T>(this IRuleBuilder<T, int> ruleBuilder)
{
return ruleBuilder.SetValidator(new GreaterThanValidator(0));
}
public static IRuleBuilderOptions<T, int> IsZero<T>(this IRuleBuilder<T, int> ruleBuilder)
{
return ruleBuilder.SetValidator(new EqualValidator(0));
}
public static IRuleBuilderOptions<T, string> HaveHttpProtocol<T>(this IRuleBuilder<T, string> ruleBuilder)
{
return ruleBuilder.SetValidator(new RegularExpressionValidator("^http(s)?://", RegexOptions.IgnoreCase)).WithMessage("must start with http:// or https://");
}
public static IRuleBuilderOptions<T, string> NotBlank<T>(this IRuleBuilder<T, string> ruleBuilder)
{
return ruleBuilder.SetValidator(new NotNullValidator()).SetValidator(new NotEmptyValidator(""));
}
public static IRuleBuilderOptions<T, IEnumerable<TProp>> EmptyCollection<T, TProp>(this IRuleBuilder<T, IEnumerable<TProp>> ruleBuilder)
{
return ruleBuilder.SetValidator(new EmptyCollectionValidator<TProp>());
}
public static IRuleBuilderOptions<T, int> IsValidRssSyncInterval<T>(this IRuleBuilder<T, int> ruleBuilder)
{
return ruleBuilder.SetValidator(new RssSyncIntervalValidator());
}
}
}