diff --git a/frontend/src/Store/Actions/commandActions.js b/frontend/src/Store/Actions/commandActions.js index 66d2ca6da..a31979d0d 100644 --- a/frontend/src/Store/Actions/commandActions.js +++ b/frontend/src/Store/Actions/commandActions.js @@ -139,7 +139,8 @@ export function executeCommandHelper( payload, dispatch) { const promise = createAjaxRequest({ url: '/command', method: 'POST', - data: JSON.stringify(payload) + data: JSON.stringify(payload), + dataType: 'json' }).request; return promise.then((data) => { diff --git a/frontend/src/Store/Actions/tagActions.js b/frontend/src/Store/Actions/tagActions.js index 3b92eb8a4..6800b1d58 100644 --- a/frontend/src/Store/Actions/tagActions.js +++ b/frontend/src/Store/Actions/tagActions.js @@ -53,7 +53,8 @@ export const actionHandlers = handleThunks({ const promise = createAjaxRequest({ url: '/tag', method: 'POST', - data: JSON.stringify(payload.tag) + data: JSON.stringify(payload.tag), + dataType: 'json' }).request; promise.done((data) => { diff --git a/src/NzbDrone.Common/Serializer/System.Text.Json/STJson.cs b/src/NzbDrone.Common/Serializer/System.Text.Json/STJson.cs index c10740d3c..4fba4fae1 100644 --- a/src/NzbDrone.Common/Serializer/System.Text.Json/STJson.cs +++ b/src/NzbDrone.Common/Serializer/System.Text.Json/STJson.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading.Tasks; namespace NzbDrone.Common.Serializer { @@ -15,15 +16,19 @@ namespace NzbDrone.Common.Serializer public static JsonSerializerOptions GetSerializerSettings() { - var serializerSettings = new JsonSerializerOptions - { - AllowTrailingCommas = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNameCaseInsensitive = true, - DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true - }; + var settings = new JsonSerializerOptions(); + ApplySerializerSettings(settings); + return settings; + } + + public static void ApplySerializerSettings(JsonSerializerOptions serializerSettings) + { + serializerSettings.AllowTrailingCommas = true; + serializerSettings.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + serializerSettings.PropertyNameCaseInsensitive = true; + serializerSettings.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase; + serializerSettings.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + serializerSettings.WriteIndented = true; serializerSettings.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, true)); serializerSettings.Converters.Add(new STJVersionConverter()); @@ -31,8 +36,6 @@ namespace NzbDrone.Common.Serializer serializerSettings.Converters.Add(new STJTimeSpanConverter()); serializerSettings.Converters.Add(new STJUtcConverter()); serializerSettings.Converters.Add(new DictionaryStringObjectConverter()); - - return serializerSettings; } public static T Deserialize(string json) @@ -85,5 +88,15 @@ namespace NzbDrone.Common.Serializer JsonSerializer.Serialize(writer, (object)model, options); } } + + public static Task SerializeAsync(TModel model, Stream outputStream, JsonSerializerOptions options = null) + { + if (options == null) + { + options = SerializerSettings; + } + + return JsonSerializer.SerializeAsync(outputStream, (object)model, options); + } } } diff --git a/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs b/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs index 7c3cee291..595364f85 100644 --- a/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs +++ b/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs @@ -2,7 +2,6 @@ namespace NzbDrone.Core.IndexerSearch { public class NewznabRequest { - public int id { get; set; } public string t { get; set; } public string q { get; set; } public string cat { get; set; } diff --git a/src/NzbDrone.Host/AccessControl/FirewallAdapter.cs b/src/NzbDrone.Host/AccessControl/FirewallAdapter.cs index 7e67be8cf..db6a4ccd3 100644 --- a/src/NzbDrone.Host/AccessControl/FirewallAdapter.cs +++ b/src/NzbDrone.Host/AccessControl/FirewallAdapter.cs @@ -5,7 +5,7 @@ using NLog; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration; -namespace Prowlarr.Host.AccessControl +namespace NzbDrone.Host.AccessControl { public interface IFirewallAdapter { diff --git a/src/NzbDrone.Host/MainAppContainerBuilder.cs b/src/NzbDrone.Host/MainAppContainerBuilder.cs index 78831d23e..161ad5a5d 100644 --- a/src/NzbDrone.Host/MainAppContainerBuilder.cs +++ b/src/NzbDrone.Host/MainAppContainerBuilder.cs @@ -1,9 +1,7 @@ using System.Collections.Generic; -using Nancy.Bootstrapper; using NzbDrone.Common.Composition; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.SignalR; -using Prowlarr.Http; namespace Prowlarr.Host { @@ -28,8 +26,6 @@ namespace Prowlarr.Host { AutoRegisterImplementations(); - Container.Register(); - if (OsInfo.IsWindows) { Container.Register(); diff --git a/src/NzbDrone.Host/Prowlarr.Host.csproj b/src/NzbDrone.Host/Prowlarr.Host.csproj index 2af152ae0..2b84c4742 100644 --- a/src/NzbDrone.Host/Prowlarr.Host.csproj +++ b/src/NzbDrone.Host/Prowlarr.Host.csproj @@ -4,7 +4,6 @@ Library - diff --git a/src/NzbDrone.Host/WebHost/AccessControl/RemoteAccessAdapter.cs b/src/NzbDrone.Host/WebHost/AccessControl/RemoteAccessAdapter.cs index 23605bd5d..2f3ff0217 100644 --- a/src/NzbDrone.Host/WebHost/AccessControl/RemoteAccessAdapter.cs +++ b/src/NzbDrone.Host/WebHost/AccessControl/RemoteAccessAdapter.cs @@ -1,4 +1,5 @@ using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Host.AccessControl; namespace Prowlarr.Host.AccessControl { diff --git a/src/NzbDrone.Host/WebHost/ControllerActivator.cs b/src/NzbDrone.Host/WebHost/ControllerActivator.cs new file mode 100644 index 000000000..ad90106fa --- /dev/null +++ b/src/NzbDrone.Host/WebHost/ControllerActivator.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using NzbDrone.Common.Composition; + +namespace NzbDrone.Host +{ + public class ControllerActivator : IControllerActivator + { + private readonly IContainer _container; + + public ControllerActivator(IContainer container) + { + _container = container; + } + + public object Create(ControllerContext context) + { + return _container.Resolve(context.ActionDescriptor.ControllerTypeInfo.AsType()); + } + + public void Release(ControllerContext context, object controller) + { + // Nothing to do + } + } +} diff --git a/src/NzbDrone.Host/WebHost/Middleware/IAspNetCoreMiddleware.cs b/src/NzbDrone.Host/WebHost/Middleware/IAspNetCoreMiddleware.cs deleted file mode 100644 index 6182d8711..000000000 --- a/src/NzbDrone.Host/WebHost/Middleware/IAspNetCoreMiddleware.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.AspNetCore.Builder; - -namespace Prowlarr.Host.Middleware -{ - public interface IAspNetCoreMiddleware - { - int Order { get; } - void Attach(IApplicationBuilder appBuilder); - } -} diff --git a/src/NzbDrone.Host/WebHost/Middleware/NancyMiddleware.cs b/src/NzbDrone.Host/WebHost/Middleware/NancyMiddleware.cs deleted file mode 100644 index 4b26889cf..000000000 --- a/src/NzbDrone.Host/WebHost/Middleware/NancyMiddleware.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Nancy.Bootstrapper; -using Nancy.Owin; - -namespace Prowlarr.Host.Middleware -{ - public class NancyMiddleware : IAspNetCoreMiddleware - { - private readonly INancyBootstrapper _nancyBootstrapper; - - public int Order => 2; - - public NancyMiddleware(INancyBootstrapper nancyBootstrapper) - { - _nancyBootstrapper = nancyBootstrapper; - } - - public void Attach(IApplicationBuilder appBuilder) - { - var options = new NancyOptions - { - Bootstrapper = _nancyBootstrapper, - PerformPassThrough = context => context.Request.Path.StartsWith("/signalr") - }; - - appBuilder.UseOwin(x => x.UseNancy(options)); - } - } -} diff --git a/src/NzbDrone.Host/WebHost/Middleware/SignalRMiddleware.cs b/src/NzbDrone.Host/WebHost/Middleware/SignalRMiddleware.cs deleted file mode 100644 index 0c6218c5c..000000000 --- a/src/NzbDrone.Host/WebHost/Middleware/SignalRMiddleware.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.DependencyInjection; -using NLog; -using NzbDrone.Common.Composition; -using NzbDrone.Core.Configuration; -using NzbDrone.SignalR; - -namespace Prowlarr.Host.Middleware -{ - public class SignalRMiddleware : IAspNetCoreMiddleware - { - private readonly IContainer _container; - private readonly Logger _logger; - private static string API_KEY; - private static string URL_BASE; - public int Order => 1; - - public SignalRMiddleware(IContainer container, - IConfigFileProvider configFileProvider, - Logger logger) - { - _container = container; - _logger = logger; - API_KEY = configFileProvider.ApiKey; - URL_BASE = configFileProvider.UrlBase; - } - - public void Attach(IApplicationBuilder appBuilder) - { - appBuilder.UseWebSockets(); - - appBuilder.Use(async (context, next) => - { - if (context.Request.Path.StartsWithSegments("/signalr") && - !context.Request.Path.Value.EndsWith("/negotiate")) - { - if (!context.Request.Query.ContainsKey("access_token") || - context.Request.Query["access_token"] != API_KEY) - { - context.Response.StatusCode = 401; - await context.Response.WriteAsync("Unauthorized"); - return; - } - } - - try - { - await next(); - } - catch (OperationCanceledException e) - { - // Demote the exception to trace logging so users don't worry (as much). - _logger.Trace(e); - } - }); - - appBuilder.UseEndpoints(x => - { - x.MapHub(URL_BASE + "/signalr/messages"); - }); - - // This is a side effect of haing multiple IoC containers, TinyIoC and whatever - // Kestrel/SignalR is using. Ideally we'd have one IoC container, but that's non-trivial with TinyIoC - // TODO: Use a single IoC container if supported for TinyIoC or if we switch to another system (ie Autofac). - var hubContext = appBuilder.ApplicationServices.GetService>(); - _container.Register(hubContext); - } - } -} diff --git a/src/NzbDrone.Host/WebHost/WebHostController.cs b/src/NzbDrone.Host/WebHost/WebHostController.cs index d0bc17b98..595e09ca7 100644 --- a/src/NzbDrone.Host/WebHost/WebHostController.cs +++ b/src/NzbDrone.Host/WebHost/WebHostController.cs @@ -4,42 +4,60 @@ using System.IO; using System.Linq; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Internal; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NLog; using NLog.Extensions.Logging; +using NzbDrone.Common.Composition; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Exceptions; using NzbDrone.Common.Extensions; using NzbDrone.Common.Serializer; using NzbDrone.Core.Configuration; -using Prowlarr.Host.AccessControl; -using Prowlarr.Host.Middleware; +using NzbDrone.Host; +using NzbDrone.Host.AccessControl; +using NzbDrone.SignalR; +using Prowlarr.Api.V1.System; +using Prowlarr.Http; +using Prowlarr.Http.Authentication; +using Prowlarr.Http.ErrorManagement; +using Prowlarr.Http.Frontend; +using Prowlarr.Http.Middleware; using LogLevel = Microsoft.Extensions.Logging.LogLevel; namespace Prowlarr.Host { public class WebHostController : IHostController { + private readonly IContainer _container; private readonly IRuntimeInfo _runtimeInfo; private readonly IConfigFileProvider _configFileProvider; private readonly IFirewallAdapter _firewallAdapter; - private readonly IEnumerable _middlewares; + private readonly ProwlarrErrorPipeline _errorHandler; private readonly Logger _logger; private IWebHost _host; - public WebHostController(IRuntimeInfo runtimeInfo, + public WebHostController(IContainer container, + IRuntimeInfo runtimeInfo, IConfigFileProvider configFileProvider, IFirewallAdapter firewallAdapter, - IEnumerable middlewares, + ProwlarrErrorPipeline errorHandler, Logger logger) { + _container = container; _runtimeInfo = runtimeInfo; _configFileProvider = configFileProvider; _firewallAdapter = firewallAdapter; - _middlewares = middlewares; + _errorHandler = errorHandler; _logger = logger; } @@ -105,24 +123,125 @@ namespace Prowlarr.Host }) .ConfigureServices(services => { + // So that we can resolve containers with our TinyIoC services + services.AddSingleton(_container); + services.AddSingleton(); + + // Bits used in our custom middleware + services.AddSingleton(_container.Resolve()); + services.AddSingleton(_container.Resolve()); + + // Used in authentication + services.AddSingleton(_container.Resolve()); + + services.AddRouting(options => options.LowercaseUrls = true); + + services.AddResponseCompression(); + + services.AddCors(options => + { + options.AddPolicy(VersionedApiControllerAttribute.API_CORS_POLICY, + builder => + builder.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader()); + + options.AddPolicy("AllowGet", + builder => + builder.AllowAnyOrigin() + .WithMethods("GET", "OPTIONS") + .AllowAnyHeader()); + }); + + services + .AddControllers(options => + { + options.ReturnHttpNotAcceptable = true; + }) + .AddApplicationPart(typeof(SystemController).Assembly) + .AddApplicationPart(typeof(StaticResourceController).Assembly) + .AddJsonOptions(options => + { + STJson.ApplySerializerSettings(options.JsonSerializerOptions); + }); + services .AddSignalR() .AddJsonProtocol(options => { options.PayloadSerializerOptions = STJson.GetSerializerSettings(); }); + + services.AddAuthorization(options => + { + options.AddPolicy("UI", policy => + { + policy.AuthenticationSchemes.Add(_configFileProvider.AuthenticationMethod.ToString()); + policy.RequireAuthenticatedUser(); + }); + + options.AddPolicy("SignalR", policy => + { + policy.AuthenticationSchemes.Add("SignalR"); + policy.RequireAuthenticatedUser(); + }); + + // Require auth on everything except those marked [AllowAnonymous] + options.FallbackPolicy = new AuthorizationPolicyBuilder("API") + .RequireAuthenticatedUser() + .Build(); + }); + + services.AddAppAuthentication(_configFileProvider); }) .Configure(app => { + app.UseMiddleware(); + app.UsePathBase(new PathString(_configFileProvider.UrlBase)); + app.UseExceptionHandler(new ExceptionHandlerOptions + { + AllowStatusCode404Response = true, + ExceptionHandler = _errorHandler.HandleException + }); + app.UseRouting(); + app.UseCors(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseResponseCompression(); app.Properties["host.AppName"] = BuildInfo.AppName; - app.UsePathBase(_configFileProvider.UrlBase); - foreach (var middleWare in _middlewares.OrderBy(c => c.Order)) + app.UseMiddleware(); + app.UseMiddleware(_configFileProvider.UrlBase); + app.UseMiddleware(); + app.UseMiddleware(); + + app.Use((context, next) => { - _logger.Debug("Attaching {0} to host", middleWare.GetType().Name); - middleWare.Attach(app); - } + if (context.Request.Path.StartsWithSegments("/api/v1/command", StringComparison.CurrentCultureIgnoreCase)) + { + context.Request.EnableBuffering(); + } + + return next(); + }); + + app.UseWebSockets(); + + app.UseEndpoints(x => + { + x.MapHub("/signalr/messages").RequireAuthorization("SignalR"); + x.MapControllers(); + }); + + // This is a side effect of haing multiple IoC containers, TinyIoC and whatever + // Kestrel/SignalR is using. Ideally we'd have one IoC container, but that's non-trivial with TinyIoC + // TODO: Use a single IoC container if supported for TinyIoC or if we switch to another system (ie Autofac). + _container.Register(app.ApplicationServices); + _container.Register(app.ApplicationServices.GetService>()); + _container.Register(app.ApplicationServices.GetService()); + _container.Register(app.ApplicationServices.GetService()); + _container.Register(app.ApplicationServices.GetService()); }) .UseContentRoot(Directory.GetCurrentDirectory()) .Build(); diff --git a/src/NzbDrone.Integration.Test/Client/ClientBase.cs b/src/NzbDrone.Integration.Test/Client/ClientBase.cs index ad8048eca..57a5e2d88 100644 --- a/src/NzbDrone.Integration.Test/Client/ClientBase.cs +++ b/src/NzbDrone.Integration.Test/Client/ClientBase.cs @@ -51,11 +51,7 @@ namespace NzbDrone.Integration.Test.Client throw response.ErrorException; } - // cache control header gets reordered on net core - ((string)response.Headers.Single(c => c.Name == "Cache-Control").Value).Split(',').Select(x => x.Trim()) - .Should().BeEquivalentTo("no-store, must-revalidate, no-cache, max-age=0".Split(',').Select(x => x.Trim())); - response.Headers.Single(c => c.Name == "Pragma").Value.Should().Be("no-cache"); - response.Headers.Single(c => c.Name == "Expires").Value.Should().Be("0"); + AssertDisableCache(response); response.ErrorMessage.Should().BeNullOrWhiteSpace(); @@ -71,6 +67,16 @@ namespace NzbDrone.Integration.Test.Client return Json.Deserialize(content); } + + private static void AssertDisableCache(IRestResponse response) + { + // cache control header gets reordered on net core + var headers = response.Headers; + ((string)headers.Single(c => c.Name == "Cache-Control").Value).Split(',').Select(x => x.Trim()) + .Should().BeEquivalentTo("no-store, no-cache".Split(',').Select(x => x.Trim())); + headers.Single(c => c.Name == "Pragma").Value.Should().Be("no-cache"); + headers.Single(c => c.Name == "Expires").Value.Should().Be("-1"); + } } public class ClientBase : ClientBase diff --git a/src/NzbDrone.Integration.Test/CorsFixture.cs b/src/NzbDrone.Integration.Test/CorsFixture.cs index 696d3f2d9..1c00ad5d1 100644 --- a/src/NzbDrone.Integration.Test/CorsFixture.cs +++ b/src/NzbDrone.Integration.Test/CorsFixture.cs @@ -11,6 +11,7 @@ namespace NzbDrone.Integration.Test private RestRequest BuildGet(string route = "indexer") { var request = new RestRequest(route, Method.GET); + request.AddHeader("Origin", "http://a.different.domain"); request.AddHeader(AccessControlHeaders.RequestMethod, "POST"); return request; @@ -19,6 +20,8 @@ namespace NzbDrone.Integration.Test private RestRequest BuildOptions(string route = "indexer") { var request = new RestRequest(route, Method.OPTIONS); + request.AddHeader("Origin", "http://a.different.domain"); + request.AddHeader(AccessControlHeaders.RequestMethod, "POST"); return request; } diff --git a/src/NzbDrone.Integration.Test/GenericApiFixture.cs b/src/NzbDrone.Integration.Test/GenericApiFixture.cs index 63c6e9dbc..a42a23055 100644 --- a/src/NzbDrone.Integration.Test/GenericApiFixture.cs +++ b/src/NzbDrone.Integration.Test/GenericApiFixture.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using FluentAssertions; using NUnit.Framework; using RestSharp; @@ -33,8 +33,6 @@ namespace NzbDrone.Integration.Test [TestCase("application/junk")] public void should_get_unacceptable_with_accept_header(string header) { - IgnoreOnMonoVersions("5.12", "5.14"); - var request = new RestRequest("system/status") { RequestFormat = DataFormat.None diff --git a/src/NzbDrone.Integration.Test/HttpLogFixture.cs b/src/NzbDrone.Integration.Test/HttpLogFixture.cs index 537836329..4995bf9a8 100644 --- a/src/NzbDrone.Integration.Test/HttpLogFixture.cs +++ b/src/NzbDrone.Integration.Test/HttpLogFixture.cs @@ -11,8 +11,6 @@ namespace NzbDrone.Integration.Test [Test] public void should_log_on_error() { - IgnoreOnMonoVersions("5.12", "5.14"); - var config = HostConfig.Get(1); config.LogLevel = "Trace"; HostConfig.Put(config); diff --git a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs index 43a245ef9..ed21f89e7 100644 --- a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs +++ b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs @@ -131,22 +131,6 @@ namespace NzbDrone.Integration.Test } } - protected void IgnoreOnMonoVersions(params string[] version_strings) - { - if (!PlatformInfo.IsMono) - { - return; - } - - var current = PlatformInfo.GetVersion(); - var versions = version_strings.Select(x => new Version(x)).ToList(); - - if (versions.Any(x => x.Major == current.Major && x.Minor == current.Minor)) - { - throw new IgnoreException($"Ignored on mono {PlatformInfo.GetVersion()}"); - } - } - public string GetTempDirectory(params string[] args) { var path = Path.Combine(TempDirectory, Path.Combine(args)); diff --git a/src/NzbDrone.Mono.Test/DiskProviderTests/SymlinkResolverFixture.cs b/src/NzbDrone.Mono.Test/DiskProviderTests/SymlinkResolverFixture.cs index d6d783556..ac7fadd60 100644 --- a/src/NzbDrone.Mono.Test/DiskProviderTests/SymlinkResolverFixture.cs +++ b/src/NzbDrone.Mono.Test/DiskProviderTests/SymlinkResolverFixture.cs @@ -1,11 +1,5 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.IO; using FluentAssertions; -using Mono.Posix; using Mono.Unix; using NUnit.Framework; using NzbDrone.Mono.Disk; diff --git a/src/Prowlarr.Api.V1/Applications/ApplicationModule.cs b/src/Prowlarr.Api.V1/Applications/ApplicationController.cs similarity index 52% rename from src/Prowlarr.Api.V1/Applications/ApplicationModule.cs rename to src/Prowlarr.Api.V1/Applications/ApplicationController.cs index 85c2560e9..b7b63b46e 100644 --- a/src/Prowlarr.Api.V1/Applications/ApplicationModule.cs +++ b/src/Prowlarr.Api.V1/Applications/ApplicationController.cs @@ -1,12 +1,14 @@ using NzbDrone.Core.Applications; +using Prowlarr.Http; namespace Prowlarr.Api.V1.Application { - public class ApplicationModule : ProviderModuleBase + [V1ApiController("applications")] + public class ApplicationController : ProviderControllerBase { public static readonly ApplicationResourceMapper ResourceMapper = new ApplicationResourceMapper(); - public ApplicationModule(ApplicationFactory applicationsFactory) + public ApplicationController(ApplicationFactory applicationsFactory) : base(applicationsFactory, "applications", ResourceMapper) { } diff --git a/src/Prowlarr.Api.V1/Commands/CommandModule.cs b/src/Prowlarr.Api.V1/Commands/CommandController.cs similarity index 77% rename from src/Prowlarr.Api.V1/Commands/CommandModule.cs rename to src/Prowlarr.Api.V1/Commands/CommandController.cs index da1cfbf8f..6e8a1bc3d 100644 --- a/src/Prowlarr.Api.V1/Commands/CommandModule.cs +++ b/src/Prowlarr.Api.V1/Commands/CommandController.cs @@ -1,27 +1,32 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common; +using NzbDrone.Common.Serializer; using NzbDrone.Common.TPL; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.ProgressMessaging; +using NzbDrone.Http.REST.Attributes; using NzbDrone.SignalR; using Prowlarr.Http; -using Prowlarr.Http.Extensions; +using Prowlarr.Http.REST; using Prowlarr.Http.Validation; namespace Prowlarr.Api.V1.Commands { - public class CommandModule : ProwlarrRestModuleWithSignalR, IHandle + [V1ApiController] + public class CommandController : RestControllerWithSignalR, IHandle { private readonly IManageCommandQueue _commandQueueManager; private readonly IServiceFactory _serviceFactory; private readonly Debouncer _debouncer; private readonly Dictionary _pendingUpdates; - public CommandModule(IManageCommandQueue commandQueueManager, + public CommandController(IManageCommandQueue commandQueueManager, IBroadcastSignalRMessage signalRBroadcaster, IServiceFactory serviceFactory) : base(signalRBroadcaster) @@ -32,45 +37,49 @@ namespace Prowlarr.Api.V1.Commands _debouncer = new Debouncer(SendUpdates, TimeSpan.FromSeconds(0.1)); _pendingUpdates = new Dictionary(); - GetResourceById = GetCommand; - CreateResource = StartCommand; - GetResourceAll = GetStartedCommands; - DeleteResource = CancelCommand; - PostValidator.RuleFor(c => c.Name).NotBlank(); } - private CommandResource GetCommand(int id) + public override CommandResource GetResourceById(int id) { return _commandQueueManager.Get(id).ToResource(); } - private int StartCommand(CommandResource commandResource) + [RestPostById] + public ActionResult StartCommand(CommandResource commandResource) { var commandType = _serviceFactory.GetImplementations(typeof(Command)) .Single(c => c.Name.Replace("Command", "") .Equals(commandResource.Name, StringComparison.InvariantCultureIgnoreCase)); - dynamic command = Request.Body.FromJson(commandType); + Request.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(Request.Body); + var body = reader.ReadToEnd(); + + dynamic command = STJson.Deserialize(body, commandType); + command.Trigger = CommandTrigger.Manual; command.SuppressMessages = !command.SendUpdatesToClient; command.SendUpdatesToClient = true; var trackedCommand = _commandQueueManager.Push(command, CommandPriority.Normal, CommandTrigger.Manual); - return trackedCommand.Id; + return Created(trackedCommand.Id); } - private List GetStartedCommands() + [HttpGet] + public List GetStartedCommands() { return _commandQueueManager.All().ToResource(); } - private void CancelCommand(int id) + [RestDeleteById] + public void CancelCommand(int id) { _commandQueueManager.Cancel(id); } + [NonAction] public void Handle(CommandUpdatedEvent message) { if (message.Command.Body.SendUpdatesToClient) diff --git a/src/Prowlarr.Api.V1/Config/ConfigController.cs b/src/Prowlarr.Api.V1/Config/ConfigController.cs new file mode 100644 index 000000000..e56f5e3e5 --- /dev/null +++ b/src/Prowlarr.Api.V1/Config/ConfigController.cs @@ -0,0 +1,48 @@ +using System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Configuration; +using NzbDrone.Http.REST.Attributes; +using Prowlarr.Http.REST; + +namespace Prowlarr.Api.V1.Config +{ + public abstract class ConfigController : RestController + where TResource : RestResource, new() + { + protected readonly IConfigService _configService; + + protected ConfigController(IConfigService configService) + { + _configService = configService; + } + + public override TResource GetResourceById(int id) + { + return GetConfig(); + } + + [HttpGet] + public TResource GetConfig() + { + var resource = ToResource(_configService); + resource.Id = 1; + + return resource; + } + + [RestPutById] + public virtual ActionResult SaveConfig(TResource resource) + { + var dictionary = resource.GetType() + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); + + _configService.SaveConfigDictionary(dictionary); + + return Accepted(resource.Id); + } + + protected abstract TResource ToResource(IConfigService model); + } +} diff --git a/src/Prowlarr.Api.V1/Config/DevelopmentConfigController.cs b/src/Prowlarr.Api.V1/Config/DevelopmentConfigController.cs new file mode 100644 index 000000000..63db0f365 --- /dev/null +++ b/src/Prowlarr.Api.V1/Config/DevelopmentConfigController.cs @@ -0,0 +1,39 @@ +using System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Configuration; +using Prowlarr.Api.V1.Tags; +using Prowlarr.Http; + +namespace Prowlarr.Api.V1.Config +{ + [V1ApiController("config/development")] + public class DevelopmentConfigController : ConfigController + { + private readonly IConfigFileProvider _configFileProvider; + + public DevelopmentConfigController(IConfigFileProvider configFileProvider, + IConfigService configService) + : base(configService) + { + _configFileProvider = configFileProvider; + } + + public override ActionResult SaveConfig(DevelopmentConfigResource resource) + { + var dictionary = resource.GetType() + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); + + _configFileProvider.SaveConfigDictionary(dictionary); + _configService.SaveConfigDictionary(dictionary); + + return Accepted(resource.Id); + } + + protected override DevelopmentConfigResource ToResource(IConfigService model) + { + return DevelopmentConfigResourceMapper.ToResource(_configFileProvider, model); + } + } +} diff --git a/src/Prowlarr.Api.V1/Config/DevelopmentConfigModule.cs b/src/Prowlarr.Api.V1/Config/DevelopmentConfigModule.cs deleted file mode 100644 index b0612933c..000000000 --- a/src/Prowlarr.Api.V1/Config/DevelopmentConfigModule.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Linq; -using System.Reflection; -using NzbDrone.Core.Authentication; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Validation.Paths; -using Prowlarr.Http; - -namespace Prowlarr.Api.V1.Config -{ - public class DevelopmentConfigModule : ProwlarrRestModule - { - private readonly IConfigFileProvider _configFileProvider; - private readonly IConfigService _configService; - - public DevelopmentConfigModule(IConfigFileProvider configFileProvider, - IConfigService configService) - : base("/config/development") - { - _configFileProvider = configFileProvider; - _configService = configService; - - GetResourceSingle = GetDevelopmentConfig; - GetResourceById = GetDevelopmentConfig; - UpdateResource = SaveDevelopmentConfig; - } - - private DevelopmentConfigResource GetDevelopmentConfig() - { - var resource = DevelopmentConfigResourceMapper.ToResource(_configFileProvider, _configService); - resource.Id = 1; - - return resource; - } - - private DevelopmentConfigResource GetDevelopmentConfig(int id) - { - return GetDevelopmentConfig(); - } - - private void SaveDevelopmentConfig(DevelopmentConfigResource resource) - { - var dictionary = resource.GetType() - .GetProperties(BindingFlags.Instance | BindingFlags.Public) - .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); - - _configFileProvider.SaveConfigDictionary(dictionary); - _configService.SaveConfigDictionary(dictionary); - } - } -} diff --git a/src/Prowlarr.Api.V1/Config/DownloadClientConfigModule.cs b/src/Prowlarr.Api.V1/Config/DownloadClientConfigController.cs similarity index 57% rename from src/Prowlarr.Api.V1/Config/DownloadClientConfigModule.cs rename to src/Prowlarr.Api.V1/Config/DownloadClientConfigController.cs index 5db97ef82..dae88820f 100644 --- a/src/Prowlarr.Api.V1/Config/DownloadClientConfigModule.cs +++ b/src/Prowlarr.Api.V1/Config/DownloadClientConfigController.cs @@ -1,10 +1,12 @@ using NzbDrone.Core.Configuration; +using Prowlarr.Http; namespace Prowlarr.Api.V1.Config { - public class DownloadClientConfigModule : ProwlarrConfigModule + [V1ApiController("config/downloadclient")] + public class DownloadClientConfigController : ConfigController { - public DownloadClientConfigModule(IConfigService configService) + public DownloadClientConfigController(IConfigService configService) : base(configService) { } diff --git a/src/Prowlarr.Api.V1/Config/HostConfigModule.cs b/src/Prowlarr.Api.V1/Config/HostConfigController.cs similarity index 83% rename from src/Prowlarr.Api.V1/Config/HostConfigModule.cs rename to src/Prowlarr.Api.V1/Config/HostConfigController.cs index 8d9079dda..26fa1acc4 100644 --- a/src/Prowlarr.Api.V1/Config/HostConfigModule.cs +++ b/src/Prowlarr.Api.V1/Config/HostConfigController.cs @@ -3,36 +3,35 @@ using System.Linq; using System.Reflection; using System.Security.Cryptography.X509Certificates; using FluentValidation; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; using NzbDrone.Core.Update; using NzbDrone.Core.Validation; using NzbDrone.Core.Validation.Paths; +using NzbDrone.Http.REST.Attributes; using Prowlarr.Http; +using Prowlarr.Http.REST; namespace Prowlarr.Api.V1.Config { - public class HostConfigModule : ProwlarrRestModule + [V1ApiController("config/host")] + public class HostConfigController : RestController { private readonly IConfigFileProvider _configFileProvider; private readonly IConfigService _configService; private readonly IUserService _userService; - public HostConfigModule(IConfigFileProvider configFileProvider, - IConfigService configService, - IUserService userService, - FileExistsValidator fileExistsValidator) - : base("/config/host") + public HostConfigController(IConfigFileProvider configFileProvider, + IConfigService configService, + IUserService userService, + FileExistsValidator fileExistsValidator) { _configFileProvider = configFileProvider; _configService = configService; _userService = userService; - GetResourceSingle = GetHostConfig; - GetResourceById = GetHostConfig; - UpdateResource = SaveHostConfig; - SharedValidator.RuleFor(c => c.BindAddress) .ValidIp4Address() .NotListenAllIp4Address() @@ -79,7 +78,13 @@ namespace Prowlarr.Api.V1.Config return cert != null; } - private HostConfigResource GetHostConfig() + public override HostConfigResource GetResourceById(int id) + { + return GetHostConfig(); + } + + [HttpGet] + public HostConfigResource GetHostConfig() { var resource = HostConfigResourceMapper.ToResource(_configFileProvider, _configService); resource.Id = 1; @@ -94,12 +99,8 @@ namespace Prowlarr.Api.V1.Config return resource; } - private HostConfigResource GetHostConfig(int id) - { - return GetHostConfig(); - } - - private void SaveHostConfig(HostConfigResource resource) + [RestPutById] + public ActionResult SaveHostConfig(HostConfigResource resource) { var dictionary = resource.GetType() .GetProperties(BindingFlags.Instance | BindingFlags.Public) @@ -112,6 +113,8 @@ namespace Prowlarr.Api.V1.Config { _userService.Upsert(resource.Username, resource.Password); } + + return Accepted(resource.Id); } } } diff --git a/src/Prowlarr.Api.V1/Config/IndexerConfigModule.cs b/src/Prowlarr.Api.V1/Config/IndexerConfigController.cs similarity index 80% rename from src/Prowlarr.Api.V1/Config/IndexerConfigModule.cs rename to src/Prowlarr.Api.V1/Config/IndexerConfigController.cs index 384fe97ec..1c3dfd0a1 100644 --- a/src/Prowlarr.Api.V1/Config/IndexerConfigModule.cs +++ b/src/Prowlarr.Api.V1/Config/IndexerConfigController.cs @@ -1,12 +1,14 @@ using FluentValidation; using NzbDrone.Core.Configuration; +using Prowlarr.Http; using Prowlarr.Http.Validation; namespace Prowlarr.Api.V1.Config { - public class IndexerConfigModule : ProwlarrConfigModule + [V1ApiController("config/indexer")] + public class IndexerConfigController : ConfigController { - public IndexerConfigModule(IConfigService configService) + public IndexerConfigController(IConfigService configService) : base(configService) { SharedValidator.RuleFor(c => c.MinimumAge) diff --git a/src/Prowlarr.Api.V1/Config/MediaManagementConfigModule.cs b/src/Prowlarr.Api.V1/Config/MediaManagementConfigController.cs similarity index 75% rename from src/Prowlarr.Api.V1/Config/MediaManagementConfigModule.cs rename to src/Prowlarr.Api.V1/Config/MediaManagementConfigController.cs index 9f63461b2..ca120203b 100644 --- a/src/Prowlarr.Api.V1/Config/MediaManagementConfigModule.cs +++ b/src/Prowlarr.Api.V1/Config/MediaManagementConfigController.cs @@ -3,12 +3,14 @@ using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration; using NzbDrone.Core.Validation; using NzbDrone.Core.Validation.Paths; +using Prowlarr.Http; namespace Prowlarr.Api.V1.Config { - public class MediaManagementConfigModule : ProwlarrConfigModule + [V1ApiController("config/mediamanagement")] + public class MediaManagementConfigController : ConfigController { - public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FileChmodValidator fileChmodValidator) + public MediaManagementConfigController(IConfigService configService, PathExistsValidator pathExistsValidator, FileChmodValidator fileChmodValidator) : base(configService) { SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0); diff --git a/src/Prowlarr.Api.V1/Config/ProwlarrConfigModule.cs b/src/Prowlarr.Api.V1/Config/ProwlarrConfigModule.cs deleted file mode 100644 index 8ed822ada..000000000 --- a/src/Prowlarr.Api.V1/Config/ProwlarrConfigModule.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Linq; -using System.Reflection; -using NzbDrone.Core.Configuration; -using Prowlarr.Http; -using Prowlarr.Http.REST; - -namespace Prowlarr.Api.V1.Config -{ - public abstract class ProwlarrConfigModule : ProwlarrRestModule - where TResource : RestResource, new() - { - private readonly IConfigService _configService; - - protected ProwlarrConfigModule(IConfigService configService) - : this(new TResource().ResourceName.Replace("config", ""), configService) - { - } - - protected ProwlarrConfigModule(string resource, IConfigService configService) - : base("config/" + resource.Trim('/')) - { - _configService = configService; - - GetResourceSingle = GetConfig; - GetResourceById = GetConfig; - UpdateResource = SaveConfig; - } - - private TResource GetConfig() - { - var resource = ToResource(_configService); - resource.Id = 1; - - return resource; - } - - protected abstract TResource ToResource(IConfigService model); - - private TResource GetConfig(int id) - { - return GetConfig(); - } - - private void SaveConfig(TResource resource) - { - var dictionary = resource.GetType() - .GetProperties(BindingFlags.Instance | BindingFlags.Public) - .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); - - _configService.SaveConfigDictionary(dictionary); - } - } -} diff --git a/src/Prowlarr.Api.V1/Config/UiConfigModule.cs b/src/Prowlarr.Api.V1/Config/UiConfigController.cs similarity index 62% rename from src/Prowlarr.Api.V1/Config/UiConfigModule.cs rename to src/Prowlarr.Api.V1/Config/UiConfigController.cs index df322e9d3..436b5717f 100644 --- a/src/Prowlarr.Api.V1/Config/UiConfigModule.cs +++ b/src/Prowlarr.Api.V1/Config/UiConfigController.cs @@ -1,10 +1,12 @@ using NzbDrone.Core.Configuration; +using Prowlarr.Http; namespace Prowlarr.Api.V1.Config { - public class UiConfigModule : ProwlarrConfigModule + [V1ApiController("config/ui")] + public class UiConfigController : ConfigController { - public UiConfigModule(IConfigService configService) + public UiConfigController(IConfigService configService) : base(configService) { } diff --git a/src/Prowlarr.Api.V1/CustomFilters/CustomFilterController.cs b/src/Prowlarr.Api.V1/CustomFilters/CustomFilterController.cs new file mode 100644 index 000000000..c60318885 --- /dev/null +++ b/src/Prowlarr.Api.V1/CustomFilters/CustomFilterController.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.CustomFilters; +using NzbDrone.Http.REST.Attributes; +using Prowlarr.Http; +using Prowlarr.Http.REST; + +namespace Prowlarr.Api.V1.CustomFilters +{ + [V1ApiController] + public class CustomFilterController : RestController + { + private readonly ICustomFilterService _customFilterService; + + public CustomFilterController(ICustomFilterService customFilterService) + { + _customFilterService = customFilterService; + } + + public override CustomFilterResource GetResourceById(int id) + { + return _customFilterService.Get(id).ToResource(); + } + + [HttpGet] + public List GetCustomFilters() + { + return _customFilterService.All().ToResource(); + } + + [RestPostById] + public ActionResult AddCustomFilter(CustomFilterResource resource) + { + var customFilter = _customFilterService.Add(resource.ToModel()); + + return Created(customFilter.Id); + } + + [RestPutById] + public ActionResult UpdateCustomFilter(CustomFilterResource resource) + { + _customFilterService.Update(resource.ToModel()); + return Accepted(resource.Id); + } + + [RestDeleteById] + public void DeleteCustomResource(int id) + { + _customFilterService.Delete(id); + } + } +} diff --git a/src/Prowlarr.Api.V1/CustomFilters/CustomFilterModule.cs b/src/Prowlarr.Api.V1/CustomFilters/CustomFilterModule.cs deleted file mode 100644 index f02cd2471..000000000 --- a/src/Prowlarr.Api.V1/CustomFilters/CustomFilterModule.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.CustomFilters; -using Prowlarr.Http; - -namespace Prowlarr.Api.V1.CustomFilters -{ - public class CustomFilterModule : ProwlarrRestModule - { - private readonly ICustomFilterService _customFilterService; - - public CustomFilterModule(ICustomFilterService customFilterService) - { - _customFilterService = customFilterService; - - GetResourceById = GetCustomFilter; - GetResourceAll = GetCustomFilters; - CreateResource = AddCustomFilter; - UpdateResource = UpdateCustomFilter; - DeleteResource = DeleteCustomResource; - } - - private CustomFilterResource GetCustomFilter(int id) - { - return _customFilterService.Get(id).ToResource(); - } - - private List GetCustomFilters() - { - return _customFilterService.All().ToResource(); - } - - private int AddCustomFilter(CustomFilterResource resource) - { - var customFilter = _customFilterService.Add(resource.ToModel()); - - return customFilter.Id; - } - - private void UpdateCustomFilter(CustomFilterResource resource) - { - _customFilterService.Update(resource.ToModel()); - } - - private void DeleteCustomResource(int id) - { - _customFilterService.Delete(id); - } - } -} diff --git a/src/Prowlarr.Api.V1/FileSystem/FileSystemController.cs b/src/Prowlarr.Api.V1/FileSystem/FileSystemController.cs new file mode 100644 index 000000000..610ec56a1 --- /dev/null +++ b/src/Prowlarr.Api.V1/FileSystem/FileSystemController.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Disk; +using Prowlarr.Http; + +namespace Prowlarr.Api.V1.FileSystem +{ + [V1ApiController] + public class FileSystemController : Controller + { + private readonly IFileSystemLookupService _fileSystemLookupService; + private readonly IDiskProvider _diskProvider; + + public FileSystemController(IFileSystemLookupService fileSystemLookupService, + IDiskProvider diskProvider) + { + _fileSystemLookupService = fileSystemLookupService; + _diskProvider = diskProvider; + } + + [HttpGet] + public IActionResult GetContents(string path, bool includeFiles = false, bool allowFoldersWithoutTrailingSlashes = false) + { + return Ok(_fileSystemLookupService.LookupContents(path, includeFiles, allowFoldersWithoutTrailingSlashes)); + } + + [HttpGet("type")] + public object GetEntityType(string path) + { + if (_diskProvider.FileExists(path)) + { + return new { type = "file" }; + } + + //Return folder even if it doesn't exist on disk to avoid leaking anything from the UI about the underlying system + return new { type = "folder" }; + } + } +} diff --git a/src/Prowlarr.Api.V1/FileSystem/FileSystemModule.cs b/src/Prowlarr.Api.V1/FileSystem/FileSystemModule.cs deleted file mode 100644 index 332533bb2..000000000 --- a/src/Prowlarr.Api.V1/FileSystem/FileSystemModule.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using Nancy; -using NzbDrone.Common.Disk; -using NzbDrone.Common.Extensions; -using Prowlarr.Http.Extensions; - -namespace Prowlarr.Api.V1.FileSystem -{ - public class FileSystemModule : ProwlarrV1Module - { - private readonly IFileSystemLookupService _fileSystemLookupService; - private readonly IDiskProvider _diskProvider; - - public FileSystemModule(IFileSystemLookupService fileSystemLookupService, - IDiskProvider diskProvider) - : base("/filesystem") - { - _fileSystemLookupService = fileSystemLookupService; - _diskProvider = diskProvider; - Get("/", x => GetContents()); - Get("/type", x => GetEntityType()); - } - - private object GetContents() - { - var pathQuery = Request.Query.path; - var includeFiles = Request.GetBooleanQueryParameter("includeFiles"); - var allowFoldersWithoutTrailingSlashes = Request.GetBooleanQueryParameter("allowFoldersWithoutTrailingSlashes"); - - return _fileSystemLookupService.LookupContents((string)pathQuery.Value, includeFiles, allowFoldersWithoutTrailingSlashes); - } - - private object GetEntityType() - { - var pathQuery = Request.Query.path; - var path = (string)pathQuery.Value; - - if (_diskProvider.FileExists(path)) - { - return new { type = "file" }; - } - - //Return folder even if it doesn't exist on disk to avoid leaking anything from the UI about the underlying system - return new { type = "folder" }; - } - } -} diff --git a/src/Prowlarr.Api.V1/Health/HealthModule.cs b/src/Prowlarr.Api.V1/Health/HealthController.cs similarity index 59% rename from src/Prowlarr.Api.V1/Health/HealthModule.cs rename to src/Prowlarr.Api.V1/Health/HealthController.cs index c0aee6f24..8f13ca598 100644 --- a/src/Prowlarr.Api.V1/Health/HealthModule.cs +++ b/src/Prowlarr.Api.V1/Health/HealthController.cs @@ -1,29 +1,39 @@ +using System; using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.HealthCheck; using NzbDrone.Core.Messaging.Events; using NzbDrone.SignalR; using Prowlarr.Http; +using Prowlarr.Http.REST; namespace Prowlarr.Api.V1.Health { - public class HealthModule : ProwlarrRestModuleWithSignalR, + [V1ApiController] + public class HealthController : RestControllerWithSignalR, IHandle { private readonly IHealthCheckService _healthCheckService; - public HealthModule(IBroadcastSignalRMessage signalRBroadcaster, IHealthCheckService healthCheckService) + public HealthController(IBroadcastSignalRMessage signalRBroadcaster, IHealthCheckService healthCheckService) : base(signalRBroadcaster) { _healthCheckService = healthCheckService; - GetResourceAll = GetHealth; } - private List GetHealth() + public override HealthResource GetResourceById(int id) + { + throw new NotImplementedException(); + } + + [HttpGet] + public List GetHealth() { return _healthCheckService.Results().ToResource(); } + [NonAction] public void Handle(HealthCheckCompleteEvent message) { BroadcastResourceChange(ModelAction.Sync); diff --git a/src/Prowlarr.Api.V1/History/HistoryController.cs b/src/Prowlarr.Api.V1/History/HistoryController.cs new file mode 100644 index 000000000..234f57b4f --- /dev/null +++ b/src/Prowlarr.Api.V1/History/HistoryController.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.History; +using Prowlarr.Http; +using Prowlarr.Http.Extensions; + +namespace Prowlarr.Api.V1.History +{ + [V1ApiController] + public class HistoryController : Controller + { + private readonly IHistoryService _historyService; + + public HistoryController(IHistoryService historyService) + { + _historyService = historyService; + } + + protected HistoryResource MapToResource(NzbDrone.Core.History.History model) + { + var resource = model.ToResource(); + + return resource; + } + + [HttpGet] + public PagingResource GetHistory() + { + var pagingResource = Request.ReadPagingResourceFromRequest(); + var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending); + + var eventTypeFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "eventType"); + var downloadIdFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "downloadId"); + + if (eventTypeFilter != null) + { + var filterValue = (HistoryEventType)Convert.ToInt32(eventTypeFilter.Value); + pagingSpec.FilterExpressions.Add(v => v.EventType == filterValue); + } + + if (downloadIdFilter != null) + { + var downloadId = downloadIdFilter.Value; + pagingSpec.FilterExpressions.Add(h => h.DownloadId == downloadId); + } + + return pagingSpec.ApplyToPage(_historyService.Paged, h => MapToResource(h)); + } + + [HttpGet("since")] + public List GetHistorySince(DateTime date, HistoryEventType? eventType = null) + { + return _historyService.Since(date, eventType).Select(h => MapToResource(h)).ToList(); + } + + [HttpGet("indexer")] + public List GetIndexerHistory(int indexerId, HistoryEventType? eventType = null) + { + return _historyService.GetByIndexerId(indexerId, eventType).Select(h => MapToResource(h)).ToList(); + } + } +} diff --git a/src/Prowlarr.Api.V1/History/HistoryModule.cs b/src/Prowlarr.Api.V1/History/HistoryModule.cs deleted file mode 100644 index 5a87bda70..000000000 --- a/src/Prowlarr.Api.V1/History/HistoryModule.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Nancy; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.History; -using Prowlarr.Http; -using Prowlarr.Http.Extensions; -using Prowlarr.Http.REST; - -namespace Prowlarr.Api.V1.History -{ - public class HistoryModule : ProwlarrRestModule - { - private readonly IHistoryService _historyService; - - public HistoryModule(IHistoryService historyService) - { - _historyService = historyService; - GetResourcePaged = GetHistory; - - Get("/since", x => GetHistorySince()); - Get("/indexer", x => GetIndexerHistory()); - } - - protected HistoryResource MapToResource(NzbDrone.Core.History.History model, bool includeMovie) - { - var resource = model.ToResource(); - - return resource; - } - - private PagingResource GetHistory(PagingResource pagingResource) - { - var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending); - var includeMovie = Request.GetBooleanQueryParameter("includeMovie"); - - var eventTypeFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "eventType"); - var downloadIdFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "downloadId"); - - if (eventTypeFilter != null) - { - var filterValue = (HistoryEventType)Convert.ToInt32(eventTypeFilter.Value); - pagingSpec.FilterExpressions.Add(v => v.EventType == filterValue); - } - - if (downloadIdFilter != null) - { - var downloadId = downloadIdFilter.Value; - pagingSpec.FilterExpressions.Add(h => h.DownloadId == downloadId); - } - - return ApplyToPage(_historyService.Paged, pagingSpec, h => MapToResource(h, includeMovie)); - } - - private List GetHistorySince() - { - var queryDate = Request.Query.Date; - var queryEventType = Request.Query.EventType; - - if (!queryDate.HasValue) - { - throw new BadRequestException("date is missing"); - } - - DateTime date = DateTime.Parse(queryDate.Value); - HistoryEventType? eventType = null; - var includeMovie = Request.GetBooleanQueryParameter("includeMovie"); - - if (queryEventType.HasValue) - { - eventType = (HistoryEventType)Convert.ToInt32(queryEventType.Value); - } - - return _historyService.Since(date, eventType).Select(h => MapToResource(h, includeMovie)).ToList(); - } - - private List GetIndexerHistory() - { - var queryIndexerId = Request.Query.IndexerId; - var queryEventType = Request.Query.EventType; - - if (!queryIndexerId.HasValue) - { - throw new BadRequestException("indexerId is missing"); - } - - int indexerId = Convert.ToInt32(queryIndexerId.Value); - HistoryEventType? eventType = null; - var includeIndexer = Request.GetBooleanQueryParameter("includeIndexer"); - - if (queryEventType.HasValue) - { - eventType = (HistoryEventType)Convert.ToInt32(queryEventType.Value); - } - - return _historyService.GetByIndexerId(indexerId, eventType).Select(h => MapToResource(h, includeIndexer)).ToList(); - } - } -} diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerModule.cs b/src/Prowlarr.Api.V1/Indexers/IndexerController.cs similarity index 68% rename from src/Prowlarr.Api.V1/Indexers/IndexerModule.cs rename to src/Prowlarr.Api.V1/Indexers/IndexerController.cs index d1bf49ce9..b33928582 100644 --- a/src/Prowlarr.Api.V1/Indexers/IndexerModule.cs +++ b/src/Prowlarr.Api.V1/Indexers/IndexerController.cs @@ -2,42 +2,32 @@ using System; using System.Collections.Generic; using System.Net; using System.Text; -using Nancy; -using Nancy.ModelBinding; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Indexers; using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.Parser; -using NzbDrone.Http.Extensions; +using Prowlarr.Http; using Prowlarr.Http.Extensions; using Prowlarr.Http.REST; namespace Prowlarr.Api.V1.Indexers { - public class IndexerModule : ProviderModuleBase + [V1ApiController] + public class IndexerController : ProviderControllerBase { private IIndexerFactory _indexerFactory { get; set; } private ISearchForNzb _nzbSearchService { get; set; } private IDownloadMappingService _downloadMappingService { get; set; } private IDownloadService _downloadService { get; set; } - public IndexerModule(IndexerFactory indexerFactory, ISearchForNzb nzbSearchService, IDownloadMappingService downloadMappingService, IDownloadService downloadService, IndexerResourceMapper resourceMapper) + public IndexerController(IndexerFactory indexerFactory, ISearchForNzb nzbSearchService, IDownloadMappingService downloadMappingService, IDownloadService downloadService, IndexerResourceMapper resourceMapper) : base(indexerFactory, "indexer", resourceMapper) { _indexerFactory = indexerFactory; _nzbSearchService = nzbSearchService; _downloadMappingService = downloadMappingService; _downloadService = downloadService; - - Get("{id}/newznab", x => - { - var request = this.Bind(); - return GetNewznabResponse(request); - }); - Get("{id}/download", x => - { - return GetDownload(x.id); - }); } protected override void Validate(IndexerDefinition definition, bool includeWarnings) @@ -50,10 +40,11 @@ namespace Prowlarr.Api.V1.Indexers base.Validate(definition, includeWarnings); } - private object GetNewznabResponse(NewznabRequest request) + [HttpGet("{id:int}/newznab")] + public IActionResult GetNewznabResponse(int id, [FromQuery] NewznabRequest request) { var requestType = request.t; - request.source = UserAgentParser.ParseSource(Request.Headers.UserAgent); + request.source = UserAgentParser.ParseSource(Request.Headers["User-Agent"]); request.server = Request.GetServerUrl(); if (requestType.IsNullOrWhiteSpace()) @@ -61,7 +52,7 @@ namespace Prowlarr.Api.V1.Indexers throw new BadRequestException("Missing Function Parameter"); } - var indexer = _indexerFactory.Get(request.id); + var indexer = _indexerFactory.Get(id); if (indexer == null) { @@ -73,32 +64,26 @@ namespace Prowlarr.Api.V1.Indexers switch (requestType) { case "caps": - Response response = indexerInstance.GetCapabilities().ToXml(); - response.ContentType = "application/rss+xml"; - return response; + return Content(indexerInstance.GetCapabilities().ToXml(), "application/rss+xml"); case "search": case "tvsearch": case "music": case "book": case "movie": var results = _nzbSearchService.Search(request, new List { indexer.Id }, false); - - Response searchResponse = results.ToXml(indexerInstance.Protocol); - searchResponse.ContentType = "application/rss+xml"; - return searchResponse; + return Content(results.ToXml(indexerInstance.Protocol), "application/rss+xml"); default: throw new BadRequestException("Function Not Available"); } } - private object GetDownload(int id) + [HttpGet("{id:int}/download")] + public object GetDownload(int id, string link, string file) { var indexerDef = _indexerFactory.Get(id); var indexer = _indexerFactory.GetInstance(indexerDef); - var link = Request.Query.Link; - var file = Request.Query.File; - if (!link.HasValue || !file.HasValue) + if (link.IsNullOrWhiteSpace() || file.IsNullOrWhiteSpace()) { throw new BadRequestException("Invalid Prowlarr link"); } @@ -110,15 +95,15 @@ namespace Prowlarr.Api.V1.Indexers throw new NotFoundException("Indexer Not Found"); } - var source = UserAgentParser.ParseSource(Request.Headers.UserAgent); + var source = UserAgentParser.ParseSource(Request.Headers["User-Agent"]); - var unprotectedlLink = _downloadMappingService.ConvertToNormalLink((string)link.Value); + var unprotectedlLink = _downloadMappingService.ConvertToNormalLink(link); // If Indexer is set to download via Redirect then just redirect to the link if (indexer.SupportsRedirect && indexerDef.Redirect) { _downloadService.RecordRedirect(unprotectedlLink, id, source, file); - return Response.AsRedirect(unprotectedlLink, Nancy.Responses.RedirectResponse.RedirectType.Permanent); + return RedirectPermanent(unprotectedlLink); } var downloadBytes = Array.Empty(); @@ -135,14 +120,14 @@ namespace Prowlarr.Api.V1.Indexers && downloadBytes[6] == 0x3a) { var magnetUrl = Encoding.UTF8.GetString(downloadBytes); - return Response.AsRedirect(magnetUrl, Nancy.Responses.RedirectResponse.RedirectType.Permanent); + return RedirectPermanent(magnetUrl); } var contentType = indexer.Protocol == DownloadProtocol.Torrent ? "application/x-bittorrent" : "application/x-nzb"; var extension = indexer.Protocol == DownloadProtocol.Torrent ? "torrent" : "nzb"; var filename = $"{file}.{extension}"; - return Response.FromByteArray(downloadBytes, contentType).AsAttachment(filename, contentType); + return File(downloadBytes, contentType, filename); } } } diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerDefaultCategoriesController.cs b/src/Prowlarr.Api.V1/Indexers/IndexerDefaultCategoriesController.cs new file mode 100644 index 000000000..6e8478c81 --- /dev/null +++ b/src/Prowlarr.Api.V1/Indexers/IndexerDefaultCategoriesController.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Indexers; +using Prowlarr.Http; + +namespace NzbDrone.Api.V1.Indexers +{ + [V1ApiController("indexer/categories")] + public class IndexerDefaultCategoriesController : Controller + { + [HttpGet] + public IndexerCategory[] GetAll() + { + return NewznabStandardCategory.ParentCats; + } + } +} diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerDefaultCategoriesModule.cs b/src/Prowlarr.Api.V1/Indexers/IndexerDefaultCategoriesModule.cs deleted file mode 100644 index 3420e5c17..000000000 --- a/src/Prowlarr.Api.V1/Indexers/IndexerDefaultCategoriesModule.cs +++ /dev/null @@ -1,19 +0,0 @@ -using NzbDrone.Core.Indexers; -using Prowlarr.Api.V1; - -namespace NzbDrone.Api.V1.Indexers -{ - public class IndexerDefaultCategoriesModule : ProwlarrV1Module - { - public IndexerDefaultCategoriesModule() - : base("/indexer/categories") - { - Get("/", movie => GetAll()); - } - - private IndexerCategory[] GetAll() - { - return NewznabStandardCategory.ParentCats; - } - } -} diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerEditorModule.cs b/src/Prowlarr.Api.V1/Indexers/IndexerEditorController.cs similarity index 75% rename from src/Prowlarr.Api.V1/Indexers/IndexerEditorModule.cs rename to src/Prowlarr.Api.V1/Indexers/IndexerEditorController.cs index 323cdc71d..224c5f396 100644 --- a/src/Prowlarr.Api.V1/Indexers/IndexerEditorModule.cs +++ b/src/Prowlarr.Api.V1/Indexers/IndexerEditorController.cs @@ -1,32 +1,30 @@ using System.Collections.Generic; using System.Linq; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Indexers; using NzbDrone.Core.Messaging.Commands; +using Prowlarr.Http; using Prowlarr.Http.Extensions; namespace Prowlarr.Api.V1.Indexers { - public class IndexerEditorModule : ProwlarrV1Module + [V1ApiController("indexer/editor")] + public class IndexerEditorController : Controller { private readonly IIndexerFactory _indexerService; private readonly IManageCommandQueue _commandQueueManager; private readonly IndexerResourceMapper _resourceMapper; - public IndexerEditorModule(IIndexerFactory indexerService, IManageCommandQueue commandQueueManager, IndexerResourceMapper resourceMapper) - : base("/indexer/editor") + public IndexerEditorController(IIndexerFactory indexerService, IManageCommandQueue commandQueueManager, IndexerResourceMapper resourceMapper) { _indexerService = indexerService; _commandQueueManager = commandQueueManager; _resourceMapper = resourceMapper; - - Put("/", movie => SaveAll()); - Delete("/", movie => DeleteIndexers()); } - private object SaveAll() + [HttpPut] + public IActionResult SaveAll(IndexerEditorResource resource) { - var resource = Request.Body.FromJson(); var indexersToUpdate = _indexerService.All().Where(x => resource.IndexerIds.Contains(x.Id)); foreach (var indexer in indexersToUpdate) @@ -65,13 +63,12 @@ namespace Prowlarr.Api.V1.Indexers _indexerService.SetProviderCharacteristics(definition); } - return ResponseWithCode(_resourceMapper.ToResource(indexers), HttpStatusCode.Accepted); + return Accepted(_resourceMapper.ToResource(indexers)); } - private object DeleteIndexers() + [HttpDelete] + public object DeleteIndexers(IndexerEditorResource resource) { - var resource = Request.Body.FromJson(); - _indexerService.DeleteIndexers(resource.IndexerIds); return new object(); diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerFlagModule.cs b/src/Prowlarr.Api.V1/Indexers/IndexerFlagController.cs similarity index 65% rename from src/Prowlarr.Api.V1/Indexers/IndexerFlagModule.cs rename to src/Prowlarr.Api.V1/Indexers/IndexerFlagController.cs index 0299d0089..9121aa63d 100644 --- a/src/Prowlarr.Api.V1/Indexers/IndexerFlagModule.cs +++ b/src/Prowlarr.Api.V1/Indexers/IndexerFlagController.cs @@ -1,19 +1,17 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Parser.Model; using Prowlarr.Http; namespace Prowlarr.Api.V1.Indexers { - public class IndexerFlagModule : ProwlarrRestModule + [V1ApiController] + public class IndexerFlagController : Controller { - public IndexerFlagModule() - { - GetResourceAll = GetAll; - } - - private List GetAll() + [HttpGet] + public List GetAll() { return Enum.GetValues(typeof(IndexerFlags)).Cast().Select(f => new IndexerFlagResource { diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerStatsModule.cs b/src/Prowlarr.Api.V1/Indexers/IndexerStatsController.cs similarity index 57% rename from src/Prowlarr.Api.V1/Indexers/IndexerStatsModule.cs rename to src/Prowlarr.Api.V1/Indexers/IndexerStatsController.cs index f79dfa0f4..065054c7b 100644 --- a/src/Prowlarr.Api.V1/Indexers/IndexerStatsModule.cs +++ b/src/Prowlarr.Api.V1/Indexers/IndexerStatsController.cs @@ -1,28 +1,21 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.IndexerStats; using Prowlarr.Http; namespace Prowlarr.Api.V1.Indexers { - public class IndexerStatsModule : ProwlarrRestModule + [V1ApiController] + public class IndexerStatsController : Controller { private readonly IIndexerStatisticsService _indexerStatisticsService; - public IndexerStatsModule(IIndexerStatisticsService indexerStatisticsService) + public IndexerStatsController(IIndexerStatisticsService indexerStatisticsService) { _indexerStatisticsService = indexerStatisticsService; - - Get("/", x => - { - return GetAll(); - }); } - private IndexerStatsResource GetAll() + [HttpGet] + public IndexerStatsResource GetAll() { var indexerResource = new IndexerStatsResource { diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerStatusModule.cs b/src/Prowlarr.Api.V1/Indexers/IndexerStatusController.cs similarity index 59% rename from src/Prowlarr.Api.V1/Indexers/IndexerStatusModule.cs rename to src/Prowlarr.Api.V1/Indexers/IndexerStatusController.cs index dcfb8ab3c..d6452f3a9 100644 --- a/src/Prowlarr.Api.V1/Indexers/IndexerStatusModule.cs +++ b/src/Prowlarr.Api.V1/Indexers/IndexerStatusController.cs @@ -1,31 +1,40 @@ using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Indexers; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.ThingiProvider.Events; using NzbDrone.SignalR; using Prowlarr.Http; +using Prowlarr.Http.REST; +using NotImplementedException = System.NotImplementedException; namespace Prowlarr.Api.V1.Indexers { - public class IndexerStatusModule : ProwlarrRestModuleWithSignalR, + [V1ApiController] + public class IndexerStatusController : RestControllerWithSignalR, IHandle> { private readonly IIndexerStatusService _indexerStatusService; - public IndexerStatusModule(IBroadcastSignalRMessage signalRBroadcaster, IIndexerStatusService indexerStatusService) + public IndexerStatusController(IBroadcastSignalRMessage signalRBroadcaster, IIndexerStatusService indexerStatusService) : base(signalRBroadcaster) { _indexerStatusService = indexerStatusService; + } - GetResourceAll = GetAll; + public override IndexerStatusResource GetResourceById(int id) + { + throw new NotImplementedException(); } - private List GetAll() + [HttpGet] + public List GetAll() { return _indexerStatusService.GetBlockedProviders().ToResource(); } + [NonAction] public void Handle(ProviderStatusChangedEvent message) { BroadcastResourceChange(ModelAction.Sync); diff --git a/src/Prowlarr.Api.V1/Languages/LanguageModule.cs b/src/Prowlarr.Api.V1/Languages/LanguageController.cs similarity index 69% rename from src/Prowlarr.Api.V1/Languages/LanguageModule.cs rename to src/Prowlarr.Api.V1/Languages/LanguageController.cs index 0aaf1b8c6..33622aea9 100644 --- a/src/Prowlarr.Api.V1/Languages/LanguageModule.cs +++ b/src/Prowlarr.Api.V1/Languages/LanguageController.cs @@ -1,19 +1,16 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Languages; using Prowlarr.Http; +using Prowlarr.Http.REST; namespace Prowlarr.Api.V1.Languages { - public class LanguageModule : ProwlarrRestModule + [V1ApiController()] + public class LanguageController : RestController { - public LanguageModule() - { - GetResourceAll = GetAll; - GetResourceById = GetById; - } - - private LanguageResource GetById(int id) + public override LanguageResource GetResourceById(int id) { var language = (Language)id; @@ -24,7 +21,8 @@ namespace Prowlarr.Api.V1.Languages }; } - private List GetAll() + [HttpGet] + public List GetAll() { return Language.All.Select(l => new LanguageResource { diff --git a/src/Prowlarr.Api.V1/Localization/LocalizationModule.cs b/src/Prowlarr.Api.V1/Localization/LocalizationController.cs similarity index 76% rename from src/Prowlarr.Api.V1/Localization/LocalizationModule.cs rename to src/Prowlarr.Api.V1/Localization/LocalizationController.cs index 4e66e1b63..4a7888f37 100644 --- a/src/Prowlarr.Api.V1/Localization/LocalizationModule.cs +++ b/src/Prowlarr.Api.V1/Localization/LocalizationController.cs @@ -1,21 +1,22 @@ +using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using NzbDrone.Core.Localization; using Prowlarr.Http; namespace Prowlarr.Api.V1.Localization { - public class LocalizationModule : ProwlarrRestModule + [V1ApiController] + public class LocalizationController : Controller { private readonly ILocalizationService _localizationService; - public LocalizationModule(ILocalizationService localizationService) + public LocalizationController(ILocalizationService localizationService) { _localizationService = localizationService; - - Get("/", x => GetLocalizationDictionary()); } - private string GetLocalizationDictionary() + [HttpGet] + public string GetLocalizationDictionary() { // We don't want camel case for transation strings, create new serializer settings var serializerSettings = new JsonSerializerSettings diff --git a/src/Prowlarr.Api.V1/Logs/LogModule.cs b/src/Prowlarr.Api.V1/Logs/LogController.cs similarity index 82% rename from src/Prowlarr.Api.V1/Logs/LogModule.cs rename to src/Prowlarr.Api.V1/Logs/LogController.cs index 71d2d8dc4..047c37b7e 100644 --- a/src/Prowlarr.Api.V1/Logs/LogModule.cs +++ b/src/Prowlarr.Api.V1/Logs/LogController.cs @@ -1,21 +1,25 @@ using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Instrumentation; using Prowlarr.Http; +using Prowlarr.Http.Extensions; namespace Prowlarr.Api.V1.Logs { - public class LogModule : ProwlarrRestModule + [V1ApiController] + public class LogController : Controller { private readonly ILogService _logService; - public LogModule(ILogService logService) + public LogController(ILogService logService) { _logService = logService; - GetResourcePaged = GetLogs; } - private PagingResource GetLogs(PagingResource pagingResource) + [HttpGet] + public PagingResource GetLogs() { + var pagingResource = Request.ReadPagingResourceFromRequest(); var pageSpec = pagingResource.MapToPagingSpec(); if (pageSpec.SortKey == "time") @@ -50,7 +54,7 @@ namespace Prowlarr.Api.V1.Logs } } - var response = ApplyToPage(_logService.Paged, pageSpec, LogResourceMapper.ToResource); + var response = pageSpec.ApplyToPage(_logService.Paged, LogResourceMapper.ToResource); if (pageSpec.SortKey == "id") { diff --git a/src/Prowlarr.Api.V1/Logs/LogFileModule.cs b/src/Prowlarr.Api.V1/Logs/LogFileController.cs similarity index 83% rename from src/Prowlarr.Api.V1/Logs/LogFileModule.cs rename to src/Prowlarr.Api.V1/Logs/LogFileController.cs index acba73cc8..9f43b0a21 100644 --- a/src/Prowlarr.Api.V1/Logs/LogFileModule.cs +++ b/src/Prowlarr.Api.V1/Logs/LogFileController.cs @@ -1,18 +1,20 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; +using Prowlarr.Http; namespace Prowlarr.Api.V1.Logs { - public class LogFileModule : LogFileModuleBase + [V1ApiController("log/file")] + public class LogFileController : LogFileControllerBase { private readonly IAppFolderInfo _appFolderInfo; private readonly IDiskProvider _diskProvider; - public LogFileModule(IAppFolderInfo appFolderInfo, + public LogFileController(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider) : base(diskProvider, configFileProvider, "") diff --git a/src/Prowlarr.Api.V1/Logs/LogFileModuleBase.cs b/src/Prowlarr.Api.V1/Logs/LogFileControllerBase.cs similarity index 68% rename from src/Prowlarr.Api.V1/Logs/LogFileModuleBase.cs rename to src/Prowlarr.Api.V1/Logs/LogFileControllerBase.cs index 23ddc3fbf..a662a049c 100644 --- a/src/Prowlarr.Api.V1/Logs/LogFileModuleBase.cs +++ b/src/Prowlarr.Api.V1/Logs/LogFileControllerBase.cs @@ -1,35 +1,32 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; -using Nancy; -using Nancy.Responses; +using Microsoft.AspNetCore.Mvc; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Core.Configuration; -using Prowlarr.Http; namespace Prowlarr.Api.V1.Logs { - public abstract class LogFileModuleBase : ProwlarrRestModule + public abstract class LogFileControllerBase : Controller { protected const string LOGFILE_ROUTE = @"/(?[-.a-zA-Z0-9]+?\.txt)"; + protected string _resource; private readonly IDiskProvider _diskProvider; private readonly IConfigFileProvider _configFileProvider; - public LogFileModuleBase(IDiskProvider diskProvider, + public LogFileControllerBase(IDiskProvider diskProvider, IConfigFileProvider configFileProvider, - string route) - : base("log/file" + route) + string resource) { _diskProvider = diskProvider; _configFileProvider = configFileProvider; - GetResourceAll = GetLogFilesResponse; - - Get(LOGFILE_ROUTE, options => GetLogFileResponse(options.filename)); + _resource = resource; } - private List GetLogFilesResponse() + [HttpGet] + public List GetLogFilesResponse() { var result = new List(); @@ -45,7 +42,7 @@ namespace Prowlarr.Api.V1.Logs Id = i + 1, Filename = filename, LastWriteTime = _diskProvider.FileGetLastWrite(file), - ContentsUrl = string.Format("{0}/api/v1/{1}/{2}", _configFileProvider.UrlBase, Resource, filename), + ContentsUrl = string.Format("{0}/api/v1/{1}/{2}", _configFileProvider.UrlBase, _resource, filename), DownloadUrl = string.Format("{0}/{1}/{2}", _configFileProvider.UrlBase, DownloadUrlRoot, filename) }); } @@ -53,7 +50,8 @@ namespace Prowlarr.Api.V1.Logs return result.OrderByDescending(l => l.LastWriteTime).ToList(); } - private object GetLogFileResponse(string filename) + [HttpGet(@"{filename:regex([[-.a-zA-Z0-9]]+?\.txt)}")] + public IActionResult GetLogFileResponse(string filename) { LogManager.Flush(); @@ -61,12 +59,10 @@ namespace Prowlarr.Api.V1.Logs if (!_diskProvider.FileExists(filePath)) { - return new NotFoundResponse(); + return NotFound(); } - var data = _diskProvider.ReadAllText(filePath); - - return new TextResponse(data); + return PhysicalFile(filePath, "text/plain"); } protected abstract IEnumerable GetLogFiles(); diff --git a/src/Prowlarr.Api.V1/Logs/UpdateLogFileModule.cs b/src/Prowlarr.Api.V1/Logs/UpdateLogFileController.cs similarity index 83% rename from src/Prowlarr.Api.V1/Logs/UpdateLogFileModule.cs rename to src/Prowlarr.Api.V1/Logs/UpdateLogFileController.cs index 1d3226793..f2a07f8c5 100644 --- a/src/Prowlarr.Api.V1/Logs/UpdateLogFileModule.cs +++ b/src/Prowlarr.Api.V1/Logs/UpdateLogFileController.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -6,18 +6,20 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; +using Prowlarr.Http; namespace Prowlarr.Api.V1.Logs { - public class UpdateLogFileModule : LogFileModuleBase + [V1ApiController("log/file/update")] + public class UpdateLogFileController : LogFileControllerBase { private readonly IAppFolderInfo _appFolderInfo; private readonly IDiskProvider _diskProvider; - public UpdateLogFileModule(IAppFolderInfo appFolderInfo, + public UpdateLogFileController(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider) - : base(diskProvider, configFileProvider, "/update") + : base(diskProvider, configFileProvider, "update") { _appFolderInfo = appFolderInfo; _diskProvider = diskProvider; diff --git a/src/Prowlarr.Api.V1/Notifications/NotificationModule.cs b/src/Prowlarr.Api.V1/Notifications/NotificationController.cs similarity index 69% rename from src/Prowlarr.Api.V1/Notifications/NotificationModule.cs rename to src/Prowlarr.Api.V1/Notifications/NotificationController.cs index f475e5719..f8dfbbc3c 100644 --- a/src/Prowlarr.Api.V1/Notifications/NotificationModule.cs +++ b/src/Prowlarr.Api.V1/Notifications/NotificationController.cs @@ -1,12 +1,14 @@ using NzbDrone.Core.Notifications; +using Prowlarr.Http; namespace Prowlarr.Api.V1.Notifications { - public class NotificationModule : ProviderModuleBase + [V1ApiController] + public class NotificationController : ProviderControllerBase { public static readonly NotificationResourceMapper ResourceMapper = new NotificationResourceMapper(); - public NotificationModule(NotificationFactory notificationFactory) + public NotificationController(NotificationFactory notificationFactory) : base(notificationFactory, "notification", ResourceMapper) { } diff --git a/src/Prowlarr.Api.V1/ProviderModuleBase.cs b/src/Prowlarr.Api.V1/ProviderControllerBase.cs similarity index 74% rename from src/Prowlarr.Api.V1/ProviderModuleBase.cs rename to src/Prowlarr.Api.V1/ProviderControllerBase.cs index f3bb52c03..69056edbf 100644 --- a/src/Prowlarr.Api.V1/ProviderModuleBase.cs +++ b/src/Prowlarr.Api.V1/ProviderControllerBase.cs @@ -2,16 +2,17 @@ using System.Collections.Generic; using System.Linq; using FluentValidation; using FluentValidation.Results; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Serializer; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; -using Prowlarr.Http; +using NzbDrone.Http.REST.Attributes; using Prowlarr.Http.Extensions; +using Prowlarr.Http.REST; namespace Prowlarr.Api.V1 { - public abstract class ProviderModuleBase : ProwlarrRestModule + public abstract class ProviderControllerBase : RestController where TProviderDefinition : ProviderDefinition, new() where TProvider : IProvider where TProviderResource : ProviderResource, new() @@ -19,23 +20,11 @@ namespace Prowlarr.Api.V1 protected readonly IProviderFactory _providerFactory; protected readonly ProviderResourceMapper _resourceMapper; - protected ProviderModuleBase(IProviderFactory providerFactory, string resource, ProviderResourceMapper resourceMapper) - : base(resource) + protected ProviderControllerBase(IProviderFactory providerFactory, string resource, ProviderResourceMapper resourceMapper) { _providerFactory = providerFactory; _resourceMapper = resourceMapper; - Get("schema", x => GetTemplates()); - Post("test", x => Test(ReadResourceFromRequest(true))); - Post("testall", x => TestAll()); - Post("action/{action}", x => RequestAction(x.action, ReadResourceFromRequest(true, true))); - - GetResourceAll = GetAll; - GetResourceById = GetProviderById; - CreateResource = CreateProvider; - UpdateResource = UpdateProvider; - DeleteResource = DeleteProvider; - SharedValidator.RuleFor(c => c.Name).NotEmpty(); SharedValidator.RuleFor(c => c.Name).Must((v, c) => !_providerFactory.All().Any(p => p.Name == c && p.Id != v.Id)).WithMessage("Should be unique"); SharedValidator.RuleFor(c => c.Implementation).NotEmpty(); @@ -44,7 +33,7 @@ namespace Prowlarr.Api.V1 PostValidator.RuleFor(c => c.Fields).NotNull(); } - private TProviderResource GetProviderById(int id) + public override TProviderResource GetResourceById(int id) { var definition = _providerFactory.Get(id); _providerFactory.SetProviderCharacteristics(definition); @@ -52,7 +41,8 @@ namespace Prowlarr.Api.V1 return _resourceMapper.ToResource(definition); } - private List GetAll() + [HttpGet] + public List GetAll() { var providerDefinitions = _providerFactory.All().OrderBy(p => p.ImplementationName); @@ -68,7 +58,8 @@ namespace Prowlarr.Api.V1 return result.OrderBy(p => p.Name).ToList(); } - private int CreateProvider(TProviderResource providerResource) + [RestPostById] + public ActionResult CreateProvider(TProviderResource providerResource) { var providerDefinition = GetDefinition(providerResource, false); @@ -79,10 +70,11 @@ namespace Prowlarr.Api.V1 providerDefinition = _providerFactory.Create(providerDefinition); - return providerDefinition.Id; + return Created(providerDefinition.Id); } - private void UpdateProvider(TProviderResource providerResource) + [RestPutById] + public ActionResult UpdateProvider(TProviderResource providerResource) { var providerDefinition = GetDefinition(providerResource, false); var forceSave = Request.GetBooleanQueryParameter("forceSave"); @@ -94,6 +86,8 @@ namespace Prowlarr.Api.V1 } _providerFactory.Update(providerDefinition); + + return Accepted(providerResource.Id); } private TProviderDefinition GetDefinition(TProviderResource providerResource, bool includeWarnings = false, bool validate = true) @@ -108,12 +102,15 @@ namespace Prowlarr.Api.V1 return definition; } - private void DeleteProvider(int id) + [RestDeleteById] + public object DeleteProvider(int id) { _providerFactory.Delete(id); + return new object(); } - protected virtual object GetTemplates() + [HttpGet("schema")] + public virtual List GetTemplates() { var defaultDefinitions = _providerFactory.GetDefaultDefinitions().OrderBy(p => p.ImplementationName).ToList(); @@ -134,7 +131,9 @@ namespace Prowlarr.Api.V1 return result; } - private object Test(TProviderResource providerResource) + [SkipValidation(true, false)] + [HttpPost("test")] + public object Test([FromBody] TProviderResource providerResource) { var providerDefinition = GetDefinition(providerResource, true); @@ -143,7 +142,8 @@ namespace Prowlarr.Api.V1 return "{}"; } - private object TestAll() + [HttpPost("testall")] + public IActionResult TestAll() { var providerDefinitions = _providerFactory.All() .Where(c => c.Settings.Validate().IsValid && c.Enable) @@ -161,19 +161,20 @@ namespace Prowlarr.Api.V1 }); } - return ResponseWithCode(result, result.Any(c => !c.IsValid) ? HttpStatusCode.BadRequest : HttpStatusCode.OK); + return result.Any(c => !c.IsValid) ? BadRequest(result) : Ok(result); } - private object RequestAction(string action, TProviderResource providerResource) + [SkipValidation] + [HttpPost("action/{name}")] + public IActionResult RequestAction(string name, [FromBody] TProviderResource resource) { - var providerDefinition = GetDefinition(providerResource, true, false); + var providerDefinition = GetDefinition(resource, true, false); + + var query = Request.Query.ToDictionary(x => x.Key, x => x.Value.ToString()); - var query = ((IDictionary)Request.Query.ToDictionary()).ToDictionary(k => k.Key, k => k.Value.ToString()); + var data = _providerFactory.RequestAction(providerDefinition, name, query); - var data = _providerFactory.RequestAction(providerDefinition, action, query); - Response resp = data.ToJson(); - resp.ContentType = "application/json"; - return resp; + return Content(data.ToJson(), "application/json"); } protected virtual void Validate(TProviderDefinition definition, bool includeWarnings) diff --git a/src/Prowlarr.Api.V1/Prowlarr.Api.V1.csproj b/src/Prowlarr.Api.V1/Prowlarr.Api.V1.csproj index 0edca5cd2..497026277 100644 --- a/src/Prowlarr.Api.V1/Prowlarr.Api.V1.csproj +++ b/src/Prowlarr.Api.V1/Prowlarr.Api.V1.csproj @@ -5,9 +5,6 @@ - - - diff --git a/src/Prowlarr.Api.V1/ProwlarrV1FeedModule.cs b/src/Prowlarr.Api.V1/ProwlarrV1FeedModule.cs deleted file mode 100644 index ef927ccfe..000000000 --- a/src/Prowlarr.Api.V1/ProwlarrV1FeedModule.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Prowlarr.Http; - -namespace Prowlarr.Api.V1 -{ - public abstract class ProwlarrV3FeedModule : ProwlarrModule - { - protected ProwlarrV3FeedModule(string resource) - : base("/feed/v1/" + resource.Trim('/')) - { - } - } -} diff --git a/src/Prowlarr.Api.V1/ProwlarrV1Module.cs b/src/Prowlarr.Api.V1/ProwlarrV1Module.cs deleted file mode 100644 index dd814a461..000000000 --- a/src/Prowlarr.Api.V1/ProwlarrV1Module.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Prowlarr.Http; - -namespace Prowlarr.Api.V1 -{ - public abstract class ProwlarrV1Module : ProwlarrModule - { - protected ProwlarrV1Module(string resource) - : base("/api/v1/" + resource.Trim('/')) - { - } - } -} diff --git a/src/Prowlarr.Api.V1/Search/SearchModule.cs b/src/Prowlarr.Api.V1/Search/SearchController.cs similarity index 71% rename from src/Prowlarr.Api.V1/Search/SearchModule.cs rename to src/Prowlarr.Api.V1/Search/SearchController.cs index e4b778282..c95ba6655 100644 --- a/src/Prowlarr.Api.V1/Search/SearchModule.cs +++ b/src/Prowlarr.Api.V1/Search/SearchController.cs @@ -1,47 +1,42 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; -using Nancy.ModelBinding; +using Microsoft.AspNetCore.Mvc; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.Exceptions; using NzbDrone.Core.IndexerSearch; -using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; using Prowlarr.Http; using Prowlarr.Http.Extensions; namespace Prowlarr.Api.V1.Search { - public class SearchModule : ProwlarrRestModule + [V1ApiController] + public class SearchController : Controller { private readonly ISearchForNzb _nzbSearhService; private readonly Logger _logger; - public SearchModule(ISearchForNzb nzbSearhService, Logger logger) + public SearchController(ISearchForNzb nzbSearhService, Logger logger) { _nzbSearhService = nzbSearhService; _logger = logger; - - GetResourceAll = GetAll; } - private List GetAll() + [HttpGet] + public List GetAll(string query, [FromQuery] List indexerIds, [FromQuery] List categories) { - var request = this.Bind(); - - if (request.Query.IsNotNullOrWhiteSpace()) + if (query.IsNotNullOrWhiteSpace()) { - var indexerIds = request.IndexerIds ?? new List(); - var categories = request.Categories ?? new List(); - - if (indexerIds.Count > 0) + if (indexerIds.Any()) { - return GetSearchReleases(request.Query, indexerIds, categories); + return GetSearchReleases(query, indexerIds, categories); } else { - return GetSearchReleases(request.Query, null, categories); + return GetSearchReleases(query, null, categories); } } diff --git a/src/Prowlarr.Api.V1/Search/SearchRequest.cs b/src/Prowlarr.Api.V1/Search/SearchRequest.cs deleted file mode 100644 index 23897640c..000000000 --- a/src/Prowlarr.Api.V1/Search/SearchRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; - -namespace Prowlarr.Api.V1.Search -{ - public class SearchRequest - { - public List IndexerIds { get; set; } - public string Query { get; set; } - public List Categories { get; set; } - } -} diff --git a/src/Prowlarr.Api.V1/System/Backup/BackupModule.cs b/src/Prowlarr.Api.V1/System/Backup/BackupController.cs similarity index 84% rename from src/Prowlarr.Api.V1/System/Backup/BackupModule.cs rename to src/Prowlarr.Api.V1/System/Backup/BackupController.cs index 1ef5a1027..88bc79a59 100644 --- a/src/Prowlarr.Api.V1/System/Backup/BackupModule.cs +++ b/src/Prowlarr.Api.V1/System/Backup/BackupController.cs @@ -1,17 +1,20 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Crypto; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Backup; +using NzbDrone.Http.REST.Attributes; using Prowlarr.Http; using Prowlarr.Http.REST; namespace Prowlarr.Api.V1.System.Backup { - public class BackupModule : ProwlarrRestModule + [V1ApiController("system/backup")] + public class BackupController : Controller { private readonly IBackupService _backupService; private readonly IAppFolderInfo _appFolderInfo; @@ -19,21 +22,16 @@ namespace Prowlarr.Api.V1.System.Backup private static readonly List ValidExtensions = new List { ".zip", ".db", ".xml" }; - public BackupModule(IBackupService backupService, + public BackupController(IBackupService backupService, IAppFolderInfo appFolderInfo, IDiskProvider diskProvider) - : base("system/backup") { _backupService = backupService; _appFolderInfo = appFolderInfo; _diskProvider = diskProvider; - GetResourceAll = GetBackupFiles; - DeleteResource = DeleteBackup; - - Post(@"/restore/(?[\d]{1,10})", x => Restore((int)x.Id)); - Post("/restore/upload", x => UploadAndRestore()); } + [HttpGet] public List GetBackupFiles() { var backups = _backupService.GetBackups(); @@ -50,7 +48,8 @@ namespace Prowlarr.Api.V1.System.Backup .ToList(); } - private void DeleteBackup(int id) + [RestDeleteById] + public void DeleteBackup(int id) { var backup = GetBackup(id); var path = GetBackupPath(backup); @@ -63,6 +62,7 @@ namespace Prowlarr.Api.V1.System.Backup _diskProvider.DeleteFile(path); } + [HttpPost("restore/{id:int}")] public object Restore(int id) { var backup = GetBackup(id); @@ -82,9 +82,10 @@ namespace Prowlarr.Api.V1.System.Backup }; } + [HttpPost("restore/upload")] public object UploadAndRestore() { - var files = Context.Request.Files.ToList(); + var files = Request.Form.Files; if (files.Empty()) { @@ -92,7 +93,7 @@ namespace Prowlarr.Api.V1.System.Backup } var file = files.First(); - var extension = Path.GetExtension(file.Name); + var extension = Path.GetExtension(file.FileName); if (!ValidExtensions.Contains(extension)) { @@ -101,7 +102,7 @@ namespace Prowlarr.Api.V1.System.Backup var path = Path.Combine(_appFolderInfo.TempFolder, $"prowlarr_backup_restore{extension}"); - _diskProvider.SaveStream(file.Value, path); + _diskProvider.SaveStream(file.OpenReadStream(), path); _backupService.Restore(path); // Cleanup restored file diff --git a/src/Prowlarr.Api.V1/System/SystemModule.cs b/src/Prowlarr.Api.V1/System/SystemController.cs similarity index 62% rename from src/Prowlarr.Api.V1/System/SystemModule.cs rename to src/Prowlarr.Api.V1/System/SystemController.cs index 6753d84a3..ec5e2f28c 100644 --- a/src/Prowlarr.Api.V1/System/SystemModule.cs +++ b/src/Prowlarr.Api.V1/System/SystemController.cs @@ -1,52 +1,60 @@ +using System.IO; using System.Threading.Tasks; -using Nancy.Routing; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Internal; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Datastore; using NzbDrone.Core.Lifecycle; +using Prowlarr.Http; +using Prowlarr.Http.Validation; namespace Prowlarr.Api.V1.System { - public class SystemModule : ProwlarrV1Module + [V1ApiController] + public class SystemController : Controller { private readonly IAppFolderInfo _appFolderInfo; private readonly IRuntimeInfo _runtimeInfo; private readonly IPlatformInfo _platformInfo; private readonly IOsInfo _osInfo; - private readonly IRouteCacheProvider _routeCacheProvider; private readonly IConfigFileProvider _configFileProvider; private readonly IMainDatabase _database; private readonly ILifecycleService _lifecycleService; private readonly IDeploymentInfoProvider _deploymentInfoProvider; + private readonly EndpointDataSource _endpointData; + private readonly DfaGraphWriter _graphWriter; + private readonly DuplicateEndpointDetector _detector; - public SystemModule(IAppFolderInfo appFolderInfo, - IRuntimeInfo runtimeInfo, - IPlatformInfo platformInfo, - IOsInfo osInfo, - IRouteCacheProvider routeCacheProvider, - IConfigFileProvider configFileProvider, - IMainDatabase database, - ILifecycleService lifecycleService, - IDeploymentInfoProvider deploymentInfoProvider) - : base("system") + public SystemController(IAppFolderInfo appFolderInfo, + IRuntimeInfo runtimeInfo, + IPlatformInfo platformInfo, + IOsInfo osInfo, + IConfigFileProvider configFileProvider, + IMainDatabase database, + ILifecycleService lifecycleService, + IDeploymentInfoProvider deploymentInfoProvider, + EndpointDataSource endpoints, + DfaGraphWriter graphWriter, + DuplicateEndpointDetector detector) { _appFolderInfo = appFolderInfo; _runtimeInfo = runtimeInfo; _platformInfo = platformInfo; _osInfo = osInfo; - _routeCacheProvider = routeCacheProvider; _configFileProvider = configFileProvider; _database = database; _lifecycleService = lifecycleService; _deploymentInfoProvider = deploymentInfoProvider; - Get("/status", x => GetStatus()); - Get("/routes", x => GetRoutes()); - Post("/shutdown", x => Shutdown()); - Post("/restart", x => Restart()); + _endpointData = endpoints; + _graphWriter = graphWriter; + _detector = detector; } - private object GetStatus() + [HttpGet("status")] + public object GetStatus() { return new { @@ -82,18 +90,32 @@ namespace Prowlarr.Api.V1.System }; } - private object GetRoutes() + [HttpGet("routes")] + public IActionResult GetRoutes() { - return _routeCacheProvider.GetCache().Values; + using (var sw = new StringWriter()) + { + _graphWriter.Write(_endpointData, sw); + var graph = sw.ToString(); + return Content(graph, "text/plain"); + } + } + + [HttpGet("routes/duplicate")] + public object DuplicateRoutes() + { + return _detector.GetDuplicateEndpoints(_endpointData); } - private object Shutdown() + [HttpPost("shutdown")] + public object Shutdown() { Task.Factory.StartNew(() => _lifecycleService.Shutdown()); return new { ShuttingDown = true }; } - private object Restart() + [HttpPost("restart")] + public object Restart() { Task.Factory.StartNew(() => _lifecycleService.Restart()); return new { Restarting = true }; diff --git a/src/Prowlarr.Api.V1/System/Tasks/TaskModule.cs b/src/Prowlarr.Api.V1/System/Tasks/TaskController.cs similarity index 79% rename from src/Prowlarr.Api.V1/System/Tasks/TaskModule.cs rename to src/Prowlarr.Api.V1/System/Tasks/TaskController.cs index cbc0f3273..428f78566 100644 --- a/src/Prowlarr.Api.V1/System/Tasks/TaskModule.cs +++ b/src/Prowlarr.Api.V1/System/Tasks/TaskController.cs @@ -1,27 +1,29 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Jobs; using NzbDrone.Core.Messaging.Events; using NzbDrone.SignalR; using Prowlarr.Http; +using Prowlarr.Http.REST; namespace Prowlarr.Api.V1.System.Tasks { - public class TaskModule : ProwlarrRestModuleWithSignalR, IHandle + [V1ApiController("system/task")] + public class TaskController : RestControllerWithSignalR, IHandle { private readonly ITaskManager _taskManager; - public TaskModule(ITaskManager taskManager, IBroadcastSignalRMessage broadcastSignalRMessage) - : base(broadcastSignalRMessage, "system/task") + public TaskController(ITaskManager taskManager, IBroadcastSignalRMessage broadcastSignalRMessage) + : base(broadcastSignalRMessage) { _taskManager = taskManager; - GetResourceAll = GetAll; - GetResourceById = GetTask; } - private List GetAll() + [HttpGet] + public List GetAll() { return _taskManager.GetAll() .Select(ConvertToResource) @@ -29,7 +31,7 @@ namespace Prowlarr.Api.V1.System.Tasks .ToList(); } - private TaskResource GetTask(int id) + public override TaskResource GetResourceById(int id) { var task = _taskManager.GetAll() .SingleOrDefault(t => t.Id == id); @@ -58,6 +60,7 @@ namespace Prowlarr.Api.V1.System.Tasks }; } + [NonAction] public void Handle(CommandExecutedEvent message) { BroadcastResourceChange(ModelAction.Sync); diff --git a/src/Prowlarr.Api.V1/Tags/TagModule.cs b/src/Prowlarr.Api.V1/Tags/TagController.cs similarity index 53% rename from src/Prowlarr.Api.V1/Tags/TagModule.cs rename to src/Prowlarr.Api.V1/Tags/TagController.cs index 44d1db688..b10a6be68 100644 --- a/src/Prowlarr.Api.V1/Tags/TagModule.cs +++ b/src/Prowlarr.Api.V1/Tags/TagController.cs @@ -1,54 +1,59 @@ +using System; using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Tags; +using NzbDrone.Http.REST.Attributes; using NzbDrone.SignalR; using Prowlarr.Http; +using Prowlarr.Http.REST; namespace Prowlarr.Api.V1.Tags { - public class TagModule : ProwlarrRestModuleWithSignalR, IHandle + [V1ApiController] + public class TagController : RestControllerWithSignalR, IHandle { private readonly ITagService _tagService; - public TagModule(IBroadcastSignalRMessage signalRBroadcaster, + public TagController(IBroadcastSignalRMessage signalRBroadcaster, ITagService tagService) : base(signalRBroadcaster) { _tagService = tagService; - - GetResourceById = GetById; - GetResourceAll = GetAll; - CreateResource = Create; - UpdateResource = Update; - DeleteResource = DeleteTag; } - private TagResource GetById(int id) + public override TagResource GetResourceById(int id) { return _tagService.GetTag(id).ToResource(); } - private List GetAll() + [HttpGet] + public List GetAll() { return _tagService.All().ToResource(); } - private int Create(TagResource resource) + [RestPostById] + public ActionResult Create(TagResource resource) { - return _tagService.Add(resource.ToModel()).Id; + return Created(_tagService.Add(resource.ToModel()).Id); } - private void Update(TagResource resource) + [RestPutById] + public ActionResult Update(TagResource resource) { _tagService.Update(resource.ToModel()); + return Accepted(resource.Id); } - private void DeleteTag(int id) + [RestDeleteById] + public void DeleteTag(int id) { _tagService.Delete(id); } + [NonAction] public void Handle(TagsUpdatedEvent message) { BroadcastResourceChange(ModelAction.Sync); diff --git a/src/Prowlarr.Api.V1/Tags/TagDetailsModule.cs b/src/Prowlarr.Api.V1/Tags/TagDetailsController.cs similarity index 53% rename from src/Prowlarr.Api.V1/Tags/TagDetailsModule.cs rename to src/Prowlarr.Api.V1/Tags/TagDetailsController.cs index 8c1662ad0..2c927dcf0 100644 --- a/src/Prowlarr.Api.V1/Tags/TagDetailsModule.cs +++ b/src/Prowlarr.Api.V1/Tags/TagDetailsController.cs @@ -1,28 +1,28 @@ using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Tags; using Prowlarr.Http; +using Prowlarr.Http.REST; namespace Prowlarr.Api.V1.Tags { - public class TagDetailsModule : ProwlarrRestModule + [V1ApiController("tag/detail")] + public class TagDetailsController : RestController { private readonly ITagService _tagService; - public TagDetailsModule(ITagService tagService) - : base("/tag/detail") + public TagDetailsController(ITagService tagService) { _tagService = tagService; - - GetResourceById = GetById; - GetResourceAll = GetAll; } - private TagDetailsResource GetById(int id) + public override TagDetailsResource GetResourceById(int id) { return _tagService.Details(id).ToResource(); } - private List GetAll() + [HttpGet] + public List GetAll() { return _tagService.Details().ToResource(); } diff --git a/src/Prowlarr.Api.V1/Update/UpdateModule.cs b/src/Prowlarr.Api.V1/Update/UpdateController.cs similarity index 81% rename from src/Prowlarr.Api.V1/Update/UpdateModule.cs rename to src/Prowlarr.Api.V1/Update/UpdateController.cs index 7011bb349..168d40679 100644 --- a/src/Prowlarr.Api.V1/Update/UpdateModule.cs +++ b/src/Prowlarr.Api.V1/Update/UpdateController.cs @@ -1,22 +1,24 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Update; using Prowlarr.Http; namespace Prowlarr.Api.V1.Update { - public class UpdateModule : ProwlarrRestModule + [V1ApiController] + public class UpdateController : Controller { private readonly IRecentUpdateProvider _recentUpdateProvider; - public UpdateModule(IRecentUpdateProvider recentUpdateProvider) + public UpdateController(IRecentUpdateProvider recentUpdateProvider) { _recentUpdateProvider = recentUpdateProvider; - GetResourceAll = GetRecentUpdates; } - private List GetRecentUpdates() + [HttpGet] + public List GetRecentUpdates() { var resources = _recentUpdateProvider.GetRecentUpdatePackages() .OrderByDescending(u => u.Version) diff --git a/src/Prowlarr.Http/Authentication/ApiKeyAuthenticationHandler.cs b/src/Prowlarr.Http/Authentication/ApiKeyAuthenticationHandler.cs new file mode 100644 index 000000000..a5a6f2940 --- /dev/null +++ b/src/Prowlarr.Http/Authentication/ApiKeyAuthenticationHandler.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Prowlarr.Http.Authentication +{ + public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions + { + public const string DefaultScheme = "API Key"; + public string Scheme => DefaultScheme; + public string AuthenticationType = DefaultScheme; + + public string HeaderName { get; set; } + public string QueryName { get; set; } + public string ApiKey { get; set; } + } + + public class ApiKeyAuthenticationHandler : AuthenticationHandler + { + public ApiKeyAuthenticationHandler(IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) + : base(options, logger, encoder, clock) + { + } + + private string ParseApiKey() + { + // Try query parameter + if (Request.Query.TryGetValue(Options.QueryName, out var value)) + { + return value.FirstOrDefault(); + } + + // No ApiKey query parameter found try headers + if (Request.Headers.TryGetValue(Options.HeaderName, out var headerValue)) + { + return headerValue.FirstOrDefault(); + } + + return Request.Headers["Authorization"].FirstOrDefault()?.Replace("Bearer ", ""); + } + + protected override Task HandleAuthenticateAsync() + { + var providedApiKey = ParseApiKey(); + + if (string.IsNullOrWhiteSpace(providedApiKey)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + if (Options.ApiKey == providedApiKey) + { + var claims = new List + { + new Claim("ApiKey", "true") + }; + + var identity = new ClaimsIdentity(claims, Options.AuthenticationType); + var identities = new List { identity }; + var principal = new ClaimsPrincipal(identities); + var ticket = new AuthenticationTicket(principal, Options.Scheme); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + + return Task.FromResult(AuthenticateResult.NoResult()); + } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.StatusCode = 401; + return Task.CompletedTask; + } + + protected override Task HandleForbiddenAsync(AuthenticationProperties properties) + { + Response.StatusCode = 403; + return Task.CompletedTask; + } + } +} diff --git a/src/Prowlarr.Http/Authentication/AuthenticationBuilderExtensions.cs b/src/Prowlarr.Http/Authentication/AuthenticationBuilderExtensions.cs new file mode 100644 index 000000000..2ab6e5a0a --- /dev/null +++ b/src/Prowlarr.Http/Authentication/AuthenticationBuilderExtensions.cs @@ -0,0 +1,65 @@ +using System; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using NzbDrone.Core.Authentication; +using NzbDrone.Core.Configuration; + +namespace Prowlarr.Http.Authentication +{ + public static class AuthenticationBuilderExtensions + { + public static AuthenticationBuilder AddApiKey(this AuthenticationBuilder authenticationBuilder, string name, Action options) + { + return authenticationBuilder.AddScheme(name, options); + } + + public static AuthenticationBuilder AddBasicAuthentication(this AuthenticationBuilder authenticationBuilder) + { + return authenticationBuilder.AddScheme(AuthenticationType.Basic.ToString(), options => { }); + } + + public static AuthenticationBuilder AddNoAuthentication(this AuthenticationBuilder authenticationBuilder) + { + return authenticationBuilder.AddScheme(AuthenticationType.None.ToString(), options => { }); + } + + public static AuthenticationBuilder AddAppAuthentication(this IServiceCollection services, IConfigFileProvider config) + { + var authBuilder = services.AddAuthentication(config.AuthenticationMethod.ToString()); + + if (config.AuthenticationMethod == AuthenticationType.Basic) + { + authBuilder.AddBasicAuthentication(); + } + else if (config.AuthenticationMethod == AuthenticationType.Forms) + { + authBuilder.AddCookie(AuthenticationType.Forms.ToString(), options => + { + options.AccessDeniedPath = "/login?loginFailed=true"; + options.LoginPath = "/login"; + options.ExpireTimeSpan = TimeSpan.FromDays(7); + }); + } + else + { + authBuilder.AddNoAuthentication(); + } + + authBuilder.AddApiKey("API", options => + { + options.HeaderName = "X-Api-Key"; + options.QueryName = "apikey"; + options.ApiKey = config.ApiKey; + }); + + authBuilder.AddApiKey("SignalR", options => + { + options.HeaderName = "X-Api-Key"; + options.QueryName = "access_token"; + options.ApiKey = config.ApiKey; + }); + + return authBuilder; + } + } +} diff --git a/src/Prowlarr.Http/Authentication/AuthenticationController.cs b/src/Prowlarr.Http/Authentication/AuthenticationController.cs new file mode 100644 index 000000000..0949945fa --- /dev/null +++ b/src/Prowlarr.Http/Authentication/AuthenticationController.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Configuration; + +namespace Prowlarr.Http.Authentication +{ + [AllowAnonymous] + [ApiController] + public class AuthenticationController : Controller + { + private readonly IAuthenticationService _authService; + private readonly IConfigFileProvider _configFileProvider; + + public AuthenticationController(IAuthenticationService authService, IConfigFileProvider configFileProvider) + { + _authService = authService; + _configFileProvider = configFileProvider; + } + + [HttpPost("login")] + public async Task Login([FromForm] LoginResource resource, [FromQuery] string returnUrl = null) + { + var user = _authService.Login(HttpContext.Request, resource.Username, resource.Password); + + if (user == null) + { + return Redirect($"~/login?returnUrl={returnUrl}&loginFailed=true"); + } + + var claims = new List + { + new Claim("user", user.Username), + new Claim("identifier", user.Identifier.ToString()), + new Claim("UiAuth", "true") + }; + + var authProperties = new AuthenticationProperties + { + IsPersistent = resource.RememberMe == "on" + }; + await HttpContext.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(claims, "Cookies", "user", "identifier")), authProperties); + + return Redirect("/"); + } + + [HttpGet("logout")] + public async Task Logout() + { + _authService.Logout(HttpContext); + await HttpContext.SignOutAsync(); + return Redirect("/"); + } + } +} diff --git a/src/Prowlarr.Http/Authentication/AuthenticationModule.cs b/src/Prowlarr.Http/Authentication/AuthenticationModule.cs deleted file mode 100644 index 42f0180eb..000000000 --- a/src/Prowlarr.Http/Authentication/AuthenticationModule.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using Nancy; -using Nancy.Authentication.Forms; -using Nancy.Extensions; -using Nancy.ModelBinding; -using NzbDrone.Core.Configuration; - -namespace Prowlarr.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())); - 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 + "/"); - } - } -} diff --git a/src/Prowlarr.Http/Authentication/AuthenticationService.cs b/src/Prowlarr.Http/Authentication/AuthenticationService.cs index d54a4ae77..04990632a 100644 --- a/src/Prowlarr.Http/Authentication/AuthenticationService.cs +++ b/src/Prowlarr.Http/Authentication/AuthenticationService.cs @@ -1,26 +1,16 @@ -using System; -using System.Linq; -using System.Security.Claims; -using System.Security.Principal; -using Nancy; -using Nancy.Authentication.Basic; -using Nancy.Authentication.Forms; +using Microsoft.AspNetCore.Http; using NLog; -using NzbDrone.Common.Extensions; using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; using Prowlarr.Http.Extensions; namespace Prowlarr.Http.Authentication { - public interface IAuthenticationService : IUserValidator, IUserMapper + public interface IAuthenticationService { - void SetContext(NancyContext context); - - void LogUnauthorized(NancyContext context); - User Login(NancyContext context, string username, string password); - void Logout(NancyContext context); - bool IsAuthenticated(NancyContext context); + void LogUnauthorized(HttpRequest context); + User Login(HttpRequest request, string username, string password); + void Logout(HttpContext context); } public class AuthenticationService : IAuthenticationService @@ -32,9 +22,6 @@ namespace Prowlarr.Http.Authentication private static string API_KEY; private static AuthenticationType AUTH_METHOD; - [ThreadStatic] - private static NancyContext _context; - public AuthenticationService(IConfigFileProvider configFileProvider, IUserService userService) { _userService = userService; @@ -42,13 +29,7 @@ namespace Prowlarr.Http.Authentication 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) + public User Login(HttpRequest request, string username, string password) { if (AUTH_METHOD == AuthenticationType.None) { @@ -59,174 +40,50 @@ namespace Prowlarr.Http.Authentication if (user != null) { - LogSuccess(context, username); + LogSuccess(request, username); return user; } - LogFailure(context, username); + LogFailure(request, username); return null; } - public void Logout(NancyContext context) + public void Logout(HttpContext 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()) + if (context.User != null) { - return ValidApiKey(apiKey); + LogLogout(context.Request, context.User.Identity.Name); } - - 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) + public void LogUnauthorized(HttpRequest context) { - _authLogger.Info("Auth-Unauthorized ip {0} url '{1}'", context.GetRemoteIP(), context.Request.Url.ToString()); + _authLogger.Info("Auth-Unauthorized ip {0} url '{1}'", context.GetRemoteIP(), context.Path); } - private void LogInvalidated(NancyContext context) + private void LogInvalidated(HttpRequest context) { _authLogger.Info("Auth-Invalidated ip {0}", context.GetRemoteIP()); } - private void LogFailure(NancyContext context, string username) + private void LogFailure(HttpRequest context, string username) { _authLogger.Warn("Auth-Failure ip {0} username '{1}'", context.GetRemoteIP(), username); } - private void LogSuccess(NancyContext context, string username) + private void LogSuccess(HttpRequest context, string username) { _authLogger.Info("Auth-Success ip {0} username '{1}'", context.GetRemoteIP(), username); } - private void LogLogout(NancyContext context, string username) + private void LogLogout(HttpRequest context, string username) { _authLogger.Info("Auth-Logout ip {0} username '{1}'", context.GetRemoteIP(), username); } diff --git a/src/Prowlarr.Http/Authentication/BasicAuthenticationHandler.cs b/src/Prowlarr.Http/Authentication/BasicAuthenticationHandler.cs new file mode 100644 index 000000000..e5ff98a20 --- /dev/null +++ b/src/Prowlarr.Http/Authentication/BasicAuthenticationHandler.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NzbDrone.Common.EnvironmentInfo; + +namespace Prowlarr.Http.Authentication +{ + public class BasicAuthenticationHandler : AuthenticationHandler + { + private readonly IAuthenticationService _authService; + + public BasicAuthenticationHandler(IAuthenticationService authService, + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) + : base(options, logger, encoder, clock) + { + _authService = authService; + } + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.ContainsKey("Authorization")) + { + return Task.FromResult(AuthenticateResult.Fail("Authorization header missing.")); + } + + // Get authorization key + var authorizationHeader = Request.Headers["Authorization"].ToString(); + var authHeaderRegex = new Regex(@"Basic (.*)"); + + if (!authHeaderRegex.IsMatch(authorizationHeader)) + { + return Task.FromResult(AuthenticateResult.Fail("Authorization code not formatted properly.")); + } + + var authBase64 = Encoding.UTF8.GetString(Convert.FromBase64String(authHeaderRegex.Replace(authorizationHeader, "$1"))); + var authSplit = authBase64.Split(':', 2); + var authUsername = authSplit[0]; + var authPassword = authSplit.Length > 1 ? authSplit[1] : throw new Exception("Unable to get password"); + + var user = _authService.Login(Request, authUsername, authPassword); + + if (user == null) + { + return Task.FromResult(AuthenticateResult.Fail("The username or password is not correct.")); + } + + var claims = new List + { + new Claim("user", user.Username), + new Claim("identifier", user.Identifier.ToString()), + new Claim("UiAuth", "true") + }; + + var identity = new ClaimsIdentity(claims, "Basic", "user", "identifier"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "Basic"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.Headers.Add("WWW-Authenticate", $"Basic realm=\"{BuildInfo.AppName}\""); + Response.StatusCode = 401; + return Task.CompletedTask; + } + + protected override Task HandleForbiddenAsync(AuthenticationProperties properties) + { + Response.StatusCode = 403; + return Task.CompletedTask; + } + } +} diff --git a/src/Prowlarr.Http/Authentication/EnableAuthInNancy.cs b/src/Prowlarr.Http/Authentication/EnableAuthInNancy.cs deleted file mode 100644 index 97e56d408..000000000 --- a/src/Prowlarr.Http/Authentication/EnableAuthInNancy.cs +++ /dev/null @@ -1,142 +0,0 @@ -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.Extensions; -using NzbDrone.Core.Authentication; -using NzbDrone.Core.Configuration; -using Prowlarr.Http.Extensions; -using Prowlarr.Http.Extensions.Pipelines; - -namespace Prowlarr.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((Action)SlidingAuthenticationForFormsAuth); - } - else if (_configFileProvider.AuthenticationMethod == AuthenticationType.Basic) - { - pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, "Prowlarr")); - pipelines.BeforeRequest.AddItemToStartOfPipeline(CaptureContext); - } - - pipelines.BeforeRequest.AddItemToEndOfPipeline((Func)RequiresAuthentication); - pipelines.AfterRequest.AddItemToEndOfPipeline((Action)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 = "ProwlarrAuth"; - - 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; - } - } -} diff --git a/src/Prowlarr.Http/Authentication/LoginResource.cs b/src/Prowlarr.Http/Authentication/LoginResource.cs index e23415f25..615403fc2 100644 --- a/src/Prowlarr.Http/Authentication/LoginResource.cs +++ b/src/Prowlarr.Http/Authentication/LoginResource.cs @@ -4,6 +4,6 @@ { public string Username { get; set; } public string Password { get; set; } - public bool RememberMe { get; set; } + public string RememberMe { get; set; } } } diff --git a/src/Prowlarr.Http/Authentication/NoAuthenticationHandler.cs b/src/Prowlarr.Http/Authentication/NoAuthenticationHandler.cs new file mode 100644 index 000000000..ac549aa18 --- /dev/null +++ b/src/Prowlarr.Http/Authentication/NoAuthenticationHandler.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Prowlarr.Http.Authentication +{ + public class NoAuthenticationHandler : AuthenticationHandler + { + public NoAuthenticationHandler(IAuthenticationService authService, + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) + : base(options, logger, encoder, clock) + { + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new List + { + new Claim("user", "Anonymous"), + new Claim("UiAuth", "true") + }; + + var identity = new ClaimsIdentity(claims, "NoAuth", "user", "identifier"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "NoAuth"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } +} diff --git a/src/Prowlarr.Http/ErrorManagement/ErrorHandler.cs b/src/Prowlarr.Http/ErrorManagement/ErrorHandler.cs deleted file mode 100644 index cade63fe1..000000000 --- a/src/Prowlarr.Http/ErrorManagement/ErrorHandler.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Nancy; -using Nancy.ErrorHandling; -using Prowlarr.Http.Extensions; - -namespace Prowlarr.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.MovedPermanently || 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); - } - } - } -} diff --git a/src/Prowlarr.Http/ErrorManagement/ErrorModel.cs b/src/Prowlarr.Http/ErrorManagement/ErrorModel.cs index 1c5409d7d..0c8d2fe58 100644 --- a/src/Prowlarr.Http/ErrorManagement/ErrorModel.cs +++ b/src/Prowlarr.Http/ErrorManagement/ErrorModel.cs @@ -1,4 +1,8 @@ -using Prowlarr.Http.Exceptions; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using NzbDrone.Common.Serializer; +using Prowlarr.Http.Exceptions; namespace Prowlarr.Http.ErrorManagement { @@ -17,5 +21,12 @@ namespace Prowlarr.Http.ErrorManagement public ErrorModel() { } + + public Task WriteToResponse(HttpResponse response, HttpStatusCode statusCode = HttpStatusCode.InternalServerError) + { + response.StatusCode = (int)statusCode; + response.ContentType = "application/json"; + return STJson.SerializeAsync(this, response.Body); + } } } diff --git a/src/Prowlarr.Http/ErrorManagement/ProwlarrErrorPipeline.cs b/src/Prowlarr.Http/ErrorManagement/ProwlarrErrorPipeline.cs index fefd7dd7a..866a1a9a8 100644 --- a/src/Prowlarr.Http/ErrorManagement/ProwlarrErrorPipeline.cs +++ b/src/Prowlarr.Http/ErrorManagement/ProwlarrErrorPipeline.cs @@ -1,15 +1,14 @@ -using System; using System.Data.SQLite; +using System.Net; +using System.Threading.Tasks; using FluentValidation; -using Nancy; -using Nancy.Extensions; -using Nancy.IO; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; using NLog; +using NzbDrone.Common.Serializer; using NzbDrone.Core.Datastore; using NzbDrone.Core.Exceptions; using Prowlarr.Http.Exceptions; -using Prowlarr.Http.Extensions; -using HttpStatusCode = Nancy.HttpStatusCode; namespace Prowlarr.Http.ErrorManagement { @@ -22,63 +21,81 @@ namespace Prowlarr.Http.ErrorManagement _logger = logger; } - public Response HandleException(NancyContext context, Exception exception) + public async Task HandleException(HttpContext context) { _logger.Trace("Handling Exception"); + var response = context.Response; + var exceptionHandlerPathFeature = context.Features.Get(); + var exception = exceptionHandlerPathFeature?.Error; + + _logger.Warn(exception); + + var statusCode = HttpStatusCode.InternalServerError; + var errorModel = new ErrorModel + { + Message = exception.Message, + Description = exception.ToString() + }; + if (exception is ApiException apiException) { _logger.Warn(apiException, "API Error:\n{0}", apiException.Message); - var body = RequestStream.FromStream(context.Request.Body).AsString(); - _logger.Trace("Request body:\n{0}", body); - return apiException.ToErrorResponse(context); - } + /* var body = RequestStream.FromStream(context.Request.Body).AsString(); + _logger.Trace("Request body:\n{0}", body);*/ - if (exception is ValidationException validationException) + errorModel = new ErrorModel(apiException); + statusCode = apiException.StatusCode; + } + else if (exception is ValidationException validationException) { _logger.Warn("Invalid request {0}", validationException.Message); - return validationException.Errors.AsResponse(context, HttpStatusCode.BadRequest); + response.StatusCode = (int)HttpStatusCode.BadRequest; + response.ContentType = "application/json"; + await response.WriteAsync(STJson.ToJson(validationException.Errors)); + return; } - - if (exception is NzbDroneClientException clientException) + else if (exception is NzbDroneClientException clientException) { - return new ErrorModel + errorModel = new ErrorModel { Message = exception.Message, Description = exception.ToString() - }.AsResponse(context, (HttpStatusCode)clientException.StatusCode); + }; + statusCode = clientException.StatusCode; } - - if (exception is ModelNotFoundException notFoundException) + else if (exception is ModelNotFoundException notFoundException) { - return new ErrorModel + errorModel = new ErrorModel { Message = exception.Message, Description = exception.ToString() - }.AsResponse(context, HttpStatusCode.NotFound); + }; + statusCode = HttpStatusCode.NotFound; } - - if (exception is ModelConflictException conflictException) + else if (exception is ModelConflictException conflictException) { - return new ErrorModel + _logger.Error(exception, "DB error"); + errorModel = new ErrorModel { Message = exception.Message, Description = exception.ToString() - }.AsResponse(context, HttpStatusCode.Conflict); + }; + statusCode = HttpStatusCode.Conflict; } - - if (exception is SQLiteException sqLiteException) + else if (exception is SQLiteException sqLiteException) { if (context.Request.Method == "PUT" || context.Request.Method == "POST") { if (sqLiteException.Message.Contains("constraint failed")) { - return new ErrorModel + errorModel = new ErrorModel { Message = exception.Message, - }.AsResponse(context, HttpStatusCode.Conflict); + }; + statusCode = HttpStatusCode.Conflict; } } @@ -87,11 +104,7 @@ namespace Prowlarr.Http.ErrorManagement _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); + await errorModel.WriteToResponse(response, statusCode); } } } diff --git a/src/Prowlarr.Http/Exceptions/ApiException.cs b/src/Prowlarr.Http/Exceptions/ApiException.cs index 3c0801b23..870a89a3a 100644 --- a/src/Prowlarr.Http/Exceptions/ApiException.cs +++ b/src/Prowlarr.Http/Exceptions/ApiException.cs @@ -1,8 +1,5 @@ using System; -using Nancy; -using Nancy.Responses; -using Prowlarr.Http.ErrorManagement; -using Prowlarr.Http.Extensions; +using System.Net; namespace Prowlarr.Http.Exceptions { @@ -19,11 +16,6 @@ namespace Prowlarr.Http.Exceptions Content = content; } - public JsonResponse ToErrorResponse(NancyContext context) - { - return new ErrorModel(this).AsResponse(context, StatusCode); - } - private static string GetMessage(HttpStatusCode statusCode, object content) { var result = statusCode.ToString(); diff --git a/src/Prowlarr.Http/Extensions/NancyJsonSerializer.cs b/src/Prowlarr.Http/Extensions/NancyJsonSerializer.cs deleted file mode 100644 index 5a548545a..000000000 --- a/src/Prowlarr.Http/Extensions/NancyJsonSerializer.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Text.Json; -using Nancy; -using Nancy.Responses.Negotiation; -using NzbDrone.Common.Serializer; - -namespace Prowlarr.Http.Extensions -{ - public class NancyJsonSerializer : ISerializer - { - protected readonly JsonSerializerOptions _serializerSettings; - - public NancyJsonSerializer() - { - _serializerSettings = STJson.GetSerializerSettings(); - } - - public bool CanSerialize(MediaRange contentType) - { - return contentType == "application/json"; - } - - public void Serialize(MediaRange contentType, TModel model, Stream outputStream) - { - STJson.Serialize(model, outputStream, _serializerSettings); - } - - public IEnumerable Extensions { get; private set; } - } -} diff --git a/src/Prowlarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs b/src/Prowlarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs deleted file mode 100644 index 0b3004ad2..000000000 --- a/src/Prowlarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using Nancy; -using Nancy.Bootstrapper; -using Prowlarr.Http.Frontend; - -namespace Prowlarr.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((Action)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(); - } - } - } -} diff --git a/src/Prowlarr.Http/Extensions/Pipelines/CorsPipeline.cs b/src/Prowlarr.Http/Extensions/Pipelines/CorsPipeline.cs deleted file mode 100644 index 53203b2ce..000000000 --- a/src/Prowlarr.Http/Extensions/Pipelines/CorsPipeline.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Linq; -using Nancy; -using Nancy.Bootstrapper; -using NzbDrone.Common.Extensions; - -namespace Prowlarr.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); - } - } - } - } -} diff --git a/src/Prowlarr.Http/Extensions/Pipelines/GZipPipeline.cs b/src/Prowlarr.Http/Extensions/Pipelines/GZipPipeline.cs deleted file mode 100644 index 0137d536b..000000000 --- a/src/Prowlarr.Http/Extensions/Pipelines/GZipPipeline.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using System.IO; -using System.IO.Compression; -using System.Linq; -using Nancy; -using Nancy.Bootstrapper; -using NLog; -using NzbDrone.Common.EnvironmentInfo; - -namespace Prowlarr.Http.Extensions.Pipelines -{ - public class GzipCompressionPipeline : IRegisterNancyPipeline - { - private readonly Logger _logger; - - public int Order => 0; - - private readonly Action, 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, 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 innerContent, Stream targetStream) - { - using (var membuffer = new MemoryStream()) - { - WriteGZipStream(innerContent, membuffer); - membuffer.Position = 0; - membuffer.CopyTo(targetStream); - } - } - - private static void WriteGZipStream(Action 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; - } - } -} diff --git a/src/Prowlarr.Http/Extensions/Pipelines/IRegisterNancyPipeline.cs b/src/Prowlarr.Http/Extensions/Pipelines/IRegisterNancyPipeline.cs deleted file mode 100644 index 89f1bdbe4..000000000 --- a/src/Prowlarr.Http/Extensions/Pipelines/IRegisterNancyPipeline.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Nancy.Bootstrapper; - -namespace Prowlarr.Http.Extensions.Pipelines -{ - public interface IRegisterNancyPipeline - { - int Order { get; } - - void Register(IPipelines pipelines); - } -} diff --git a/src/Prowlarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs b/src/Prowlarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs deleted file mode 100644 index 69f2591b6..000000000 --- a/src/Prowlarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using Nancy; -using Nancy.Bootstrapper; -using Prowlarr.Http.Frontend; - -namespace Prowlarr.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((Func)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; - } - } -} diff --git a/src/Prowlarr.Http/Extensions/Pipelines/ProwlarrVersionPipeline.cs b/src/Prowlarr.Http/Extensions/Pipelines/ProwlarrVersionPipeline.cs deleted file mode 100644 index 74e9b4fe7..000000000 --- a/src/Prowlarr.Http/Extensions/Pipelines/ProwlarrVersionPipeline.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using Nancy; -using Nancy.Bootstrapper; -using NzbDrone.Common.EnvironmentInfo; - -namespace Prowlarr.Http.Extensions.Pipelines -{ - public class ProwlarrVersionPipeline : IRegisterNancyPipeline - { - public int Order => 0; - - public void Register(IPipelines pipelines) - { - pipelines.AfterRequest.AddItemToStartOfPipeline((Action)Handle); - } - - private void Handle(NancyContext context) - { - if (!context.Response.Headers.ContainsKey("X-ApplicationVersion")) - { - context.Response.Headers.Add("X-ApplicationVersion", BuildInfo.Version.ToString()); - } - } - } -} diff --git a/src/Prowlarr.Http/Extensions/Pipelines/RequestLoggingPipeline.cs b/src/Prowlarr.Http/Extensions/Pipelines/RequestLoggingPipeline.cs deleted file mode 100644 index 300a4a578..000000000 --- a/src/Prowlarr.Http/Extensions/Pipelines/RequestLoggingPipeline.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using System.Threading; -using Nancy; -using Nancy.Bootstrapper; -using NLog; -using NzbDrone.Common.Extensions; -using Prowlarr.Http.ErrorManagement; -using Prowlarr.Http.Extensions; -using Prowlarr.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 ProwlarrErrorPipeline _errorPipeline; - - public RequestLoggingPipeline(ProwlarrErrorPipeline errorPipeline) - { - _errorPipeline = errorPipeline; - } - - public int Order => 100; - - public void Register(IPipelines pipelines) - { - pipelines.BeforeRequest.AddItemToStartOfPipeline(LogStart); - pipelines.AfterRequest.AddItemToEndOfPipeline(LogEnd); - pipelines.OnError.AddItemToEndOfPipeline(LogError); - } - - private Response LogStart(NancyContext context) - { - var id = Interlocked.Increment(ref _requestSequenceID); - - context.Items["ApiRequestSequenceID"] = id; - context.Items["ApiRequestStartTime"] = DateTime.UtcNow; - - var reqPath = GetRequestPathAndQuery(context.Request); - - _loggerHttp.Trace("Req: {0} [{1}] {2} (from {3})", id, context.Request.Method, reqPath, GetOrigin(context)); - return null; - } - - private void LogEnd(NancyContext context) - { - var id = (int)context.Items["ApiRequestSequenceID"]; - var startTime = (DateTime)context.Items["ApiRequestStartTime"]; - - var endTime = DateTime.UtcNow; - var duration = endTime - startTime; - - var reqPath = GetRequestPathAndQuery(context.Request); - - _loggerHttp.Trace("Res: {0} [{1}] {2}: {3}.{4} ({5} ms)", id, context.Request.Method, reqPath, (int)context.Response.StatusCode, context.Response.StatusCode, (int)duration.TotalMilliseconds); - - if (context.Request.IsApiRequest()) - { - _loggerApi.Debug("[{0}] {1}: {2}.{3} ({4} ms)", context.Request.Method, reqPath, (int)context.Response.StatusCode, context.Response.StatusCode, (int)duration.TotalMilliseconds); - } - } - - private Response LogError(NancyContext context, Exception exception) - { - var response = _errorPipeline.HandleException(context, exception); - - context.Response = response; - - LogEnd(context); - - context.Response = null; - - return response; - } - - private static string GetRequestPathAndQuery(Request request) - { - if (request.Url.Query.IsNotNullOrWhiteSpace()) - { - return string.Concat(request.Url.Path, request.Url.Query); - } - else - { - return request.Url.Path; - } - } - - private static string GetOrigin(NancyContext context) - { - if (context.Request.Headers.UserAgent.IsNullOrWhiteSpace()) - { - return context.GetRemoteIP(); - } - else - { - return $"{context.GetRemoteIP()} {context.Request.Headers.UserAgent}"; - } - } - } -} diff --git a/src/Prowlarr.Http/Extensions/Pipelines/UrlBasePipeline.cs b/src/Prowlarr.Http/Extensions/Pipelines/UrlBasePipeline.cs deleted file mode 100644 index 42d4659a7..000000000 --- a/src/Prowlarr.Http/Extensions/Pipelines/UrlBasePipeline.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using Nancy; -using Nancy.Bootstrapper; -using Nancy.Responses; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Configuration; - -namespace Prowlarr.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((Func)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; - } - } -} diff --git a/src/Prowlarr.Http/Extensions/ReqResExtensions.cs b/src/Prowlarr.Http/Extensions/ReqResExtensions.cs deleted file mode 100644 index 864dc2a7e..000000000 --- a/src/Prowlarr.Http/Extensions/ReqResExtensions.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Nancy; -using Nancy.Responses; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Serializer; - -namespace Prowlarr.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(this Stream body) - where T : class, new() - { - return FromJson(body, typeof(T)); - } - - public static T FromJson(this Stream body, Type type) - { - return (T)FromJson(body, type); - } - - public static object FromJson(this Stream body, Type type) - { - body.Position = 0; - return STJson.Deserialize(body, type); - } - - public static JsonResponse AsResponse(this TModel model, NancyContext context, HttpStatusCode statusCode = HttpStatusCode.OK) - { - var response = new JsonResponse(model, NancySerializer, context.Environment) { StatusCode = statusCode }; - response.Headers.DisableCache(); - - return response; - } - - public static IDictionary DisableCache(this IDictionary 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 EnableCache(this IDictionary 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; - } - } -} diff --git a/src/Prowlarr.Http/Extensions/RequestExtensions.cs b/src/Prowlarr.Http/Extensions/RequestExtensions.cs index baf76f19c..1bcfe81b4 100644 --- a/src/Prowlarr.Http/Extensions/RequestExtensions.cs +++ b/src/Prowlarr.Http/Extensions/RequestExtensions.cs @@ -1,107 +1,137 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net; -using Nancy; +using Microsoft.AspNetCore.Http; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Exceptions; namespace Prowlarr.Http.Extensions { public static class RequestExtensions { - public static bool IsApiRequest(this Request request) + public static bool IsApiRequest(this HttpRequest request) { - return request.Path.StartsWith("/api/", StringComparison.InvariantCultureIgnoreCase); + return request.Path.StartsWithSegments("/api", StringComparison.InvariantCultureIgnoreCase); } - public static bool IsFeedRequest(this Request request) + public static bool GetBooleanQueryParameter(this HttpRequest request, string parameter, bool defaultValue = false) { - return request.Path.StartsWith("/feed/", StringComparison.InvariantCultureIgnoreCase); - } - - public static bool IsSignalRRequest(this Request request) - { - return request.Path.StartsWith("/signalr/", StringComparison.InvariantCultureIgnoreCase); - } + var parameterValue = request.Query[parameter]; - public static bool IsLocalRequest(this Request request) - { - return request.UserHostAddress.Equals("localhost") || - request.UserHostAddress.Equals("127.0.0.1") || - request.UserHostAddress.Equals("::1"); - } + if (parameterValue.Any()) + { + return bool.Parse(parameterValue.ToString()); + } - public static bool IsLoginRequest(this Request request) - { - return request.Path.Equals("/login", StringComparison.InvariantCultureIgnoreCase); + return defaultValue; } - public static bool IsContentRequest(this Request request) + public static PagingResource ReadPagingResourceFromRequest(this HttpRequest request) { - return request.Path.StartsWith("/Content/", StringComparison.InvariantCultureIgnoreCase); - } + if (!int.TryParse(request.Query["PageSize"].ToString(), out var pageSize)) + { + pageSize = 10; + } - public static bool IsSharedContentRequest(this Request request) - { - return request.Path.StartsWith("/MediaCover/", StringComparison.InvariantCultureIgnoreCase) || - request.Path.StartsWith("/Content/Images/", StringComparison.InvariantCultureIgnoreCase); - } + if (!int.TryParse(request.Query["Page"].ToString(), out var page)) + { + page = 1; + } - public static bool GetBooleanQueryParameter(this Request request, string parameter, bool defaultValue = false) - { - var parameterValue = request.Query[parameter]; + var pagingResource = new PagingResource + { + PageSize = pageSize, + Page = page, + Filters = new List() + }; - if (parameterValue.HasValue) + if (request.Query["SortKey"].Any()) { - return bool.Parse(parameterValue.Value); + var sortKey = request.Query["SortKey"].ToString(); + + pagingResource.SortKey = sortKey; + + if (request.Query["SortDirection"].Any()) + { + pagingResource.SortDirection = request.Query["SortDirection"].ToString() + .Equals("ascending", StringComparison.InvariantCultureIgnoreCase) + ? SortDirection.Ascending + : SortDirection.Descending; + } } - return defaultValue; - } + // For backwards compatibility with v2 + if (request.Query["FilterKey"].Any()) + { + var filter = new PagingResourceFilter + { + Key = request.Query["FilterKey"].ToString() + }; - public static int GetIntegerQueryParameter(this Request request, string parameter, int defaultValue = 0) - { - var parameterValue = request.Query[parameter]; + if (request.Query["FilterValue"].Any()) + { + filter.Value = request.Query["FilterValue"].ToString(); + } - if (parameterValue.HasValue) + pagingResource.Filters.Add(filter); + } + + // v3 uses filters in key=value format + foreach (var pair in request.Query) { - return int.Parse(parameterValue.Value); + pagingResource.Filters.Add(new PagingResourceFilter + { + Key = pair.Key, + Value = pair.Value.ToString() + }); } - return defaultValue; + return pagingResource; } - public static int? GetNullableIntegerQueryParameter(this Request request, string parameter, int? defaultValue = null) + public static PagingResource ApplyToPage(this PagingSpec pagingSpec, Func, PagingSpec> function, Converter mapper) { - var parameterValue = request.Query[parameter]; + pagingSpec = function(pagingSpec); - if (parameterValue.HasValue) + return new PagingResource { - return int.Parse(parameterValue.Value); - } + Page = pagingSpec.Page, + PageSize = pagingSpec.PageSize, + SortDirection = pagingSpec.SortDirection, + SortKey = pagingSpec.SortKey, + TotalRecords = pagingSpec.TotalRecords, + Records = pagingSpec.Records.ConvertAll(mapper) + }; + } - return defaultValue; + public static string GetRemoteIP(this HttpContext context) + { + return context?.Request?.GetRemoteIP() ?? "Unknown"; } - public static string GetRemoteIP(this NancyContext context) + public static string GetRemoteIP(this HttpRequest request) { - if (context == null || context.Request == null) + if (request == null) { return "Unknown"; } - var remoteAddress = context.Request.UserHostAddress; - IPAddress remoteIP; + var remoteIP = request.HttpContext.Connection.RemoteIpAddress; + var remoteAddress = remoteIP.ToString(); // Only check if forwarded by a local network reverse proxy - if (IPAddress.TryParse(remoteAddress, out remoteIP) && remoteIP.IsLocalAddress()) + if (remoteIP.IsLocalAddress()) { - var realIPHeader = context.Request.Headers["X-Real-IP"]; + var realIPHeader = request.Headers["X-Real-IP"]; if (realIPHeader.Any()) { return realIPHeader.First().ToString(); } - var forwardedForHeader = context.Request.Headers["X-Forwarded-For"]; + var forwardedForHeader = request.Headers["X-Forwarded-For"]; if (forwardedForHeader.Any()) { // Get the first address that was forwarded by a local IP to prevent remote clients faking another proxy @@ -125,16 +155,16 @@ namespace Prowlarr.Http.Extensions return remoteAddress; } - public static string GetServerUrl(this Request request) + public static string GetServerUrl(this HttpRequest request) { - var scheme = request.Url.Scheme; - var port = request.Url.Port; + var scheme = request.Scheme; + var port = request.HttpContext.Connection.LocalPort; // Check for protocol headers added by reverse proxys // X-Forwarded-Proto: A de facto standard for identifying the originating protocol of an HTTP request var xForwardedProto = request.Headers.Where(x => x.Key == "X-Forwarded-Proto").Select(x => x.Value).FirstOrDefault(); - if (xForwardedProto != null) + if (xForwardedProto.Any()) { scheme = xForwardedProto.First(); } @@ -146,12 +176,25 @@ namespace Prowlarr.Http.Extensions } //default to 443 if the Host header doesn't contain the port (needed for reverse proxy setups) - if (scheme == "https" && !request.Url.HostName.Contains(":")) + if (scheme == "https" && !request.Host.Port.HasValue) { port = 443; } - return $"{scheme}://{request.Url.HostName}:{port}"; + return $"{scheme}://{request.Host.Host}:{port}"; + } + + public static void DisableCache(this IHeaderDictionary headers) + { + headers["Cache-Control"] = "no-cache, no-store"; + headers["Expires"] = "-1"; + headers["Pragma"] = "no-cache"; + } + + public static void EnableCache(this IHeaderDictionary headers) + { + headers["Cache-Control"] = "max-age=31536000, public"; + headers["Last-Modified"] = BuildInfo.BuildDateTime.ToString("r"); } } } diff --git a/src/Prowlarr.Http/Extensions/ResponseExtensions.cs b/src/Prowlarr.Http/Extensions/ResponseExtensions.cs deleted file mode 100644 index bc3eb3e98..000000000 --- a/src/Prowlarr.Http/Extensions/ResponseExtensions.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.IO; -using Nancy; - -namespace NzbDrone.Http.Extensions -{ - public static class ResponseExtensions - { - public static Response FromByteArray(this IResponseFormatter formatter, byte[] body, string contentType = null) - { - return new ByteArrayResponse(body, contentType); - } - } - - public class ByteArrayResponse : Response - { - public ByteArrayResponse(byte[] body, string contentType = null) - { - this.ContentType = contentType ?? "application/octet-stream"; - - this.Contents = stream => - { - using (var writer = new BinaryWriter(stream)) - { - writer.Write(body); - } - }; - } - } -} diff --git a/src/Prowlarr.Http/Frontend/CacheableSpecification.cs b/src/Prowlarr.Http/Frontend/CacheableSpecification.cs deleted file mode 100644 index 33d7d62e4..000000000 --- a/src/Prowlarr.Http/Frontend/CacheableSpecification.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using Nancy; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Extensions; - -namespace Prowlarr.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; - } - } -} diff --git a/src/Prowlarr.Http/Frontend/InitializeJsModule.cs b/src/Prowlarr.Http/Frontend/InitializeJsController.cs similarity index 68% rename from src/Prowlarr.Http/Frontend/InitializeJsModule.cs rename to src/Prowlarr.Http/Frontend/InitializeJsController.cs index 98bc65907..947371e88 100644 --- a/src/Prowlarr.Http/Frontend/InitializeJsModule.cs +++ b/src/Prowlarr.Http/Frontend/InitializeJsController.cs @@ -1,7 +1,6 @@ -using System.IO; using System.Text; -using Nancy; -using Nancy.Responses; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Analytics; @@ -9,7 +8,9 @@ using NzbDrone.Core.Configuration; namespace Prowlarr.Http.Frontend { - public class InitializeJsModule : NancyModule + [Authorize(Policy = "UI")] + [ApiController] + public class InitializeJsController : Controller { private readonly IConfigFileProvider _configFileProvider; private readonly IAnalyticsService _analyticsService; @@ -18,35 +19,21 @@ namespace Prowlarr.Http.Frontend private static string _urlBase; private string _generatedContent; - public InitializeJsModule(IConfigFileProvider configFileProvider, - IAnalyticsService analyticsService) + public InitializeJsController(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.Sonarr and prefetch the information returned here when starting the UI - return new StreamResponse(GetContentStream, "application/javascript"); } - private Stream GetContentStream() + [HttpGet("/initialize.js")] + public IActionResult Index() { - var text = GetContent(); - - var stream = new MemoryStream(); - var writer = new StreamWriter(stream); - writer.Write(text); - writer.Flush(); - stream.Position = 0; - - return stream; + // TODO: Move away from window.Prowlarr and prefetch the information returned here when starting the UI + return Content(GetContent(), "application/javascript"); } private string GetContent() diff --git a/src/Prowlarr.Http/Frontend/Mappers/HtmlMapperBase.cs b/src/Prowlarr.Http/Frontend/Mappers/HtmlMapperBase.cs index 1a94f545b..948ecda83 100644 --- a/src/Prowlarr.Http/Frontend/Mappers/HtmlMapperBase.cs +++ b/src/Prowlarr.Http/Frontend/Mappers/HtmlMapperBase.cs @@ -1,7 +1,6 @@ using System; using System.IO; using System.Text.RegularExpressions; -using Nancy; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; @@ -40,13 +39,14 @@ namespace Prowlarr.Http.Frontend.Mappers return stream; } - public override Response GetResponse(string resourceUrl) + /* + public override IActionResult GetResponse(string resourceUrl) { var response = base.GetResponse(resourceUrl); response.Headers["X-UA-Compatible"] = "IE=edge"; return response; - } + }*/ protected string GetHtmlText() { diff --git a/src/Prowlarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs b/src/Prowlarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs index 4cf62932e..ac356b23c 100644 --- a/src/Prowlarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs +++ b/src/Prowlarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs @@ -1,4 +1,4 @@ -using Nancy; +using Microsoft.AspNetCore.Mvc; namespace Prowlarr.Http.Frontend.Mappers { @@ -6,6 +6,6 @@ namespace Prowlarr.Http.Frontend.Mappers { string Map(string resourceUrl); bool CanHandle(string resourceUrl); - Response GetResponse(string resourceUrl); + IActionResult GetResponse(string resourceUrl); } } diff --git a/src/Prowlarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs b/src/Prowlarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs index a89751a10..578fc4ba8 100644 --- a/src/Prowlarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs +++ b/src/Prowlarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs @@ -1,7 +1,7 @@ using System; using System.IO; -using Nancy; -using Nancy.Responses; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; @@ -13,14 +13,14 @@ namespace Prowlarr.Http.Frontend.Mappers private readonly IDiskProvider _diskProvider; private readonly Logger _logger; private readonly StringComparison _caseSensitive; - - private static readonly NotFoundResponse NotFoundResponse = new NotFoundResponse(); + private readonly IContentTypeProvider _mimeTypeProvider; protected StaticResourceMapperBase(IDiskProvider diskProvider, Logger logger) { _diskProvider = diskProvider; _logger = logger; + _mimeTypeProvider = new FileExtensionContentTypeProvider(); _caseSensitive = RuntimeInfo.IsProduction ? DiskProviderBase.PathStringComparison : StringComparison.OrdinalIgnoreCase; } @@ -28,19 +28,23 @@ namespace Prowlarr.Http.Frontend.Mappers public abstract bool CanHandle(string resourceUrl); - public virtual Response GetResponse(string resourceUrl) + public virtual IActionResult 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); + if (!_mimeTypeProvider.TryGetContentType(filePath, out var contentType)) + { + contentType = "application/octet-stream"; + } + + return new FileStreamResult(GetContentStream(filePath), contentType); } _logger.Warn("File {0} not found", filePath); - return NotFoundResponse; + return null; } protected virtual Stream GetContentStream(string filePath) diff --git a/src/Prowlarr.Http/Frontend/StaticResourceController.cs b/src/Prowlarr.Http/Frontend/StaticResourceController.cs new file mode 100644 index 000000000..b634f7cec --- /dev/null +++ b/src/Prowlarr.Http/Frontend/StaticResourceController.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Configuration; +using Prowlarr.Http.Frontend.Mappers; + +namespace Prowlarr.Http.Frontend +{ + [Authorize(Policy="UI")] + [ApiController] + public class StaticResourceController : Controller + { + private readonly string _urlBase; + private readonly string _loginPath; + private readonly IEnumerable _requestMappers; + private readonly Logger _logger; + + public StaticResourceController(IConfigFileProvider configFileProvider, + IAppFolderInfo appFolderInfo, + IEnumerable requestMappers, + Logger logger) + { + _urlBase = configFileProvider.UrlBase.Trim('/'); + _requestMappers = requestMappers; + _logger = logger; + + _loginPath = Path.Combine(appFolderInfo.StartUpFolder, configFileProvider.UiFolder, "login.html"); + } + + [AllowAnonymous] + [HttpGet("login")] + public IActionResult LoginPage() + { + return PhysicalFile(_loginPath, "text/html"); + } + + [EnableCors("AllowGet")] + [AllowAnonymous] + [HttpGet("/content/{**path:regex(^(?!api/).*)}")] + public IActionResult IndexContent([FromRoute] string path) + { + return MapResource("Content/" + path); + } + + [HttpGet("")] + [HttpGet("/{**path:regex(^(?!api/).*)}")] + public IActionResult Index([FromRoute] string path) + { + return MapResource(path); + } + + private IActionResult MapResource(string path) + { + path = "/" + (path ?? ""); + + var mapper = _requestMappers.SingleOrDefault(m => m.CanHandle(path)); + + if (mapper != null) + { + return mapper.GetResponse(path) ?? NotFound(); + } + + _logger.Warn("Couldn't find handler for {0}", path); + + return NotFound(); + } + } +} diff --git a/src/Prowlarr.Http/Frontend/StaticResourceModule.cs b/src/Prowlarr.Http/Frontend/StaticResourceModule.cs deleted file mode 100644 index ce12fecdd..000000000 --- a/src/Prowlarr.Http/Frontend/StaticResourceModule.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Nancy; -using NLog; -using Prowlarr.Http.Frontend.Mappers; - -namespace Prowlarr.Http.Frontend -{ - public class StaticResourceModule : NancyModule - { - private readonly IEnumerable _requestMappers; - private readonly Logger _logger; - - public StaticResourceModule(IEnumerable 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(); - } - } -} diff --git a/src/Prowlarr.Http/Middleware/CacheHeaderMiddleware.cs b/src/Prowlarr.Http/Middleware/CacheHeaderMiddleware.cs new file mode 100644 index 000000000..9a7e9c08d --- /dev/null +++ b/src/Prowlarr.Http/Middleware/CacheHeaderMiddleware.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Prowlarr.Http.Extensions; + +namespace Prowlarr.Http.Middleware +{ + public class CacheHeaderMiddleware + { + private readonly RequestDelegate _next; + private readonly ICacheableSpecification _cacheableSpecification; + + public CacheHeaderMiddleware(RequestDelegate next, ICacheableSpecification cacheableSpecification) + { + _next = next; + _cacheableSpecification = cacheableSpecification; + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Method != "OPTIONS") + { + if (_cacheableSpecification.IsCacheable(context)) + { + context.Response.Headers.EnableCache(); + } + else + { + context.Response.Headers.DisableCache(); + } + } + + await _next(context); + } + } +} diff --git a/src/Prowlarr.Http/Middleware/CacheableSpecification.cs b/src/Prowlarr.Http/Middleware/CacheableSpecification.cs new file mode 100644 index 000000000..cb0173987 --- /dev/null +++ b/src/Prowlarr.Http/Middleware/CacheableSpecification.cs @@ -0,0 +1,74 @@ +using System; +using Microsoft.AspNetCore.Http; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; + +namespace Prowlarr.Http.Middleware +{ + public interface ICacheableSpecification + { + bool IsCacheable(HttpContext context); + } + + public class CacheableSpecification : ICacheableSpecification + { + public bool IsCacheable(HttpContext context) + { + if (!RuntimeInfo.IsProduction) + { + return false; + } + + if (context.Request.Query.ContainsKey("h")) + { + return true; + } + + if (context.Request.Path.StartsWithSegments("/api", StringComparison.CurrentCultureIgnoreCase)) + { + if (context.Request.Path.ToString().ContainsIgnoreCase("/MediaCover")) + { + return true; + } + + return false; + } + + if (context.Request.Path.StartsWithSegments("/signalr", StringComparison.CurrentCultureIgnoreCase)) + { + return false; + } + + if (context.Request.Path.Equals("/index.js")) + { + return false; + } + + if (context.Request.Path.Equals("/initialize.js")) + { + return false; + } + + if (context.Request.Path.StartsWithSegments("/feed", StringComparison.CurrentCultureIgnoreCase)) + { + return false; + } + + if (context.Request.Path.StartsWithSegments("/log", StringComparison.CurrentCultureIgnoreCase) && + context.Request.Path.ToString().EndsWith(".txt", StringComparison.CurrentCultureIgnoreCase)) + { + return false; + } + + if (context.Response != null) + { + if (context.Response.ContentType?.Contains("text/html") ?? false || context.Response.StatusCode >= 400) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/Prowlarr.Http/Middleware/IfModifiedMiddleware.cs b/src/Prowlarr.Http/Middleware/IfModifiedMiddleware.cs new file mode 100644 index 000000000..941f45c92 --- /dev/null +++ b/src/Prowlarr.Http/Middleware/IfModifiedMiddleware.cs @@ -0,0 +1,43 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.StaticFiles; +using Prowlarr.Http.Extensions; + +namespace Prowlarr.Http.Middleware +{ + public class IfModifiedMiddleware + { + private readonly RequestDelegate _next; + private readonly ICacheableSpecification _cacheableSpecification; + private readonly IContentTypeProvider _mimeTypeProvider; + + public IfModifiedMiddleware(RequestDelegate next, ICacheableSpecification cacheableSpecification) + { + _next = next; + _cacheableSpecification = cacheableSpecification; + + _mimeTypeProvider = new FileExtensionContentTypeProvider(); + } + + public async Task InvokeAsync(HttpContext context) + { + if (_cacheableSpecification.IsCacheable(context) && context.Request.Headers["IfModifiedSince"].Any()) + { + context.Response.StatusCode = 304; + context.Response.Headers.EnableCache(); + + if (!_mimeTypeProvider.TryGetContentType(context.Request.Path.ToString(), out var mimeType)) + { + mimeType = "application/octet-stream"; + } + + context.Response.ContentType = mimeType; + + return; + } + + await _next(context); + } + } +} diff --git a/src/Prowlarr.Http/Middleware/LoggingMiddleware.cs b/src/Prowlarr.Http/Middleware/LoggingMiddleware.cs new file mode 100644 index 000000000..19ea1124c --- /dev/null +++ b/src/Prowlarr.Http/Middleware/LoggingMiddleware.cs @@ -0,0 +1,92 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using NLog; +using NzbDrone.Common.Extensions; +using Prowlarr.Http.ErrorManagement; +using Prowlarr.Http.Extensions; + +namespace Prowlarr.Http.Middleware +{ + public class LoggingMiddleware + { + private static readonly Logger _loggerHttp = LogManager.GetLogger("Http"); + private static readonly Logger _loggerApi = LogManager.GetLogger("Api"); + private static int _requestSequenceID; + + private readonly ProwlarrErrorPipeline _errorHandler; + private readonly RequestDelegate _next; + + public LoggingMiddleware(RequestDelegate next, + ProwlarrErrorPipeline errorHandler) + { + _next = next; + _errorHandler = errorHandler; + } + + public async Task InvokeAsync(HttpContext context) + { + LogStart(context); + + await _next(context); + + LogEnd(context); + } + + private void LogStart(HttpContext context) + { + var id = Interlocked.Increment(ref _requestSequenceID); + + context.Items["ApiRequestSequenceID"] = id; + context.Items["ApiRequestStartTime"] = DateTime.UtcNow; + + var reqPath = GetRequestPathAndQuery(context.Request); + + _loggerHttp.Trace("Req: {0} [{1}] {2} (from {3})", id, context.Request.Method, reqPath, GetOrigin(context)); + } + + private void LogEnd(HttpContext 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, context.Response.StatusCode, (HttpStatusCode)context.Response.StatusCode, (int)duration.TotalMilliseconds); + + if (context.Request.IsApiRequest()) + { + _loggerApi.Debug("[{0}] {1}: {2}.{3} ({4} ms)", context.Request.Method, reqPath, context.Response.StatusCode, (HttpStatusCode)context.Response.StatusCode, (int)duration.TotalMilliseconds); + } + } + + private static string GetRequestPathAndQuery(HttpRequest request) + { + if (request.QueryString.Value.IsNotNullOrWhiteSpace() && request.QueryString.Value != "?") + { + return string.Concat(request.Path, request.QueryString); + } + else + { + return request.Path; + } + } + + private static string GetOrigin(HttpContext context) + { + if (context.Request.Headers["UserAgent"].ToString().IsNullOrWhiteSpace()) + { + return context.GetRemoteIP(); + } + else + { + return $"{context.GetRemoteIP()} {context.Request.Headers["UserAgent"]}"; + } + } + } +} diff --git a/src/Prowlarr.Http/Middleware/UrlBaseMiddleware.cs b/src/Prowlarr.Http/Middleware/UrlBaseMiddleware.cs new file mode 100644 index 000000000..4cd37c24e --- /dev/null +++ b/src/Prowlarr.Http/Middleware/UrlBaseMiddleware.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using NzbDrone.Common.Extensions; + +namespace Prowlarr.Http.Middleware +{ + public class UrlBaseMiddleware + { + private readonly RequestDelegate _next; + private readonly string _urlBase; + + public UrlBaseMiddleware(RequestDelegate next, string urlBase) + { + _next = next; + _urlBase = urlBase; + } + + public async Task InvokeAsync(HttpContext context) + { + if (_urlBase.IsNotNullOrWhiteSpace() && context.Request.PathBase.Value.IsNullOrWhiteSpace()) + { + context.Response.Redirect($"{_urlBase}{context.Request.Path}{context.Request.QueryString}"); + return; + } + + await _next(context); + } + } +} diff --git a/src/Prowlarr.Http/Middleware/VersionMiddleware.cs b/src/Prowlarr.Http/Middleware/VersionMiddleware.cs new file mode 100644 index 000000000..107130941 --- /dev/null +++ b/src/Prowlarr.Http/Middleware/VersionMiddleware.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using NzbDrone.Common.EnvironmentInfo; + +namespace Prowlarr.Http.Middleware +{ + public class VersionMiddleware + { + private const string VERSIONHEADER = "X-ApplicationVersion"; + + private readonly RequestDelegate _next; + private readonly string _version; + + public VersionMiddleware(RequestDelegate next) + { + _next = next; + _version = BuildInfo.Version.ToString(); + } + + public async Task InvokeAsync(HttpContext context) + { + if (!context.Response.Headers.ContainsKey(VERSIONHEADER)) + { + context.Response.Headers.Add(VERSIONHEADER, _version); + } + + await _next(context); + } + } +} diff --git a/src/Prowlarr.Http/Prowlarr.Http.csproj b/src/Prowlarr.Http/Prowlarr.Http.csproj index f47044db6..4f7b884f4 100644 --- a/src/Prowlarr.Http/Prowlarr.Http.csproj +++ b/src/Prowlarr.Http/Prowlarr.Http.csproj @@ -4,9 +4,7 @@ - - - + diff --git a/src/Prowlarr.Http/ProwlarrBootstrapper.cs b/src/Prowlarr.Http/ProwlarrBootstrapper.cs deleted file mode 100644 index 58d836b80..000000000 --- a/src/Prowlarr.Http/ProwlarrBootstrapper.cs +++ /dev/null @@ -1,75 +0,0 @@ -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 Prowlarr.Http.Extensions.Pipelines; -using TinyIoC; - -namespace Prowlarr.Http -{ - public class ProwlarrBootstrapper : TinyIoCNancyBootstrapper - { - private readonly TinyIoCContainer _tinyIoCContainer; - private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(ProwlarrBootstrapper)); - - public ProwlarrBootstrapper(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().Register(); - } - - private void RegisterPipelines(IPipelines pipelines) - { - var pipelineRegistrars = _tinyIoCContainer.ResolveAll().OrderBy(v => v.Order).ToList(); - - foreach (var registerNancyPipeline in pipelineRegistrars) - { - registerNancyPipeline.Register(pipelines); - } - } - - protected override TinyIoCContainer GetApplicationContainer() - { - return _tinyIoCContainer; - } - - protected override Func 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; - } -} diff --git a/src/Prowlarr.Http/ProwlarrModule.cs b/src/Prowlarr.Http/ProwlarrModule.cs deleted file mode 100644 index 81583d4eb..000000000 --- a/src/Prowlarr.Http/ProwlarrModule.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Nancy; -using Nancy.Responses.Negotiation; - -namespace Prowlarr.Http -{ - public abstract class ProwlarrModule : NancyModule - { - protected ProwlarrModule(string resource) - : base(resource) - { - } - - protected Negotiator ResponseWithCode(object model, HttpStatusCode statusCode) - { - return Negotiate.WithModel(model).WithStatusCode(statusCode); - } - } -} diff --git a/src/Prowlarr.Http/ProwlarrRestModule.cs b/src/Prowlarr.Http/ProwlarrRestModule.cs deleted file mode 100644 index 6a74025e1..000000000 --- a/src/Prowlarr.Http/ProwlarrRestModule.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using NzbDrone.Core.Datastore; -using Prowlarr.Http.REST; -using Prowlarr.Http.Validation; - -namespace Prowlarr.Http -{ - public abstract class ProwlarrRestModule : RestModule - 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 ProwlarrRestModule() - : this(ResourceName()) - { - } - - protected ProwlarrRestModule(string resource) - : base(BaseUrl() + resource.Trim('/').ToLower()) - { - Resource = resource; - PostValidator.RuleFor(r => r.Id).IsZero(); - PutValidator.RuleFor(r => r.Id).ValidId(); - } - - protected PagingResource ApplyToPage(Func, PagingSpec> function, PagingSpec pagingSpec, Converter mapper) - { - pagingSpec = function(pagingSpec); - - return new PagingResource - { - Page = pagingSpec.Page, - PageSize = pagingSpec.PageSize, - SortDirection = pagingSpec.SortDirection, - SortKey = pagingSpec.SortKey, - TotalRecords = pagingSpec.TotalRecords, - Records = pagingSpec.Records.ConvertAll(mapper) - }; - } - } -} diff --git a/src/Prowlarr.Http/REST/Attributes/RestDeleteByIdAttribute.cs b/src/Prowlarr.Http/REST/Attributes/RestDeleteByIdAttribute.cs new file mode 100644 index 000000000..296771db8 --- /dev/null +++ b/src/Prowlarr.Http/REST/Attributes/RestDeleteByIdAttribute.cs @@ -0,0 +1,14 @@ +using System; +using Microsoft.AspNetCore.Mvc; + +namespace NzbDrone.Http.REST.Attributes +{ + [AttributeUsage(AttributeTargets.Method)] + public class RestDeleteByIdAttribute : HttpDeleteAttribute + { + public RestDeleteByIdAttribute() + : base("{id:int}") + { + } + } +} diff --git a/src/Prowlarr.Http/REST/Attributes/RestGetByIdAttribute.cs b/src/Prowlarr.Http/REST/Attributes/RestGetByIdAttribute.cs new file mode 100644 index 000000000..870b91e10 --- /dev/null +++ b/src/Prowlarr.Http/REST/Attributes/RestGetByIdAttribute.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace NzbDrone.Http.REST.Attributes +{ + [AttributeUsage(AttributeTargets.Method)] + public class RestGetByIdAttribute : ActionFilterAttribute, IActionHttpMethodProvider, IRouteTemplateProvider + { + public override void OnActionExecuting(ActionExecutingContext context) + { + Console.WriteLine($"OnExecuting {context.Controller.GetType()} {context.ActionDescriptor.DisplayName}"); + } + + public IEnumerable HttpMethods => new[] { "GET" }; + public string Template => "{id:int}"; + public new int? Order => 0; + public string Name { get; } + } +} diff --git a/src/Prowlarr.Http/REST/Attributes/RestPostByIdAttribute.cs b/src/Prowlarr.Http/REST/Attributes/RestPostByIdAttribute.cs new file mode 100644 index 000000000..680f04a81 --- /dev/null +++ b/src/Prowlarr.Http/REST/Attributes/RestPostByIdAttribute.cs @@ -0,0 +1,10 @@ +using System; +using Microsoft.AspNetCore.Mvc; + +namespace NzbDrone.Http.REST.Attributes +{ + [AttributeUsage(AttributeTargets.Method)] + public class RestPostByIdAttribute : HttpPostAttribute + { + } +} diff --git a/src/Prowlarr.Http/REST/Attributes/RestPutByIdAttribute.cs b/src/Prowlarr.Http/REST/Attributes/RestPutByIdAttribute.cs new file mode 100644 index 000000000..153e0c9d3 --- /dev/null +++ b/src/Prowlarr.Http/REST/Attributes/RestPutByIdAttribute.cs @@ -0,0 +1,14 @@ +using System; +using Microsoft.AspNetCore.Mvc; + +namespace NzbDrone.Http.REST.Attributes +{ + [AttributeUsage(AttributeTargets.Method)] + public class RestPutByIdAttribute : HttpPutAttribute + { + public RestPutByIdAttribute() + : base("{id:int?}") + { + } + } +} diff --git a/src/Prowlarr.Http/REST/Attributes/SkipValidationAttribute.cs b/src/Prowlarr.Http/REST/Attributes/SkipValidationAttribute.cs new file mode 100644 index 000000000..4101a89a4 --- /dev/null +++ b/src/Prowlarr.Http/REST/Attributes/SkipValidationAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace NzbDrone.Http.REST.Attributes +{ + [AttributeUsage(AttributeTargets.Method)] + public class SkipValidationAttribute : Attribute + { + public SkipValidationAttribute(bool skip = true, bool skipShared = true) + { + Skip = skip; + SkipShared = skipShared; + } + + public bool Skip { get; } + public bool SkipShared { get; } + } +} diff --git a/src/Prowlarr.Http/REST/BadRequestException.cs b/src/Prowlarr.Http/REST/BadRequestException.cs index 5be65cf4b..90eca565d 100644 --- a/src/Prowlarr.Http/REST/BadRequestException.cs +++ b/src/Prowlarr.Http/REST/BadRequestException.cs @@ -1,4 +1,4 @@ -using Nancy; +using System.Net; using Prowlarr.Http.Exceptions; namespace Prowlarr.Http.REST diff --git a/src/Prowlarr.Http/REST/MethodNotAllowedException.cs b/src/Prowlarr.Http/REST/MethodNotAllowedException.cs index 9563c2780..85bb0e1a2 100644 --- a/src/Prowlarr.Http/REST/MethodNotAllowedException.cs +++ b/src/Prowlarr.Http/REST/MethodNotAllowedException.cs @@ -1,4 +1,4 @@ -using Nancy; +using System.Net; using Prowlarr.Http.Exceptions; namespace Prowlarr.Http.REST diff --git a/src/Prowlarr.Http/REST/NotFoundException.cs b/src/Prowlarr.Http/REST/NotFoundException.cs index 7a57c1542..3db463ec9 100644 --- a/src/Prowlarr.Http/REST/NotFoundException.cs +++ b/src/Prowlarr.Http/REST/NotFoundException.cs @@ -1,4 +1,4 @@ -using Nancy; +using System.Net; using Prowlarr.Http.Exceptions; namespace Prowlarr.Http.REST diff --git a/src/Prowlarr.Http/REST/RestController.cs b/src/Prowlarr.Http/REST/RestController.cs new file mode 100644 index 000000000..7992f6f6f --- /dev/null +++ b/src/Prowlarr.Http/REST/RestController.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using NzbDrone.Core.Datastore; +using NzbDrone.Http.REST.Attributes; +using Prowlarr.Http.Validation; + +namespace Prowlarr.Http.REST +{ + public abstract class RestController : Controller + where TResource : RestResource, new() + { + private static readonly List VALIDATE_ID_ATTRIBUTES = new List { typeof(RestPutByIdAttribute), typeof(RestDeleteByIdAttribute) }; + + protected ResourceValidator PostValidator { get; private set; } + protected ResourceValidator PutValidator { get; private set; } + protected ResourceValidator SharedValidator { get; private set; } + + protected void ValidateId(int id) + { + if (id <= 0) + { + throw new BadRequestException(id + " is not a valid ID"); + } + } + + protected RestController() + { + PostValidator = new ResourceValidator(); + PutValidator = new ResourceValidator(); + SharedValidator = new ResourceValidator(); + + PutValidator.RuleFor(r => r.Id).ValidId(); + } + + [RestGetById] + public abstract TResource GetResourceById(int id); + + public override void OnActionExecuting(ActionExecutingContext context) + { + var descriptor = context.ActionDescriptor as ControllerActionDescriptor; + + var skipAttribute = (SkipValidationAttribute)Attribute.GetCustomAttribute(descriptor.MethodInfo, typeof(SkipValidationAttribute), true); + var skipValidate = skipAttribute?.Skip ?? false; + var skipShared = skipAttribute?.SkipShared ?? false; + + if (Request.Method == "POST" || Request.Method == "PUT") + { + var resourceArgs = context.ActionArguments.Values.Where(x => x.GetType() == typeof(TResource)) + .Select(x => x as TResource) + .ToList(); + + foreach (var resource in resourceArgs) + { + ValidateResource(resource, skipValidate, skipShared); + } + } + + var attributes = descriptor.MethodInfo.CustomAttributes; + if (attributes.Any(x => VALIDATE_ID_ATTRIBUTES.Contains(x.GetType())) && !skipValidate) + { + if (context.ActionArguments.TryGetValue("id", out var idObj)) + { + ValidateId((int)idObj); + } + } + + base.OnActionExecuting(context); + } + + public override void OnActionExecuted(ActionExecutedContext context) + { + var descriptor = context.ActionDescriptor as ControllerActionDescriptor; + + var attributes = descriptor.MethodInfo.CustomAttributes; + + if (context.Exception?.GetType() == typeof(ModelNotFoundException) && + attributes.Any(x => x.AttributeType == typeof(RestGetByIdAttribute))) + { + context.Result = new NotFoundResult(); + } + } + + protected void ValidateResource(TResource resource, bool skipValidate = false, bool skipSharedValidate = false) + { + if (resource == null) + { + throw new BadRequestException("Request body can't be empty"); + } + + var errors = new List(); + + if (!skipSharedValidate) + { + errors.AddRange(SharedValidator.Validate(resource).Errors); + } + + if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !skipValidate && !Request.Path.ToString().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); + } + } + + protected ActionResult Accepted(int id) + { + var result = GetResourceById(id); + return AcceptedAtAction(nameof(GetResourceById), new { id = id }, result); + } + + protected ActionResult Created(int id) + { + var result = GetResourceById(id); + return CreatedAtAction(nameof(GetResourceById), new { id = id }, result); + } + } +} diff --git a/src/Prowlarr.Http/ProwlarrRestModuleWithSignalR.cs b/src/Prowlarr.Http/REST/RestControllerWithSignalR.cs similarity index 76% rename from src/Prowlarr.Http/ProwlarrRestModuleWithSignalR.cs rename to src/Prowlarr.Http/REST/RestControllerWithSignalR.cs index 2101e4267..c2b52f11f 100644 --- a/src/Prowlarr.Http/ProwlarrRestModuleWithSignalR.cs +++ b/src/Prowlarr.Http/REST/RestControllerWithSignalR.cs @@ -1,28 +1,35 @@ +using System.Reflection; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.SignalR; -using Prowlarr.Http.REST; -namespace Prowlarr.Http +namespace Prowlarr.Http.REST { - public abstract class ProwlarrRestModuleWithSignalR : ProwlarrRestModule, IHandle> + public abstract class RestControllerWithSignalR : RestController, IHandle> where TResource : RestResource, new() where TModel : ModelBase, new() { + protected string Resource { get; } private readonly IBroadcastSignalRMessage _signalRBroadcaster; - protected ProwlarrRestModuleWithSignalR(IBroadcastSignalRMessage signalRBroadcaster) + protected RestControllerWithSignalR(IBroadcastSignalRMessage signalRBroadcaster) { _signalRBroadcaster = signalRBroadcaster; - } - protected ProwlarrRestModuleWithSignalR(IBroadcastSignalRMessage signalRBroadcaster, string resource) - : base(resource) - { - _signalRBroadcaster = signalRBroadcaster; + var apiAttribute = GetType().GetCustomAttribute(); + if (apiAttribute != null && apiAttribute.Resource != VersionedApiControllerAttribute.CONTROLLER_RESOURCE) + { + Resource = apiAttribute.Resource; + } + else + { + Resource = new TResource().ResourceName.Trim('/'); + } } + [NonAction] public void Handle(ModelEvent message) { if (!_signalRBroadcaster.IsConnected) diff --git a/src/Prowlarr.Http/REST/RestModule.cs b/src/Prowlarr.Http/REST/RestModule.cs deleted file mode 100644 index 678ad76c4..000000000 --- a/src/Prowlarr.Http/REST/RestModule.cs +++ /dev/null @@ -1,350 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using FluentValidation; -using FluentValidation.Results; -using Nancy; -using Nancy.Responses.Negotiation; -using NzbDrone.Core.Datastore; -using Prowlarr.Http.Extensions; - -namespace Prowlarr.Http.REST -{ - public abstract class RestModule : NancyModule - where TResource : RestResource, new() - { - private const string ROOT_ROUTE = "/"; - private const string ID_ROUTE = @"/(?[\d]{1,10})"; - - private readonly HashSet _excludedKeys = new HashSet(StringComparer.InvariantCultureIgnoreCase) - { - "page", - "pageSize", - "sortKey", - "sortDirection", - "filterKey", - "filterValue", - }; - - private Action _deleteResource; - private Func _getResourceById; - private Func> _getResourceAll; - private Func, PagingResource> _getResourcePaged; - private Func _getResourceSingle; - private Func _createResource; - private Action _updateResource; - - protected ResourceValidator PostValidator { get; private set; } - protected ResourceValidator PutValidator { get; private set; } - protected ResourceValidator 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(); - PutValidator = new ResourceValidator(); - SharedValidator = new ResourceValidator(); - } - - 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 DeleteResource - { - private get - { - return _deleteResource; - } - - set - { - _deleteResource = value; - Delete(ID_ROUTE, options => - { - ValidateId(options.Id); - DeleteResource((int)options.Id); - - return new object(); - }); - } - } - - protected Func 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> GetResourceAll - { - private get - { - return _getResourceAll; - } - - set - { - _getResourceAll = value; - Get(ROOT_ROUTE, options => - { - var resource = GetResourceAll(); - return resource; - }); - } - } - - protected Func, PagingResource> GetResourcePaged - { - private get - { - return _getResourcePaged; - } - - set - { - _getResourcePaged = value; - Get(ROOT_ROUTE, options => - { - var resource = GetResourcePaged(ReadPagingResourceFromRequest()); - return resource; - }); - } - } - - protected Func GetResourceSingle - { - private get - { - return _getResourceSingle; - } - - set - { - _getResourceSingle = value; - Get(ROOT_ROUTE, options => - { - var resource = GetResourceSingle(); - return resource; - }); - } - } - - protected Func CreateResource - { - private get - { - return _createResource; - } - - set - { - _createResource = value; - Post(ROOT_ROUTE, options => - { - var id = CreateResource(ReadResourceFromRequest()); - return ResponseWithCode(GetResourceById(id), HttpStatusCode.Created); - }); - } - } - - protected Action 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, bool skipSharedValidate = false) - { - TResource resource; - - try - { - resource = Request.Body.FromJson(); - } - catch (JsonException e) - { - throw new BadRequestException($"Invalid request body. {e.Message}"); - } - - if (resource == null) - { - throw new BadRequestException("Request body can't be empty"); - } - - var errors = new List(); - - if (!skipSharedValidate) - { - errors.AddRange(SharedValidator.Validate(resource).Errors); - } - - 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 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 - { - PageSize = pageSize, - Page = page, - Filters = new List() - }; - - 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; - } - } -} diff --git a/src/Prowlarr.Http/REST/UnsupportedMediaTypeException.cs b/src/Prowlarr.Http/REST/UnsupportedMediaTypeException.cs index f2fd02fa4..3bd732d2a 100644 --- a/src/Prowlarr.Http/REST/UnsupportedMediaTypeException.cs +++ b/src/Prowlarr.Http/REST/UnsupportedMediaTypeException.cs @@ -1,4 +1,4 @@ -using Nancy; +using System.Net; using Prowlarr.Http.Exceptions; namespace Prowlarr.Http.REST diff --git a/src/Prowlarr.Http/TinyIoCNancyBootstrapper.cs b/src/Prowlarr.Http/TinyIoCNancyBootstrapper.cs deleted file mode 100644 index ffb5d6e26..000000000 --- a/src/Prowlarr.Http/TinyIoCNancyBootstrapper.cs +++ /dev/null @@ -1,273 +0,0 @@ -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; - -#pragma warning disable SX1101 - -namespace Prowlarr.Http -{ - /// - /// TinyIoC bootstrapper - registers default route resolver and registers itself as - /// INancyModuleCatalog for resolving modules but behaviour can be overridden if required. - /// - public class TinyIoCNancyBootstrapper : NancyBootstrapperWithRequestContainerBase - { - /// - /// Default assemblies that are ignored for autoregister - /// - public static IEnumerable> DefaultAutoRegisterIgnoredAssemblies = new Func[] - { - asm => !asm.FullName.StartsWith("Nancy.", StringComparison.InvariantCulture) - }; - - /// - /// 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. - /// - protected virtual IEnumerable> AutoRegisterIgnoredAssemblies => DefaultAutoRegisterIgnoredAssemblies; - - /// - /// Configures the container using AutoRegister followed by registration - /// of default INancyModuleCatalog and IRouteResolver. - /// - /// Container instance - protected override void ConfigureApplicationContainer(TinyIoCContainer container) - { - AutoRegister(container, this.AutoRegisterIgnoredAssemblies); - } - - /// - /// Resolve INancyEngine - /// - /// INancyEngine implementation - protected override sealed INancyEngine GetEngineInternal() - { - return this.ApplicationContainer.Resolve(); - } - - /// - /// Gets the Nancy.Configuration.INancyEnvironmentConfigurator used by th. - /// - /// An Nancy.Configuration.INancyEnvironmentConfigurator instance. - protected override INancyEnvironmentConfigurator GetEnvironmentConfigurator() - { - return this.ApplicationContainer.Resolve(); - } - - /// - /// Get the Nancy.Configuration.INancyEnvironment instance. - /// - /// An configured Nancy.Configuration.INancyEnvironment instance. - /// - /// The boostrapper must be initialised (Nancy.Bootstrapper.INancyBootstrapper.Initialise) prior to calling this. - /// - public override INancyEnvironment GetEnvironment() - { - return this.ApplicationContainer.Resolve(); - } - - /// - /// Registers an Nancy.Configuration.INancyEnvironment instance in the container. - /// - /// The container to register into. - /// The Nancy.Configuration.INancyEnvironment instance to register. - protected override void RegisterNancyEnvironment(TinyIoCContainer container, INancyEnvironment environment) - { - ApplicationContainer.Register(environment); - } - - /// - /// Create a default, unconfigured, container - /// - /// Container instance - protected override TinyIoCContainer GetApplicationContainer() - { - return new TinyIoCContainer(); - } - - /// - /// 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. - /// - /// Application container to register into - protected override sealed void RegisterBootstrapperTypes(TinyIoCContainer applicationContainer) - { - applicationContainer.Register(this); - } - - /// - /// Register the default implementations of internally used types into the container as singletons - /// - /// Container to register into - /// Type registrations to register - protected override sealed void RegisterTypes(TinyIoCContainer container, IEnumerable 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(); - } - } - } - - /// - /// Register the various collections into the container as singletons to later be resolved - /// by IEnumerable{Type} constructor dependencies. - /// - /// Container to register into - /// Collection type registrations to register - protected override sealed void RegisterCollectionTypes(TinyIoCContainer container, IEnumerable 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(); - } - } - } - - /// - /// Register the given module types into the container - /// - /// Container to register into - /// NancyModule types - protected override sealed void RegisterRequestContainerModules(TinyIoCContainer container, IEnumerable moduleRegistrationTypes) - { - foreach (var moduleRegistrationType in moduleRegistrationTypes) - { - container.Register( - typeof(INancyModule), - moduleRegistrationType.ModuleType, - moduleRegistrationType.ModuleType.FullName). - AsSingleton(); - } - } - - /// - /// Register the given instances into the container - /// - /// Container to register into - /// Instance registration types - protected override void RegisterInstances(TinyIoCContainer container, IEnumerable instanceRegistrations) - { - foreach (var instanceRegistration in instanceRegistrations) - { - container.Register( - instanceRegistration.RegistrationType, - instanceRegistration.Implementation); - } - } - - /// - /// Creates a per request child/nested container - /// - /// Current context - /// Request container instance - protected override TinyIoCContainer CreateRequestContainer(NancyContext context) - { - return this.ApplicationContainer.GetChildContainer(); - } - - /// - /// Gets the diagnostics for initialisation - /// - /// IDiagnostics implementation - protected override IDiagnostics GetDiagnostics() - { - return this.ApplicationContainer.Resolve(); - } - - /// - /// Gets all registered startup tasks - /// - /// An instance containing instances. - protected override IEnumerable GetApplicationStartupTasks() - { - return this.ApplicationContainer.ResolveAll(false); - } - - /// - /// Gets all registered request startup tasks - /// - /// An instance containing instances. - protected override IEnumerable RegisterAndGetRequestStartupTasks(TinyIoCContainer container, Type[] requestStartupTypes) - { - container.RegisterMultiple(typeof(IRequestStartup), requestStartupTypes); - - return container.ResolveAll(false); - } - - /// - /// Gets all registered application registration tasks - /// - /// An instance containing instances. - protected override IEnumerable GetRegistrationTasks() - { - return this.ApplicationContainer.ResolveAll(false); - } - - /// - /// Retrieve all module instances from the container - /// - /// Container to use - /// Collection of NancyModule instances - protected override sealed IEnumerable GetAllModules(TinyIoCContainer container) - { - var nancyModules = container.ResolveAll(false); - return nancyModules; - } - - /// - /// Retrieve a specific module instance from the container - /// - /// Container to use - /// Type of the module - /// NancyModule instance - protected override sealed INancyModule GetModule(TinyIoCContainer container, Type moduleType) - { - container.Register(typeof(INancyModule), moduleType); - - return container.Resolve(); - } - - /// - /// Executes auto registation with the given container. - /// - /// Container instance - private static void AutoRegister(TinyIoCContainer container, IEnumerable> ignoredAssemblies) - { - var assembly = typeof(NancyEngine).Assembly; - - container.AutoRegister(AppDomain.CurrentDomain.GetAssemblies().Where(a => !ignoredAssemblies.Any(ia => ia(a))), DuplicateImplementationActions.RegisterMultiple, t => t.Assembly != assembly); - } - } -} diff --git a/src/Prowlarr.Http/Validation/DuplicateEndpointDetector.cs b/src/Prowlarr.Http/Validation/DuplicateEndpointDetector.cs new file mode 100644 index 000000000..c5748d801 --- /dev/null +++ b/src/Prowlarr.Http/Validation/DuplicateEndpointDetector.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using ImpromptuInterface; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.Extensions.DependencyInjection; + +namespace Prowlarr.Http.Validation +{ + public interface IDfaMatcherBuilder + { + void AddEndpoint(RouteEndpoint endpoint); + object BuildDfaTree(bool includeLabel = false); + } + + // https://github.com/dotnet/aspnetcore/blob/cc3d47f5501cdfae3e5b5be509ef2c0fb8cca069/src/Http/Routing/src/Matching/DfaNode.cs + public interface IDfaNode + { + public string Label { get; set; } + public List Matches { get; } + public IDictionary Literals { get; } + public object Parameters { get; } + public object CatchAll { get; } + public IDictionary PolicyEdges { get; } + } + + public class DuplicateEndpointDetector + { + private readonly IServiceProvider _services; + + public DuplicateEndpointDetector(IServiceProvider services) + { + _services = services; + } + + public Dictionary> GetDuplicateEndpoints(EndpointDataSource dataSource) + { + // get the DfaMatcherBuilder - internal, so needs reflection :( + var matcherBuilder = typeof(IEndpointSelectorPolicy).Assembly + .GetType("Microsoft.AspNetCore.Routing.Matching.DfaMatcherBuilder"); + + var rawBuilder = _services.GetRequiredService(matcherBuilder); + var builder = rawBuilder.ActLike(); + + var endpoints = dataSource.Endpoints; + foreach (var t in endpoints) + { + if (t is RouteEndpoint endpoint && (endpoint.Metadata.GetMetadata()?.SuppressMatching ?? false) == false) + { + builder.AddEndpoint(endpoint); + } + } + + // Assign each node a sequential index. + var visited = new Dictionary(); + var duplicates = new Dictionary>(); + + var rawTree = builder.BuildDfaTree(includeLabel: true); + + Visit(rawTree, LogDuplicates); + + return duplicates; + + void LogDuplicates(IDfaNode node) + { + if (!visited.TryGetValue(node, out var label)) + { + label = visited.Count; + visited.Add(node, label); + } + + // We can safely index into visited because this is a post-order traversal, + // all of the children of this node are already in the dictionary. + var filteredMatches = node?.Matches?.Where(x => !x.DisplayName.StartsWith("Prowlarr.Http.Frontend.StaticResourceController")).ToList(); + var matchCount = filteredMatches?.Count ?? 0; + if (matchCount > 1) + { + var duplicateEndpoints = filteredMatches.Select(x => x.DisplayName).ToList(); + duplicates[node.Label] = duplicateEndpoints; + } + } + } + + private static void Visit(object rawNode, Action visitor) + { + var node = rawNode.ActLike(); + if (node.Literals?.Values != null) + { + foreach (var dictValue in node.Literals.Values) + { + Visit(dictValue, visitor); + } + } + + // Break cycles + if (node.Parameters != null && !ReferenceEquals(rawNode, node.Parameters)) + { + Visit(node.Parameters, visitor); + } + + // Break cycles + if (node.CatchAll != null && !ReferenceEquals(rawNode, node.CatchAll)) + { + Visit(node.CatchAll, visitor); + } + + if (node.PolicyEdges?.Values != null) + { + foreach (var dictValue in node.PolicyEdges.Values) + { + Visit(dictValue, visitor); + } + } + + visitor(node); + } + } +} diff --git a/src/Prowlarr.Http/VersionedApiControllerAttribute.cs b/src/Prowlarr.Http/VersionedApiControllerAttribute.cs new file mode 100644 index 000000000..acd510f88 --- /dev/null +++ b/src/Prowlarr.Http/VersionedApiControllerAttribute.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace Prowlarr.Http +{ + public class VersionedApiControllerAttribute : Attribute, IRouteTemplateProvider, IEnableCorsAttribute, IApiBehaviorMetadata + { + public const string API_CORS_POLICY = "ApiCorsPolicy"; + public const string CONTROLLER_RESOURCE = "[controller]"; + + public VersionedApiControllerAttribute(int version, string resource = CONTROLLER_RESOURCE) + { + Resource = resource; + Template = $"api/v{version}/{resource}"; + PolicyName = API_CORS_POLICY; + } + + public string Resource { get; } + public string Template { get; } + public int? Order => 2; + public string Name { get; set; } + public string PolicyName { get; set; } + } + + public class V1ApiControllerAttribute : VersionedApiControllerAttribute + { + public V1ApiControllerAttribute(string resource = "[controller]") + : base(1, resource) + { + } + } +} diff --git a/src/Prowlarr.Http/VersionedFeedControllerAttribute.cs b/src/Prowlarr.Http/VersionedFeedControllerAttribute.cs new file mode 100644 index 000000000..c4ab588a0 --- /dev/null +++ b/src/Prowlarr.Http/VersionedFeedControllerAttribute.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace Prowlarr.Http +{ + public class VersionedFeedControllerAttribute : Attribute, IRouteTemplateProvider + { + public VersionedFeedControllerAttribute(int version, string resource = "[controller]") + { + Version = version; + Template = $"feed/v{Version}/{resource}"; + } + + public string Template { get; private set; } + public int? Order => 2; + public string Name { get; set; } + public int Version { get; private set; } + } + + public class V1FeedControllerAttribute : VersionedApiControllerAttribute + { + public V1FeedControllerAttribute(string resource = "[controller]") + : base(1, resource) + { + } + } +} diff --git a/src/Readarr.Api.V1/Indexers/ReleaseModuleBase.cs b/src/Readarr.Api.V1/Indexers/ReleaseModuleBase.cs new file mode 100644 index 000000000..ea00b5446 --- /dev/null +++ b/src/Readarr.Api.V1/Indexers/ReleaseModuleBase.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.DecisionEngine; +using Readarr.Http.REST; + +namespace Readarr.Api.V1.Indexers +{ + public abstract class ReleaseControllerBase : RestController + { + public override ReleaseResource GetResourceById(int id) + { + throw new NotImplementedException(); + } + + protected virtual List MapDecisions(IEnumerable decisions) + { + var result = new List(); + + foreach (var downloadDecision in decisions) + { + var release = MapDecision(downloadDecision, result.Count); + + result.Add(release); + } + + return result; + } + + protected virtual ReleaseResource MapDecision(DownloadDecision decision, int initialWeight) + { + var release = decision.ToResource(); + + release.ReleaseWeight = initialWeight; + + if (decision.RemoteBook.Author != null) + { + release.QualityWeight = decision.RemoteBook + .Author + .QualityProfile.Value.GetIndex(release.Quality.Quality).Index * 100; + } + + release.QualityWeight += release.Quality.Revision.Real * 10; + release.QualityWeight += release.Quality.Revision.Version; + + return release; + } + } +}