mirror of
https://github.com/fergalmoran/Readarr.git
synced 2026-03-09 06:55:44 +00:00
New: Lidarr to Readarr
This commit is contained in:
50
src/Readarr.Http/Authentication/AuthenticationModule.cs
Normal file
50
src/Readarr.Http/Authentication/AuthenticationModule.cs
Normal 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 + "/");
|
||||
}
|
||||
}
|
||||
}
|
||||
234
src/Readarr.Http/Authentication/AuthenticationService.cs
Normal file
234
src/Readarr.Http/Authentication/AuthenticationService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
143
src/Readarr.Http/Authentication/EnableAuthInNancy.cs
Normal file
143
src/Readarr.Http/Authentication/EnableAuthInNancy.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/Readarr.Http/Authentication/LoginResource.cs
Normal file
9
src/Readarr.Http/Authentication/LoginResource.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
25
src/Readarr.Http/ClientSchema/Field.cs
Normal file
25
src/Readarr.Http/ClientSchema/Field.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/Readarr.Http/ClientSchema/FieldMapping.cs
Normal file
12
src/Readarr.Http/ClientSchema/FieldMapping.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
216
src/Readarr.Http/ClientSchema/SchemaBuilder.cs
Normal file
216
src/Readarr.Http/ClientSchema/SchemaBuilder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/Readarr.Http/ClientSchema/SelectOption.cs
Normal file
8
src/Readarr.Http/ClientSchema/SelectOption.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Readarr.Http.ClientSchema
|
||||
{
|
||||
public class SelectOption
|
||||
{
|
||||
public int Value { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
41
src/Readarr.Http/ErrorManagement/ErrorHandler.cs
Normal file
41
src/Readarr.Http/ErrorManagement/ErrorHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/Readarr.Http/ErrorManagement/ErrorModel.cs
Normal file
21
src/Readarr.Http/ErrorManagement/ErrorModel.cs
Normal 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
92
src/Readarr.Http/ErrorManagement/ReadarrErrorPipeline.cs
Normal file
92
src/Readarr.Http/ErrorManagement/ReadarrErrorPipeline.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/Readarr.Http/Exceptions/ApiException.cs
Normal file
39
src/Readarr.Http/Exceptions/ApiException.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/Readarr.Http/Exceptions/InvalidApiKeyException.cs
Normal file
16
src/Readarr.Http/Exceptions/InvalidApiKeyException.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace Readarr.Http.Exceptions
|
||||
{
|
||||
public class InvalidApiKeyException : Exception
|
||||
{
|
||||
public InvalidApiKeyException()
|
||||
{
|
||||
}
|
||||
|
||||
public InvalidApiKeyException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/Readarr.Http/Extensions/AccessControlHeaders.cs
Normal file
12
src/Readarr.Http/Extensions/AccessControlHeaders.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
23
src/Readarr.Http/Extensions/NancyJsonSerializer.cs
Normal file
23
src/Readarr.Http/Extensions/NancyJsonSerializer.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
41
src/Readarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs
Normal file
41
src/Readarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/Readarr.Http/Extensions/Pipelines/CorsPipeline.cs
Normal file
80
src/Readarr.Http/Extensions/Pipelines/CorsPipeline.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/Readarr.Http/Extensions/Pipelines/GZipPipeline.cs
Normal file
104
src/Readarr.Http/Extensions/Pipelines/GZipPipeline.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Nancy.Bootstrapper;
|
||||
|
||||
namespace Readarr.Http.Extensions.Pipelines
|
||||
{
|
||||
public interface IRegisterNancyPipeline
|
||||
{
|
||||
int Order { get; }
|
||||
|
||||
void Register(IPipelines pipelines);
|
||||
}
|
||||
}
|
||||
36
src/Readarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs
Normal file
36
src/Readarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/Readarr.Http/Extensions/Pipelines/UrlBasePipeline.cs
Normal file
46
src/Readarr.Http/Extensions/Pipelines/UrlBasePipeline.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/Readarr.Http/Extensions/ReqResExtensions.cs
Normal file
63
src/Readarr.Http/Extensions/ReqResExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/Readarr.Http/Extensions/RequestExtensions.cs
Normal file
58
src/Readarr.Http/Extensions/RequestExtensions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
74
src/Readarr.Http/Frontend/CacheableSpecification.cs
Normal file
74
src/Readarr.Http/Frontend/CacheableSpecification.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/Readarr.Http/Frontend/InitializeJsModule.cs
Normal file
78
src/Readarr.Http/Frontend/InitializeJsModule.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/Readarr.Http/Frontend/Mappers/BackupFileMapper.cs
Normal file
30
src/Readarr.Http/Frontend/Mappers/BackupFileMapper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/Readarr.Http/Frontend/Mappers/BrowserConfig.cs
Normal file
34
src/Readarr.Http/Frontend/Mappers/BrowserConfig.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/Readarr.Http/Frontend/Mappers/CacheBreakerProvider.cs
Normal file
43
src/Readarr.Http/Frontend/Mappers/CacheBreakerProvider.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/Readarr.Http/Frontend/Mappers/FaviconMapper.cs
Normal file
40
src/Readarr.Http/Frontend/Mappers/FaviconMapper.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
82
src/Readarr.Http/Frontend/Mappers/HtmlMapperBase.cs
Normal file
82
src/Readarr.Http/Frontend/Mappers/HtmlMapperBase.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/Readarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs
Normal file
11
src/Readarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
42
src/Readarr.Http/Frontend/Mappers/IndexHtmlMapper.cs
Normal file
42
src/Readarr.Http/Frontend/Mappers/IndexHtmlMapper.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/Readarr.Http/Frontend/Mappers/LogFileMapper.cs
Normal file
32
src/Readarr.Http/Frontend/Mappers/LogFileMapper.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/Readarr.Http/Frontend/Mappers/LoginHtmlMapper.cs
Normal file
33
src/Readarr.Http/Frontend/Mappers/LoginHtmlMapper.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/Readarr.Http/Frontend/Mappers/ManifestMapper.cs
Normal file
34
src/Readarr.Http/Frontend/Mappers/ManifestMapper.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
49
src/Readarr.Http/Frontend/Mappers/MediaCoverMapper.cs
Normal file
49
src/Readarr.Http/Frontend/Mappers/MediaCoverMapper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/Readarr.Http/Frontend/Mappers/RobotsTxtMapper.cs
Normal file
33
src/Readarr.Http/Frontend/Mappers/RobotsTxtMapper.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/Readarr.Http/Frontend/Mappers/StaticResourceMapper.cs
Normal file
48
src/Readarr.Http/Frontend/Mappers/StaticResourceMapper.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/Readarr.Http/Frontend/Mappers/UpdateLogFileMapper.cs
Normal file
32
src/Readarr.Http/Frontend/Mappers/UpdateLogFileMapper.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/Readarr.Http/Frontend/StaticResourceModule.cs
Normal file
48
src/Readarr.Http/Frontend/StaticResourceModule.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/Readarr.Http/Mapping/MappingValidation.cs
Normal file
54
src/Readarr.Http/Mapping/MappingValidation.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/Readarr.Http/Mapping/ResourceMappingException.cs
Normal file
14
src/Readarr.Http/Mapping/ResourceMappingException.cs
Normal 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)))
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/Readarr.Http/PagingResource.cs
Normal file
41
src/Readarr.Http/PagingResource.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/Readarr.Http/PagingResourceFilter.cs
Normal file
8
src/Readarr.Http/PagingResourceFilter.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Readarr.Http
|
||||
{
|
||||
public class PagingResourceFilter
|
||||
{
|
||||
public string Key { get; set; }
|
||||
public string Value { get; set; }
|
||||
}
|
||||
}
|
||||
13
src/Readarr.Http/REST/BadRequestException.cs
Normal file
13
src/Readarr.Http/REST/BadRequestException.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Readarr.Http/REST/MethodNotAllowedException.cs
Normal file
13
src/Readarr.Http/REST/MethodNotAllowedException.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Readarr.Http/REST/NotFoundException.cs
Normal file
13
src/Readarr.Http/REST/NotFoundException.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/Readarr.Http/REST/ResourceValidator.cs
Normal file
36
src/Readarr.Http/REST/ResourceValidator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
344
src/Readarr.Http/REST/RestModule.cs
Normal file
344
src/Readarr.Http/REST/RestModule.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Readarr.Http/REST/RestResource.cs
Normal file
13
src/Readarr.Http/REST/RestResource.cs
Normal 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", "");
|
||||
}
|
||||
}
|
||||
13
src/Readarr.Http/REST/UnsupportedMediaTypeException.cs
Normal file
13
src/Readarr.Http/REST/UnsupportedMediaTypeException.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/Readarr.Http/Readarr.Http.csproj
Normal file
17
src/Readarr.Http/Readarr.Http.csproj
Normal 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>
|
||||
75
src/Readarr.Http/ReadarrBootstrapper.cs
Normal file
75
src/Readarr.Http/ReadarrBootstrapper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
18
src/Readarr.Http/ReadarrModule.cs
Normal file
18
src/Readarr.Http/ReadarrModule.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/Readarr.Http/ReadarrRestModule.cs
Normal file
57
src/Readarr.Http/ReadarrRestModule.cs
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
99
src/Readarr.Http/ReadarrRestModuleWithSignalR.cs
Normal file
99
src/Readarr.Http/ReadarrRestModuleWithSignalR.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/Readarr.Http/ResourceChangeMessage.cs
Normal file
29
src/Readarr.Http/ResourceChangeMessage.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
273
src/Readarr.Http/TinyIoCNancyBootstrapper.cs
Normal file
273
src/Readarr.Http/TinyIoCNancyBootstrapper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/Readarr.Http/Validation/EmptyCollectionValidator.cs
Normal file
26
src/Readarr.Http/Validation/EmptyCollectionValidator.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/Readarr.Http/Validation/RssSyncIntervalValidator.cs
Normal file
34
src/Readarr.Http/Validation/RssSyncIntervalValidator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/Readarr.Http/Validation/RuleBuilderExtensions.cs
Normal file
40
src/Readarr.Http/Validation/RuleBuilderExtensions.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user