New: Use ASP.NET Core instead of Nancy

(cherry picked from commit 58ddbcd77e17ef95ecfad4b746084ee9326116f3)
pull/26/head
ta264 3 years ago
parent 7d494f9743
commit dbdc527f2e

@ -139,7 +139,8 @@ export function executeCommandHelper( payload, dispatch) {
const promise = createAjaxRequest({ const promise = createAjaxRequest({
url: '/command', url: '/command',
method: 'POST', method: 'POST',
data: JSON.stringify(payload) data: JSON.stringify(payload),
dataType: 'json'
}).request; }).request;
return promise.then((data) => { return promise.then((data) => {

@ -53,7 +53,8 @@ export const actionHandlers = handleThunks({
const promise = createAjaxRequest({ const promise = createAjaxRequest({
url: '/tag', url: '/tag',
method: 'POST', method: 'POST',
data: JSON.stringify(payload.tag) data: JSON.stringify(payload.tag),
dataType: 'json'
}).request; }).request;
promise.done((data) => { promise.done((data) => {

@ -2,6 +2,7 @@ using System;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace NzbDrone.Common.Serializer namespace NzbDrone.Common.Serializer
{ {
@ -15,15 +16,19 @@ namespace NzbDrone.Common.Serializer
public static JsonSerializerOptions GetSerializerSettings() public static JsonSerializerOptions GetSerializerSettings()
{ {
var serializerSettings = new JsonSerializerOptions var settings = new JsonSerializerOptions();
{ ApplySerializerSettings(settings);
AllowTrailingCommas = true, return settings;
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }
PropertyNameCaseInsensitive = true,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, public static void ApplySerializerSettings(JsonSerializerOptions serializerSettings)
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, {
WriteIndented = true 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 JsonStringEnumConverter(JsonNamingPolicy.CamelCase, true));
serializerSettings.Converters.Add(new STJVersionConverter()); serializerSettings.Converters.Add(new STJVersionConverter());
@ -31,8 +36,6 @@ namespace NzbDrone.Common.Serializer
serializerSettings.Converters.Add(new STJTimeSpanConverter()); serializerSettings.Converters.Add(new STJTimeSpanConverter());
serializerSettings.Converters.Add(new STJUtcConverter()); serializerSettings.Converters.Add(new STJUtcConverter());
serializerSettings.Converters.Add(new DictionaryStringObjectConverter()); serializerSettings.Converters.Add(new DictionaryStringObjectConverter());
return serializerSettings;
} }
public static T Deserialize<T>(string json) public static T Deserialize<T>(string json)
@ -85,5 +88,15 @@ namespace NzbDrone.Common.Serializer
JsonSerializer.Serialize(writer, (object)model, options); JsonSerializer.Serialize(writer, (object)model, options);
} }
} }
public static Task SerializeAsync<TModel>(TModel model, Stream outputStream, JsonSerializerOptions options = null)
{
if (options == null)
{
options = SerializerSettings;
}
return JsonSerializer.SerializeAsync(outputStream, (object)model, options);
}
} }
} }

@ -2,7 +2,6 @@ namespace NzbDrone.Core.IndexerSearch
{ {
public class NewznabRequest public class NewznabRequest
{ {
public int id { get; set; }
public string t { get; set; } public string t { get; set; }
public string q { get; set; } public string q { get; set; }
public string cat { get; set; } public string cat { get; set; }

@ -5,7 +5,7 @@ using NLog;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
namespace Prowlarr.Host.AccessControl namespace NzbDrone.Host.AccessControl
{ {
public interface IFirewallAdapter public interface IFirewallAdapter
{ {

@ -1,9 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using Nancy.Bootstrapper;
using NzbDrone.Common.Composition; using NzbDrone.Common.Composition;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.SignalR; using NzbDrone.SignalR;
using Prowlarr.Http;
namespace Prowlarr.Host namespace Prowlarr.Host
{ {
@ -28,8 +26,6 @@ namespace Prowlarr.Host
{ {
AutoRegisterImplementations<MessageHub>(); AutoRegisterImplementations<MessageHub>();
Container.Register<INancyBootstrapper, ProwlarrBootstrapper>();
if (OsInfo.IsWindows) if (OsInfo.IsWindows)
{ {
Container.Register<INzbDroneServiceFactory, NzbDroneServiceFactory>(); Container.Register<INzbDroneServiceFactory, NzbDroneServiceFactory>();

@ -4,7 +4,6 @@
<OutputType>Library</OutputType> <OutputType>Library</OutputType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Owin" Version="5.0.4" />
<PackageReference Include="NLog.Extensions.Logging" Version="1.7.0" /> <PackageReference Include="NLog.Extensions.Logging" Version="1.7.0" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="5.0.0" /> <PackageReference Include="System.Text.Encoding.CodePages" Version="5.0.0" />
</ItemGroup> </ItemGroup>

@ -1,4 +1,5 @@
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Host.AccessControl;
namespace Prowlarr.Host.AccessControl namespace Prowlarr.Host.AccessControl
{ {

@ -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
}
}
}

@ -1,10 +0,0 @@
using Microsoft.AspNetCore.Builder;
namespace Prowlarr.Host.Middleware
{
public interface IAspNetCoreMiddleware
{
int Order { get; }
void Attach(IApplicationBuilder appBuilder);
}
}

@ -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));
}
}
}

@ -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<MessageHub>(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<IHubContext<MessageHub>>();
_container.Register(hubContext);
}
}
}

@ -4,42 +4,60 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; 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.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NLog; using NLog;
using NLog.Extensions.Logging; using NLog.Extensions.Logging;
using NzbDrone.Common.Composition;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Exceptions; using NzbDrone.Common.Exceptions;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Serializer; using NzbDrone.Common.Serializer;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using Prowlarr.Host.AccessControl; using NzbDrone.Host;
using Prowlarr.Host.Middleware; 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; using LogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace Prowlarr.Host namespace Prowlarr.Host
{ {
public class WebHostController : IHostController public class WebHostController : IHostController
{ {
private readonly IContainer _container;
private readonly IRuntimeInfo _runtimeInfo; private readonly IRuntimeInfo _runtimeInfo;
private readonly IConfigFileProvider _configFileProvider; private readonly IConfigFileProvider _configFileProvider;
private readonly IFirewallAdapter _firewallAdapter; private readonly IFirewallAdapter _firewallAdapter;
private readonly IEnumerable<IAspNetCoreMiddleware> _middlewares; private readonly ProwlarrErrorPipeline _errorHandler;
private readonly Logger _logger; private readonly Logger _logger;
private IWebHost _host; private IWebHost _host;
public WebHostController(IRuntimeInfo runtimeInfo, public WebHostController(IContainer container,
IRuntimeInfo runtimeInfo,
IConfigFileProvider configFileProvider, IConfigFileProvider configFileProvider,
IFirewallAdapter firewallAdapter, IFirewallAdapter firewallAdapter,
IEnumerable<IAspNetCoreMiddleware> middlewares, ProwlarrErrorPipeline errorHandler,
Logger logger) Logger logger)
{ {
_container = container;
_runtimeInfo = runtimeInfo; _runtimeInfo = runtimeInfo;
_configFileProvider = configFileProvider; _configFileProvider = configFileProvider;
_firewallAdapter = firewallAdapter; _firewallAdapter = firewallAdapter;
_middlewares = middlewares; _errorHandler = errorHandler;
_logger = logger; _logger = logger;
} }
@ -105,24 +123,125 @@ namespace Prowlarr.Host
}) })
.ConfigureServices(services => .ConfigureServices(services =>
{ {
// So that we can resolve containers with our TinyIoC services
services.AddSingleton(_container);
services.AddSingleton<IControllerActivator, ControllerActivator>();
// Bits used in our custom middleware
services.AddSingleton(_container.Resolve<ProwlarrErrorPipeline>());
services.AddSingleton(_container.Resolve<ICacheableSpecification>());
// Used in authentication
services.AddSingleton(_container.Resolve<IAuthenticationService>());
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 services
.AddSignalR() .AddSignalR()
.AddJsonProtocol(options => .AddJsonProtocol(options =>
{ {
options.PayloadSerializerOptions = STJson.GetSerializerSettings(); 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 => .Configure(app =>
{ {
app.UseMiddleware<LoggingMiddleware>();
app.UsePathBase(new PathString(_configFileProvider.UrlBase));
app.UseExceptionHandler(new ExceptionHandlerOptions
{
AllowStatusCode404Response = true,
ExceptionHandler = _errorHandler.HandleException
});
app.UseRouting(); app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseResponseCompression();
app.Properties["host.AppName"] = BuildInfo.AppName; app.Properties["host.AppName"] = BuildInfo.AppName;
app.UsePathBase(_configFileProvider.UrlBase);
foreach (var middleWare in _middlewares.OrderBy(c => c.Order)) app.UseMiddleware<VersionMiddleware>();
app.UseMiddleware<UrlBaseMiddleware>(_configFileProvider.UrlBase);
app.UseMiddleware<CacheHeaderMiddleware>();
app.UseMiddleware<IfModifiedMiddleware>();
app.Use((context, next) =>
{ {
_logger.Debug("Attaching {0} to host", middleWare.GetType().Name); if (context.Request.Path.StartsWithSegments("/api/v1/command", StringComparison.CurrentCultureIgnoreCase))
middleWare.Attach(app); {
} context.Request.EnableBuffering();
}
return next();
});
app.UseWebSockets();
app.UseEndpoints(x =>
{
x.MapHub<MessageHub>("/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<IHubContext<MessageHub>>());
_container.Register(app.ApplicationServices.GetService<IActionDescriptorCollectionProvider>());
_container.Register(app.ApplicationServices.GetService<EndpointDataSource>());
_container.Register(app.ApplicationServices.GetService<DfaGraphWriter>());
}) })
.UseContentRoot(Directory.GetCurrentDirectory()) .UseContentRoot(Directory.GetCurrentDirectory())
.Build(); .Build();

@ -51,11 +51,7 @@ namespace NzbDrone.Integration.Test.Client
throw response.ErrorException; throw response.ErrorException;
} }
// cache control header gets reordered on net core AssertDisableCache(response);
((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");
response.ErrorMessage.Should().BeNullOrWhiteSpace(); response.ErrorMessage.Should().BeNullOrWhiteSpace();
@ -71,6 +67,16 @@ namespace NzbDrone.Integration.Test.Client
return Json.Deserialize<T>(content); return Json.Deserialize<T>(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<TResource> : ClientBase public class ClientBase<TResource> : ClientBase

@ -11,6 +11,7 @@ namespace NzbDrone.Integration.Test
private RestRequest BuildGet(string route = "indexer") private RestRequest BuildGet(string route = "indexer")
{ {
var request = new RestRequest(route, Method.GET); var request = new RestRequest(route, Method.GET);
request.AddHeader("Origin", "http://a.different.domain");
request.AddHeader(AccessControlHeaders.RequestMethod, "POST"); request.AddHeader(AccessControlHeaders.RequestMethod, "POST");
return request; return request;
@ -19,6 +20,8 @@ namespace NzbDrone.Integration.Test
private RestRequest BuildOptions(string route = "indexer") private RestRequest BuildOptions(string route = "indexer")
{ {
var request = new RestRequest(route, Method.OPTIONS); var request = new RestRequest(route, Method.OPTIONS);
request.AddHeader("Origin", "http://a.different.domain");
request.AddHeader(AccessControlHeaders.RequestMethod, "POST");
return request; return request;
} }

@ -1,4 +1,4 @@
using System.Net; using System.Net;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using RestSharp; using RestSharp;
@ -33,8 +33,6 @@ namespace NzbDrone.Integration.Test
[TestCase("application/junk")] [TestCase("application/junk")]
public void should_get_unacceptable_with_accept_header(string header) public void should_get_unacceptable_with_accept_header(string header)
{ {
IgnoreOnMonoVersions("5.12", "5.14");
var request = new RestRequest("system/status") var request = new RestRequest("system/status")
{ {
RequestFormat = DataFormat.None RequestFormat = DataFormat.None

@ -11,8 +11,6 @@ namespace NzbDrone.Integration.Test
[Test] [Test]
public void should_log_on_error() public void should_log_on_error()
{ {
IgnoreOnMonoVersions("5.12", "5.14");
var config = HostConfig.Get(1); var config = HostConfig.Get(1);
config.LogLevel = "Trace"; config.LogLevel = "Trace";
HostConfig.Put(config); HostConfig.Put(config);

@ -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) public string GetTempDirectory(params string[] args)
{ {
var path = Path.Combine(TempDirectory, Path.Combine(args)); var path = Path.Combine(TempDirectory, Path.Combine(args));

@ -1,11 +1,5 @@
using System; using System.IO;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using FluentAssertions; using FluentAssertions;
using Mono.Posix;
using Mono.Unix; using Mono.Unix;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Mono.Disk; using NzbDrone.Mono.Disk;

@ -1,12 +1,14 @@
using NzbDrone.Core.Applications; using NzbDrone.Core.Applications;
using Prowlarr.Http;
namespace Prowlarr.Api.V1.Application namespace Prowlarr.Api.V1.Application
{ {
public class ApplicationModule : ProviderModuleBase<ApplicationResource, IApplication, ApplicationDefinition> [V1ApiController("applications")]
public class ApplicationController : ProviderControllerBase<ApplicationResource, IApplication, ApplicationDefinition>
{ {
public static readonly ApplicationResourceMapper ResourceMapper = new ApplicationResourceMapper(); public static readonly ApplicationResourceMapper ResourceMapper = new ApplicationResourceMapper();
public ApplicationModule(ApplicationFactory applicationsFactory) public ApplicationController(ApplicationFactory applicationsFactory)
: base(applicationsFactory, "applications", ResourceMapper) : base(applicationsFactory, "applications", ResourceMapper)
{ {
} }

@ -1,27 +1,32 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common; using NzbDrone.Common;
using NzbDrone.Common.Serializer;
using NzbDrone.Common.TPL; using NzbDrone.Common.TPL;
using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.ProgressMessaging; using NzbDrone.Core.ProgressMessaging;
using NzbDrone.Http.REST.Attributes;
using NzbDrone.SignalR; using NzbDrone.SignalR;
using Prowlarr.Http; using Prowlarr.Http;
using Prowlarr.Http.Extensions; using Prowlarr.Http.REST;
using Prowlarr.Http.Validation; using Prowlarr.Http.Validation;
namespace Prowlarr.Api.V1.Commands namespace Prowlarr.Api.V1.Commands
{ {
public class CommandModule : ProwlarrRestModuleWithSignalR<CommandResource, CommandModel>, IHandle<CommandUpdatedEvent> [V1ApiController]
public class CommandController : RestControllerWithSignalR<CommandResource, CommandModel>, IHandle<CommandUpdatedEvent>
{ {
private readonly IManageCommandQueue _commandQueueManager; private readonly IManageCommandQueue _commandQueueManager;
private readonly IServiceFactory _serviceFactory; private readonly IServiceFactory _serviceFactory;
private readonly Debouncer _debouncer; private readonly Debouncer _debouncer;
private readonly Dictionary<int, CommandResource> _pendingUpdates; private readonly Dictionary<int, CommandResource> _pendingUpdates;
public CommandModule(IManageCommandQueue commandQueueManager, public CommandController(IManageCommandQueue commandQueueManager,
IBroadcastSignalRMessage signalRBroadcaster, IBroadcastSignalRMessage signalRBroadcaster,
IServiceFactory serviceFactory) IServiceFactory serviceFactory)
: base(signalRBroadcaster) : base(signalRBroadcaster)
@ -32,45 +37,49 @@ namespace Prowlarr.Api.V1.Commands
_debouncer = new Debouncer(SendUpdates, TimeSpan.FromSeconds(0.1)); _debouncer = new Debouncer(SendUpdates, TimeSpan.FromSeconds(0.1));
_pendingUpdates = new Dictionary<int, CommandResource>(); _pendingUpdates = new Dictionary<int, CommandResource>();
GetResourceById = GetCommand;
CreateResource = StartCommand;
GetResourceAll = GetStartedCommands;
DeleteResource = CancelCommand;
PostValidator.RuleFor(c => c.Name).NotBlank(); PostValidator.RuleFor(c => c.Name).NotBlank();
} }
private CommandResource GetCommand(int id) public override CommandResource GetResourceById(int id)
{ {
return _commandQueueManager.Get(id).ToResource(); return _commandQueueManager.Get(id).ToResource();
} }
private int StartCommand(CommandResource commandResource) [RestPostById]
public ActionResult<CommandResource> StartCommand(CommandResource commandResource)
{ {
var commandType = var commandType =
_serviceFactory.GetImplementations(typeof(Command)) _serviceFactory.GetImplementations(typeof(Command))
.Single(c => c.Name.Replace("Command", "") .Single(c => c.Name.Replace("Command", "")
.Equals(commandResource.Name, StringComparison.InvariantCultureIgnoreCase)); .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.Trigger = CommandTrigger.Manual;
command.SuppressMessages = !command.SendUpdatesToClient; command.SuppressMessages = !command.SendUpdatesToClient;
command.SendUpdatesToClient = true; command.SendUpdatesToClient = true;
var trackedCommand = _commandQueueManager.Push(command, CommandPriority.Normal, CommandTrigger.Manual); var trackedCommand = _commandQueueManager.Push(command, CommandPriority.Normal, CommandTrigger.Manual);
return trackedCommand.Id; return Created(trackedCommand.Id);
} }
private List<CommandResource> GetStartedCommands() [HttpGet]
public List<CommandResource> GetStartedCommands()
{ {
return _commandQueueManager.All().ToResource(); return _commandQueueManager.All().ToResource();
} }
private void CancelCommand(int id) [RestDeleteById]
public void CancelCommand(int id)
{ {
_commandQueueManager.Cancel(id); _commandQueueManager.Cancel(id);
} }
[NonAction]
public void Handle(CommandUpdatedEvent message) public void Handle(CommandUpdatedEvent message)
{ {
if (message.Command.Body.SendUpdatesToClient) if (message.Command.Body.SendUpdatesToClient)

@ -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<TResource> : RestController<TResource>
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<TResource> 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);
}
}

@ -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<DevelopmentConfigResource>
{
private readonly IConfigFileProvider _configFileProvider;
public DevelopmentConfigController(IConfigFileProvider configFileProvider,
IConfigService configService)
: base(configService)
{
_configFileProvider = configFileProvider;
}
public override ActionResult<DevelopmentConfigResource> 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);
}
}
}

@ -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<DevelopmentConfigResource>
{
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);
}
}
}

@ -1,10 +1,12 @@
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using Prowlarr.Http;
namespace Prowlarr.Api.V1.Config namespace Prowlarr.Api.V1.Config
{ {
public class DownloadClientConfigModule : ProwlarrConfigModule<DownloadClientConfigResource> [V1ApiController("config/downloadclient")]
public class DownloadClientConfigController : ConfigController<DownloadClientConfigResource>
{ {
public DownloadClientConfigModule(IConfigService configService) public DownloadClientConfigController(IConfigService configService)
: base(configService) : base(configService)
{ {
} }

@ -3,36 +3,35 @@ using System.Linq;
using System.Reflection; using System.Reflection;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Authentication; using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.Update; using NzbDrone.Core.Update;
using NzbDrone.Core.Validation; using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths; using NzbDrone.Core.Validation.Paths;
using NzbDrone.Http.REST.Attributes;
using Prowlarr.Http; using Prowlarr.Http;
using Prowlarr.Http.REST;
namespace Prowlarr.Api.V1.Config namespace Prowlarr.Api.V1.Config
{ {
public class HostConfigModule : ProwlarrRestModule<HostConfigResource> [V1ApiController("config/host")]
public class HostConfigController : RestController<HostConfigResource>
{ {
private readonly IConfigFileProvider _configFileProvider; private readonly IConfigFileProvider _configFileProvider;
private readonly IConfigService _configService; private readonly IConfigService _configService;
private readonly IUserService _userService; private readonly IUserService _userService;
public HostConfigModule(IConfigFileProvider configFileProvider, public HostConfigController(IConfigFileProvider configFileProvider,
IConfigService configService, IConfigService configService,
IUserService userService, IUserService userService,
FileExistsValidator fileExistsValidator) FileExistsValidator fileExistsValidator)
: base("/config/host")
{ {
_configFileProvider = configFileProvider; _configFileProvider = configFileProvider;
_configService = configService; _configService = configService;
_userService = userService; _userService = userService;
GetResourceSingle = GetHostConfig;
GetResourceById = GetHostConfig;
UpdateResource = SaveHostConfig;
SharedValidator.RuleFor(c => c.BindAddress) SharedValidator.RuleFor(c => c.BindAddress)
.ValidIp4Address() .ValidIp4Address()
.NotListenAllIp4Address() .NotListenAllIp4Address()
@ -79,7 +78,13 @@ namespace Prowlarr.Api.V1.Config
return cert != null; return cert != null;
} }
private HostConfigResource GetHostConfig() public override HostConfigResource GetResourceById(int id)
{
return GetHostConfig();
}
[HttpGet]
public HostConfigResource GetHostConfig()
{ {
var resource = HostConfigResourceMapper.ToResource(_configFileProvider, _configService); var resource = HostConfigResourceMapper.ToResource(_configFileProvider, _configService);
resource.Id = 1; resource.Id = 1;
@ -94,12 +99,8 @@ namespace Prowlarr.Api.V1.Config
return resource; return resource;
} }
private HostConfigResource GetHostConfig(int id) [RestPutById]
{ public ActionResult<HostConfigResource> SaveHostConfig(HostConfigResource resource)
return GetHostConfig();
}
private void SaveHostConfig(HostConfigResource resource)
{ {
var dictionary = resource.GetType() var dictionary = resource.GetType()
.GetProperties(BindingFlags.Instance | BindingFlags.Public) .GetProperties(BindingFlags.Instance | BindingFlags.Public)
@ -112,6 +113,8 @@ namespace Prowlarr.Api.V1.Config
{ {
_userService.Upsert(resource.Username, resource.Password); _userService.Upsert(resource.Username, resource.Password);
} }
return Accepted(resource.Id);
} }
} }
} }

@ -1,12 +1,14 @@
using FluentValidation; using FluentValidation;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using Prowlarr.Http;
using Prowlarr.Http.Validation; using Prowlarr.Http.Validation;
namespace Prowlarr.Api.V1.Config namespace Prowlarr.Api.V1.Config
{ {
public class IndexerConfigModule : ProwlarrConfigModule<IndexerConfigResource> [V1ApiController("config/indexer")]
public class IndexerConfigController : ConfigController<IndexerConfigResource>
{ {
public IndexerConfigModule(IConfigService configService) public IndexerConfigController(IConfigService configService)
: base(configService) : base(configService)
{ {
SharedValidator.RuleFor(c => c.MinimumAge) SharedValidator.RuleFor(c => c.MinimumAge)

@ -3,12 +3,14 @@ using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.Validation; using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths; using NzbDrone.Core.Validation.Paths;
using Prowlarr.Http;
namespace Prowlarr.Api.V1.Config namespace Prowlarr.Api.V1.Config
{ {
public class MediaManagementConfigModule : ProwlarrConfigModule<MediaManagementConfigResource> [V1ApiController("config/mediamanagement")]
public class MediaManagementConfigController : ConfigController<MediaManagementConfigResource>
{ {
public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FileChmodValidator fileChmodValidator) public MediaManagementConfigController(IConfigService configService, PathExistsValidator pathExistsValidator, FileChmodValidator fileChmodValidator)
: base(configService) : base(configService)
{ {
SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0); SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0);

@ -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<TResource> : ProwlarrRestModule<TResource>
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);
}
}
}

@ -1,10 +1,12 @@
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using Prowlarr.Http;
namespace Prowlarr.Api.V1.Config namespace Prowlarr.Api.V1.Config
{ {
public class UiConfigModule : ProwlarrConfigModule<UiConfigResource> [V1ApiController("config/ui")]
public class UiConfigController : ConfigController<UiConfigResource>
{ {
public UiConfigModule(IConfigService configService) public UiConfigController(IConfigService configService)
: base(configService) : base(configService)
{ {
} }

@ -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<CustomFilterResource>
{
private readonly ICustomFilterService _customFilterService;
public CustomFilterController(ICustomFilterService customFilterService)
{
_customFilterService = customFilterService;
}
public override CustomFilterResource GetResourceById(int id)
{
return _customFilterService.Get(id).ToResource();
}
[HttpGet]
public List<CustomFilterResource> GetCustomFilters()
{
return _customFilterService.All().ToResource();
}
[RestPostById]
public ActionResult<CustomFilterResource> AddCustomFilter(CustomFilterResource resource)
{
var customFilter = _customFilterService.Add(resource.ToModel());
return Created(customFilter.Id);
}
[RestPutById]
public ActionResult<CustomFilterResource> UpdateCustomFilter(CustomFilterResource resource)
{
_customFilterService.Update(resource.ToModel());
return Accepted(resource.Id);
}
[RestDeleteById]
public void DeleteCustomResource(int id)
{
_customFilterService.Delete(id);
}
}
}

@ -1,49 +0,0 @@
using System.Collections.Generic;
using NzbDrone.Core.CustomFilters;
using Prowlarr.Http;
namespace Prowlarr.Api.V1.CustomFilters
{
public class CustomFilterModule : ProwlarrRestModule<CustomFilterResource>
{
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<CustomFilterResource> 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);
}
}
}

@ -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" };
}
}
}

@ -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" };
}
}
}

@ -1,29 +1,39 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.HealthCheck; using NzbDrone.Core.HealthCheck;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
using NzbDrone.SignalR; using NzbDrone.SignalR;
using Prowlarr.Http; using Prowlarr.Http;
using Prowlarr.Http.REST;
namespace Prowlarr.Api.V1.Health namespace Prowlarr.Api.V1.Health
{ {
public class HealthModule : ProwlarrRestModuleWithSignalR<HealthResource, HealthCheck>, [V1ApiController]
public class HealthController : RestControllerWithSignalR<HealthResource, HealthCheck>,
IHandle<HealthCheckCompleteEvent> IHandle<HealthCheckCompleteEvent>
{ {
private readonly IHealthCheckService _healthCheckService; private readonly IHealthCheckService _healthCheckService;
public HealthModule(IBroadcastSignalRMessage signalRBroadcaster, IHealthCheckService healthCheckService) public HealthController(IBroadcastSignalRMessage signalRBroadcaster, IHealthCheckService healthCheckService)
: base(signalRBroadcaster) : base(signalRBroadcaster)
{ {
_healthCheckService = healthCheckService; _healthCheckService = healthCheckService;
GetResourceAll = GetHealth;
} }
private List<HealthResource> GetHealth() public override HealthResource GetResourceById(int id)
{
throw new NotImplementedException();
}
[HttpGet]
public List<HealthResource> GetHealth()
{ {
return _healthCheckService.Results().ToResource(); return _healthCheckService.Results().ToResource();
} }
[NonAction]
public void Handle(HealthCheckCompleteEvent message) public void Handle(HealthCheckCompleteEvent message)
{ {
BroadcastResourceChange(ModelAction.Sync); BroadcastResourceChange(ModelAction.Sync);

@ -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<HistoryResource> GetHistory()
{
var pagingResource = Request.ReadPagingResourceFromRequest<HistoryResource>();
var pagingSpec = pagingResource.MapToPagingSpec<HistoryResource, NzbDrone.Core.History.History>("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<HistoryResource> GetHistorySince(DateTime date, HistoryEventType? eventType = null)
{
return _historyService.Since(date, eventType).Select(h => MapToResource(h)).ToList();
}
[HttpGet("indexer")]
public List<HistoryResource> GetIndexerHistory(int indexerId, HistoryEventType? eventType = null)
{
return _historyService.GetByIndexerId(indexerId, eventType).Select(h => MapToResource(h)).ToList();
}
}
}

@ -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<HistoryResource>
{
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<HistoryResource> GetHistory(PagingResource<HistoryResource> pagingResource)
{
var pagingSpec = pagingResource.MapToPagingSpec<HistoryResource, NzbDrone.Core.History.History>("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<HistoryResource> 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<HistoryResource> 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();
}
}
}

@ -2,42 +2,32 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net; using System.Net;
using System.Text; using System.Text;
using Nancy; using Microsoft.AspNetCore.Mvc;
using Nancy.ModelBinding;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers;
using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.IndexerSearch;
using NzbDrone.Core.Parser; using NzbDrone.Core.Parser;
using NzbDrone.Http.Extensions; using Prowlarr.Http;
using Prowlarr.Http.Extensions; using Prowlarr.Http.Extensions;
using Prowlarr.Http.REST; using Prowlarr.Http.REST;
namespace Prowlarr.Api.V1.Indexers namespace Prowlarr.Api.V1.Indexers
{ {
public class IndexerModule : ProviderModuleBase<IndexerResource, IIndexer, IndexerDefinition> [V1ApiController]
public class IndexerController : ProviderControllerBase<IndexerResource, IIndexer, IndexerDefinition>
{ {
private IIndexerFactory _indexerFactory { get; set; } private IIndexerFactory _indexerFactory { get; set; }
private ISearchForNzb _nzbSearchService { get; set; } private ISearchForNzb _nzbSearchService { get; set; }
private IDownloadMappingService _downloadMappingService { get; set; } private IDownloadMappingService _downloadMappingService { get; set; }
private IDownloadService _downloadService { 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) : base(indexerFactory, "indexer", resourceMapper)
{ {
_indexerFactory = indexerFactory; _indexerFactory = indexerFactory;
_nzbSearchService = nzbSearchService; _nzbSearchService = nzbSearchService;
_downloadMappingService = downloadMappingService; _downloadMappingService = downloadMappingService;
_downloadService = downloadService; _downloadService = downloadService;
Get("{id}/newznab", x =>
{
var request = this.Bind<NewznabRequest>();
return GetNewznabResponse(request);
});
Get("{id}/download", x =>
{
return GetDownload(x.id);
});
} }
protected override void Validate(IndexerDefinition definition, bool includeWarnings) protected override void Validate(IndexerDefinition definition, bool includeWarnings)
@ -50,10 +40,11 @@ namespace Prowlarr.Api.V1.Indexers
base.Validate(definition, includeWarnings); 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; var requestType = request.t;
request.source = UserAgentParser.ParseSource(Request.Headers.UserAgent); request.source = UserAgentParser.ParseSource(Request.Headers["User-Agent"]);
request.server = Request.GetServerUrl(); request.server = Request.GetServerUrl();
if (requestType.IsNullOrWhiteSpace()) if (requestType.IsNullOrWhiteSpace())
@ -61,7 +52,7 @@ namespace Prowlarr.Api.V1.Indexers
throw new BadRequestException("Missing Function Parameter"); throw new BadRequestException("Missing Function Parameter");
} }
var indexer = _indexerFactory.Get(request.id); var indexer = _indexerFactory.Get(id);
if (indexer == null) if (indexer == null)
{ {
@ -73,32 +64,26 @@ namespace Prowlarr.Api.V1.Indexers
switch (requestType) switch (requestType)
{ {
case "caps": case "caps":
Response response = indexerInstance.GetCapabilities().ToXml(); return Content(indexerInstance.GetCapabilities().ToXml(), "application/rss+xml");
response.ContentType = "application/rss+xml";
return response;
case "search": case "search":
case "tvsearch": case "tvsearch":
case "music": case "music":
case "book": case "book":
case "movie": case "movie":
var results = _nzbSearchService.Search(request, new List<int> { indexer.Id }, false); var results = _nzbSearchService.Search(request, new List<int> { indexer.Id }, false);
return Content(results.ToXml(indexerInstance.Protocol), "application/rss+xml");
Response searchResponse = results.ToXml(indexerInstance.Protocol);
searchResponse.ContentType = "application/rss+xml";
return searchResponse;
default: default:
throw new BadRequestException("Function Not Available"); 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 indexerDef = _indexerFactory.Get(id);
var indexer = _indexerFactory.GetInstance(indexerDef); 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"); throw new BadRequestException("Invalid Prowlarr link");
} }
@ -110,15 +95,15 @@ namespace Prowlarr.Api.V1.Indexers
throw new NotFoundException("Indexer Not Found"); 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 is set to download via Redirect then just redirect to the link
if (indexer.SupportsRedirect && indexerDef.Redirect) if (indexer.SupportsRedirect && indexerDef.Redirect)
{ {
_downloadService.RecordRedirect(unprotectedlLink, id, source, file); _downloadService.RecordRedirect(unprotectedlLink, id, source, file);
return Response.AsRedirect(unprotectedlLink, Nancy.Responses.RedirectResponse.RedirectType.Permanent); return RedirectPermanent(unprotectedlLink);
} }
var downloadBytes = Array.Empty<byte>(); var downloadBytes = Array.Empty<byte>();
@ -135,14 +120,14 @@ namespace Prowlarr.Api.V1.Indexers
&& downloadBytes[6] == 0x3a) && downloadBytes[6] == 0x3a)
{ {
var magnetUrl = Encoding.UTF8.GetString(downloadBytes); 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 contentType = indexer.Protocol == DownloadProtocol.Torrent ? "application/x-bittorrent" : "application/x-nzb";
var extension = indexer.Protocol == DownloadProtocol.Torrent ? "torrent" : "nzb"; var extension = indexer.Protocol == DownloadProtocol.Torrent ? "torrent" : "nzb";
var filename = $"{file}.{extension}"; var filename = $"{file}.{extension}";
return Response.FromByteArray(downloadBytes, contentType).AsAttachment(filename, contentType); return File(downloadBytes, contentType, filename);
} }
} }
} }

@ -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;
}
}
}

@ -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;
}
}
}

@ -1,32 +1,30 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Nancy; using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers;
using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Commands;
using Prowlarr.Http;
using Prowlarr.Http.Extensions; using Prowlarr.Http.Extensions;
namespace Prowlarr.Api.V1.Indexers namespace Prowlarr.Api.V1.Indexers
{ {
public class IndexerEditorModule : ProwlarrV1Module [V1ApiController("indexer/editor")]
public class IndexerEditorController : Controller
{ {
private readonly IIndexerFactory _indexerService; private readonly IIndexerFactory _indexerService;
private readonly IManageCommandQueue _commandQueueManager; private readonly IManageCommandQueue _commandQueueManager;
private readonly IndexerResourceMapper _resourceMapper; private readonly IndexerResourceMapper _resourceMapper;
public IndexerEditorModule(IIndexerFactory indexerService, IManageCommandQueue commandQueueManager, IndexerResourceMapper resourceMapper) public IndexerEditorController(IIndexerFactory indexerService, IManageCommandQueue commandQueueManager, IndexerResourceMapper resourceMapper)
: base("/indexer/editor")
{ {
_indexerService = indexerService; _indexerService = indexerService;
_commandQueueManager = commandQueueManager; _commandQueueManager = commandQueueManager;
_resourceMapper = resourceMapper; _resourceMapper = resourceMapper;
Put("/", movie => SaveAll());
Delete("/", movie => DeleteIndexers());
} }
private object SaveAll() [HttpPut]
public IActionResult SaveAll(IndexerEditorResource resource)
{ {
var resource = Request.Body.FromJson<IndexerEditorResource>();
var indexersToUpdate = _indexerService.All().Where(x => resource.IndexerIds.Contains(x.Id)); var indexersToUpdate = _indexerService.All().Where(x => resource.IndexerIds.Contains(x.Id));
foreach (var indexer in indexersToUpdate) foreach (var indexer in indexersToUpdate)
@ -65,13 +63,12 @@ namespace Prowlarr.Api.V1.Indexers
_indexerService.SetProviderCharacteristics(definition); _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<IndexerEditorResource>();
_indexerService.DeleteIndexers(resource.IndexerIds); _indexerService.DeleteIndexers(resource.IndexerIds);
return new object(); return new object();

@ -1,19 +1,17 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using Prowlarr.Http; using Prowlarr.Http;
namespace Prowlarr.Api.V1.Indexers namespace Prowlarr.Api.V1.Indexers
{ {
public class IndexerFlagModule : ProwlarrRestModule<IndexerFlagResource> [V1ApiController]
public class IndexerFlagController : Controller
{ {
public IndexerFlagModule() [HttpGet]
{ public List<IndexerFlagResource> GetAll()
GetResourceAll = GetAll;
}
private List<IndexerFlagResource> GetAll()
{ {
return Enum.GetValues(typeof(IndexerFlags)).Cast<IndexerFlags>().Select(f => new IndexerFlagResource return Enum.GetValues(typeof(IndexerFlags)).Cast<IndexerFlags>().Select(f => new IndexerFlagResource
{ {

@ -1,28 +1,21 @@
using System; using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using NzbDrone.Core.IndexerStats; using NzbDrone.Core.IndexerStats;
using Prowlarr.Http; using Prowlarr.Http;
namespace Prowlarr.Api.V1.Indexers namespace Prowlarr.Api.V1.Indexers
{ {
public class IndexerStatsModule : ProwlarrRestModule<IndexerStatsResource> [V1ApiController]
public class IndexerStatsController : Controller
{ {
private readonly IIndexerStatisticsService _indexerStatisticsService; private readonly IIndexerStatisticsService _indexerStatisticsService;
public IndexerStatsModule(IIndexerStatisticsService indexerStatisticsService) public IndexerStatsController(IIndexerStatisticsService indexerStatisticsService)
{ {
_indexerStatisticsService = indexerStatisticsService; _indexerStatisticsService = indexerStatisticsService;
Get("/", x =>
{
return GetAll();
});
} }
private IndexerStatsResource GetAll() [HttpGet]
public IndexerStatsResource GetAll()
{ {
var indexerResource = new IndexerStatsResource var indexerResource = new IndexerStatsResource
{ {

@ -1,31 +1,40 @@
using System.Collections.Generic; using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.ThingiProvider.Events; using NzbDrone.Core.ThingiProvider.Events;
using NzbDrone.SignalR; using NzbDrone.SignalR;
using Prowlarr.Http; using Prowlarr.Http;
using Prowlarr.Http.REST;
using NotImplementedException = System.NotImplementedException;
namespace Prowlarr.Api.V1.Indexers namespace Prowlarr.Api.V1.Indexers
{ {
public class IndexerStatusModule : ProwlarrRestModuleWithSignalR<IndexerStatusResource, IndexerStatus>, [V1ApiController]
public class IndexerStatusController : RestControllerWithSignalR<IndexerStatusResource, IndexerStatus>,
IHandle<ProviderStatusChangedEvent<IIndexer>> IHandle<ProviderStatusChangedEvent<IIndexer>>
{ {
private readonly IIndexerStatusService _indexerStatusService; private readonly IIndexerStatusService _indexerStatusService;
public IndexerStatusModule(IBroadcastSignalRMessage signalRBroadcaster, IIndexerStatusService indexerStatusService) public IndexerStatusController(IBroadcastSignalRMessage signalRBroadcaster, IIndexerStatusService indexerStatusService)
: base(signalRBroadcaster) : base(signalRBroadcaster)
{ {
_indexerStatusService = indexerStatusService; _indexerStatusService = indexerStatusService;
}
GetResourceAll = GetAll; public override IndexerStatusResource GetResourceById(int id)
{
throw new NotImplementedException();
} }
private List<IndexerStatusResource> GetAll() [HttpGet]
public List<IndexerStatusResource> GetAll()
{ {
return _indexerStatusService.GetBlockedProviders().ToResource(); return _indexerStatusService.GetBlockedProviders().ToResource();
} }
[NonAction]
public void Handle(ProviderStatusChangedEvent<IIndexer> message) public void Handle(ProviderStatusChangedEvent<IIndexer> message)
{ {
BroadcastResourceChange(ModelAction.Sync); BroadcastResourceChange(ModelAction.Sync);

@ -1,19 +1,16 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Languages; using NzbDrone.Core.Languages;
using Prowlarr.Http; using Prowlarr.Http;
using Prowlarr.Http.REST;
namespace Prowlarr.Api.V1.Languages namespace Prowlarr.Api.V1.Languages
{ {
public class LanguageModule : ProwlarrRestModule<LanguageResource> [V1ApiController()]
public class LanguageController : RestController<LanguageResource>
{ {
public LanguageModule() public override LanguageResource GetResourceById(int id)
{
GetResourceAll = GetAll;
GetResourceById = GetById;
}
private LanguageResource GetById(int id)
{ {
var language = (Language)id; var language = (Language)id;
@ -24,7 +21,8 @@ namespace Prowlarr.Api.V1.Languages
}; };
} }
private List<LanguageResource> GetAll() [HttpGet]
public List<LanguageResource> GetAll()
{ {
return Language.All.Select(l => new LanguageResource return Language.All.Select(l => new LanguageResource
{ {

@ -1,21 +1,22 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json; using Newtonsoft.Json;
using NzbDrone.Core.Localization; using NzbDrone.Core.Localization;
using Prowlarr.Http; using Prowlarr.Http;
namespace Prowlarr.Api.V1.Localization namespace Prowlarr.Api.V1.Localization
{ {
public class LocalizationModule : ProwlarrRestModule<LocalizationResource> [V1ApiController]
public class LocalizationController : Controller
{ {
private readonly ILocalizationService _localizationService; private readonly ILocalizationService _localizationService;
public LocalizationModule(ILocalizationService localizationService) public LocalizationController(ILocalizationService localizationService)
{ {
_localizationService = 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 // We don't want camel case for transation strings, create new serializer settings
var serializerSettings = new JsonSerializerSettings var serializerSettings = new JsonSerializerSettings

@ -1,21 +1,25 @@
using System.Linq; using System.Linq;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Instrumentation; using NzbDrone.Core.Instrumentation;
using Prowlarr.Http; using Prowlarr.Http;
using Prowlarr.Http.Extensions;
namespace Prowlarr.Api.V1.Logs namespace Prowlarr.Api.V1.Logs
{ {
public class LogModule : ProwlarrRestModule<LogResource> [V1ApiController]
public class LogController : Controller
{ {
private readonly ILogService _logService; private readonly ILogService _logService;
public LogModule(ILogService logService) public LogController(ILogService logService)
{ {
_logService = logService; _logService = logService;
GetResourcePaged = GetLogs;
} }
private PagingResource<LogResource> GetLogs(PagingResource<LogResource> pagingResource) [HttpGet]
public PagingResource<LogResource> GetLogs()
{ {
var pagingResource = Request.ReadPagingResourceFromRequest<LogResource>();
var pageSpec = pagingResource.MapToPagingSpec<LogResource, Log>(); var pageSpec = pagingResource.MapToPagingSpec<LogResource, Log>();
if (pageSpec.SortKey == "time") 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") if (pageSpec.SortKey == "id")
{ {

@ -1,18 +1,20 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using Prowlarr.Http;
namespace Prowlarr.Api.V1.Logs namespace Prowlarr.Api.V1.Logs
{ {
public class LogFileModule : LogFileModuleBase [V1ApiController("log/file")]
public class LogFileController : LogFileControllerBase
{ {
private readonly IAppFolderInfo _appFolderInfo; private readonly IAppFolderInfo _appFolderInfo;
private readonly IDiskProvider _diskProvider; private readonly IDiskProvider _diskProvider;
public LogFileModule(IAppFolderInfo appFolderInfo, public LogFileController(IAppFolderInfo appFolderInfo,
IDiskProvider diskProvider, IDiskProvider diskProvider,
IConfigFileProvider configFileProvider) IConfigFileProvider configFileProvider)
: base(diskProvider, configFileProvider, "") : base(diskProvider, configFileProvider, "")

@ -1,35 +1,32 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using Nancy; using Microsoft.AspNetCore.Mvc;
using Nancy.Responses;
using NLog; using NLog;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using Prowlarr.Http;
namespace Prowlarr.Api.V1.Logs namespace Prowlarr.Api.V1.Logs
{ {
public abstract class LogFileModuleBase : ProwlarrRestModule<LogFileResource> public abstract class LogFileControllerBase : Controller
{ {
protected const string LOGFILE_ROUTE = @"/(?<filename>[-.a-zA-Z0-9]+?\.txt)"; protected const string LOGFILE_ROUTE = @"/(?<filename>[-.a-zA-Z0-9]+?\.txt)";
protected string _resource;
private readonly IDiskProvider _diskProvider; private readonly IDiskProvider _diskProvider;
private readonly IConfigFileProvider _configFileProvider; private readonly IConfigFileProvider _configFileProvider;
public LogFileModuleBase(IDiskProvider diskProvider, public LogFileControllerBase(IDiskProvider diskProvider,
IConfigFileProvider configFileProvider, IConfigFileProvider configFileProvider,
string route) string resource)
: base("log/file" + route)
{ {
_diskProvider = diskProvider; _diskProvider = diskProvider;
_configFileProvider = configFileProvider; _configFileProvider = configFileProvider;
GetResourceAll = GetLogFilesResponse; _resource = resource;
Get(LOGFILE_ROUTE, options => GetLogFileResponse(options.filename));
} }
private List<LogFileResource> GetLogFilesResponse() [HttpGet]
public List<LogFileResource> GetLogFilesResponse()
{ {
var result = new List<LogFileResource>(); var result = new List<LogFileResource>();
@ -45,7 +42,7 @@ namespace Prowlarr.Api.V1.Logs
Id = i + 1, Id = i + 1,
Filename = filename, Filename = filename,
LastWriteTime = _diskProvider.FileGetLastWrite(file), 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) 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(); 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(); LogManager.Flush();
@ -61,12 +59,10 @@ namespace Prowlarr.Api.V1.Logs
if (!_diskProvider.FileExists(filePath)) if (!_diskProvider.FileExists(filePath))
{ {
return new NotFoundResponse(); return NotFound();
} }
var data = _diskProvider.ReadAllText(filePath); return PhysicalFile(filePath, "text/plain");
return new TextResponse(data);
} }
protected abstract IEnumerable<string> GetLogFiles(); protected abstract IEnumerable<string> GetLogFiles();

@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@ -6,18 +6,20 @@ using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using Prowlarr.Http;
namespace Prowlarr.Api.V1.Logs namespace Prowlarr.Api.V1.Logs
{ {
public class UpdateLogFileModule : LogFileModuleBase [V1ApiController("log/file/update")]
public class UpdateLogFileController : LogFileControllerBase
{ {
private readonly IAppFolderInfo _appFolderInfo; private readonly IAppFolderInfo _appFolderInfo;
private readonly IDiskProvider _diskProvider; private readonly IDiskProvider _diskProvider;
public UpdateLogFileModule(IAppFolderInfo appFolderInfo, public UpdateLogFileController(IAppFolderInfo appFolderInfo,
IDiskProvider diskProvider, IDiskProvider diskProvider,
IConfigFileProvider configFileProvider) IConfigFileProvider configFileProvider)
: base(diskProvider, configFileProvider, "/update") : base(diskProvider, configFileProvider, "update")
{ {
_appFolderInfo = appFolderInfo; _appFolderInfo = appFolderInfo;
_diskProvider = diskProvider; _diskProvider = diskProvider;

@ -1,12 +1,14 @@
using NzbDrone.Core.Notifications; using NzbDrone.Core.Notifications;
using Prowlarr.Http;
namespace Prowlarr.Api.V1.Notifications namespace Prowlarr.Api.V1.Notifications
{ {
public class NotificationModule : ProviderModuleBase<NotificationResource, INotification, NotificationDefinition> [V1ApiController]
public class NotificationController : ProviderControllerBase<NotificationResource, INotification, NotificationDefinition>
{ {
public static readonly NotificationResourceMapper ResourceMapper = new NotificationResourceMapper(); public static readonly NotificationResourceMapper ResourceMapper = new NotificationResourceMapper();
public NotificationModule(NotificationFactory notificationFactory) public NotificationController(NotificationFactory notificationFactory)
: base(notificationFactory, "notification", ResourceMapper) : base(notificationFactory, "notification", ResourceMapper)
{ {
} }

@ -2,16 +2,17 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using FluentValidation; using FluentValidation;
using FluentValidation.Results; using FluentValidation.Results;
using Nancy; using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Serializer; using NzbDrone.Common.Serializer;
using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.Validation; using NzbDrone.Core.Validation;
using Prowlarr.Http; using NzbDrone.Http.REST.Attributes;
using Prowlarr.Http.Extensions; using Prowlarr.Http.Extensions;
using Prowlarr.Http.REST;
namespace Prowlarr.Api.V1 namespace Prowlarr.Api.V1
{ {
public abstract class ProviderModuleBase<TProviderResource, TProvider, TProviderDefinition> : ProwlarrRestModule<TProviderResource> public abstract class ProviderControllerBase<TProviderResource, TProvider, TProviderDefinition> : RestController<TProviderResource>
where TProviderDefinition : ProviderDefinition, new() where TProviderDefinition : ProviderDefinition, new()
where TProvider : IProvider where TProvider : IProvider
where TProviderResource : ProviderResource<TProviderResource>, new() where TProviderResource : ProviderResource<TProviderResource>, new()
@ -19,23 +20,11 @@ namespace Prowlarr.Api.V1
protected readonly IProviderFactory<TProvider, TProviderDefinition> _providerFactory; protected readonly IProviderFactory<TProvider, TProviderDefinition> _providerFactory;
protected readonly ProviderResourceMapper<TProviderResource, TProviderDefinition> _resourceMapper; protected readonly ProviderResourceMapper<TProviderResource, TProviderDefinition> _resourceMapper;
protected ProviderModuleBase(IProviderFactory<TProvider, TProviderDefinition> providerFactory, string resource, ProviderResourceMapper<TProviderResource, TProviderDefinition> resourceMapper) protected ProviderControllerBase(IProviderFactory<TProvider, TProviderDefinition> providerFactory, string resource, ProviderResourceMapper<TProviderResource, TProviderDefinition> resourceMapper)
: base(resource)
{ {
_providerFactory = providerFactory; _providerFactory = providerFactory;
_resourceMapper = resourceMapper; _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).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.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(); SharedValidator.RuleFor(c => c.Implementation).NotEmpty();
@ -44,7 +33,7 @@ namespace Prowlarr.Api.V1
PostValidator.RuleFor(c => c.Fields).NotNull(); PostValidator.RuleFor(c => c.Fields).NotNull();
} }
private TProviderResource GetProviderById(int id) public override TProviderResource GetResourceById(int id)
{ {
var definition = _providerFactory.Get(id); var definition = _providerFactory.Get(id);
_providerFactory.SetProviderCharacteristics(definition); _providerFactory.SetProviderCharacteristics(definition);
@ -52,7 +41,8 @@ namespace Prowlarr.Api.V1
return _resourceMapper.ToResource(definition); return _resourceMapper.ToResource(definition);
} }
private List<TProviderResource> GetAll() [HttpGet]
public List<TProviderResource> GetAll()
{ {
var providerDefinitions = _providerFactory.All().OrderBy(p => p.ImplementationName); var providerDefinitions = _providerFactory.All().OrderBy(p => p.ImplementationName);
@ -68,7 +58,8 @@ namespace Prowlarr.Api.V1
return result.OrderBy(p => p.Name).ToList(); return result.OrderBy(p => p.Name).ToList();
} }
private int CreateProvider(TProviderResource providerResource) [RestPostById]
public ActionResult<TProviderResource> CreateProvider(TProviderResource providerResource)
{ {
var providerDefinition = GetDefinition(providerResource, false); var providerDefinition = GetDefinition(providerResource, false);
@ -79,10 +70,11 @@ namespace Prowlarr.Api.V1
providerDefinition = _providerFactory.Create(providerDefinition); providerDefinition = _providerFactory.Create(providerDefinition);
return providerDefinition.Id; return Created(providerDefinition.Id);
} }
private void UpdateProvider(TProviderResource providerResource) [RestPutById]
public ActionResult<TProviderResource> UpdateProvider(TProviderResource providerResource)
{ {
var providerDefinition = GetDefinition(providerResource, false); var providerDefinition = GetDefinition(providerResource, false);
var forceSave = Request.GetBooleanQueryParameter("forceSave"); var forceSave = Request.GetBooleanQueryParameter("forceSave");
@ -94,6 +86,8 @@ namespace Prowlarr.Api.V1
} }
_providerFactory.Update(providerDefinition); _providerFactory.Update(providerDefinition);
return Accepted(providerResource.Id);
} }
private TProviderDefinition GetDefinition(TProviderResource providerResource, bool includeWarnings = false, bool validate = true) private TProviderDefinition GetDefinition(TProviderResource providerResource, bool includeWarnings = false, bool validate = true)
@ -108,12 +102,15 @@ namespace Prowlarr.Api.V1
return definition; return definition;
} }
private void DeleteProvider(int id) [RestDeleteById]
public object DeleteProvider(int id)
{ {
_providerFactory.Delete(id); _providerFactory.Delete(id);
return new object();
} }
protected virtual object GetTemplates() [HttpGet("schema")]
public virtual List<TProviderResource> GetTemplates()
{ {
var defaultDefinitions = _providerFactory.GetDefaultDefinitions().OrderBy(p => p.ImplementationName).ToList(); var defaultDefinitions = _providerFactory.GetDefaultDefinitions().OrderBy(p => p.ImplementationName).ToList();
@ -134,7 +131,9 @@ namespace Prowlarr.Api.V1
return result; return result;
} }
private object Test(TProviderResource providerResource) [SkipValidation(true, false)]
[HttpPost("test")]
public object Test([FromBody] TProviderResource providerResource)
{ {
var providerDefinition = GetDefinition(providerResource, true); var providerDefinition = GetDefinition(providerResource, true);
@ -143,7 +142,8 @@ namespace Prowlarr.Api.V1
return "{}"; return "{}";
} }
private object TestAll() [HttpPost("testall")]
public IActionResult TestAll()
{ {
var providerDefinitions = _providerFactory.All() var providerDefinitions = _providerFactory.All()
.Where(c => c.Settings.Validate().IsValid && c.Enable) .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<string, object>)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); return Content(data.ToJson(), "application/json");
Response resp = data.ToJson();
resp.ContentType = "application/json";
return resp;
} }
protected virtual void Validate(TProviderDefinition definition, bool includeWarnings) protected virtual void Validate(TProviderDefinition definition, bool includeWarnings)

@ -5,9 +5,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentValidation" Version="8.6.2" /> <PackageReference Include="FluentValidation" Version="8.6.2" />
<PackageReference Include="Ical.Net" Version="4.1.11" /> <PackageReference Include="Ical.Net" Version="4.1.11" />
<PackageReference Include="Nancy" Version="2.0.0" />
<PackageReference Include="Nancy.Authentication.Basic" Version="2.0.0" />
<PackageReference Include="Nancy.Authentication.Forms" Version="2.0.0" />
<PackageReference Include="NLog" Version="4.7.7" /> <PackageReference Include="NLog" Version="4.7.7" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

@ -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('/'))
{
}
}
}

@ -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('/'))
{
}
}
}

@ -1,47 +1,42 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Net; using System.Net;
using Nancy.ModelBinding; using Microsoft.AspNetCore.Mvc;
using NLog; using NLog;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Exceptions; using NzbDrone.Core.Exceptions;
using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.IndexerSearch;
using NzbDrone.Core.IndexerSearch.Definitions;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using Prowlarr.Http; using Prowlarr.Http;
using Prowlarr.Http.Extensions; using Prowlarr.Http.Extensions;
namespace Prowlarr.Api.V1.Search namespace Prowlarr.Api.V1.Search
{ {
public class SearchModule : ProwlarrRestModule<SearchResource> [V1ApiController]
public class SearchController : Controller
{ {
private readonly ISearchForNzb _nzbSearhService; private readonly ISearchForNzb _nzbSearhService;
private readonly Logger _logger; private readonly Logger _logger;
public SearchModule(ISearchForNzb nzbSearhService, Logger logger) public SearchController(ISearchForNzb nzbSearhService, Logger logger)
{ {
_nzbSearhService = nzbSearhService; _nzbSearhService = nzbSearhService;
_logger = logger; _logger = logger;
GetResourceAll = GetAll;
} }
private List<SearchResource> GetAll() [HttpGet]
public List<SearchResource> GetAll(string query, [FromQuery] List<int> indexerIds, [FromQuery] List<int> categories)
{ {
var request = this.Bind<SearchRequest>(); if (query.IsNotNullOrWhiteSpace())
if (request.Query.IsNotNullOrWhiteSpace())
{ {
var indexerIds = request.IndexerIds ?? new List<int>(); if (indexerIds.Any())
var categories = request.Categories ?? new List<int>();
if (indexerIds.Count > 0)
{ {
return GetSearchReleases(request.Query, indexerIds, categories); return GetSearchReleases(query, indexerIds, categories);
} }
else else
{ {
return GetSearchReleases(request.Query, null, categories); return GetSearchReleases(query, null, categories);
} }
} }

@ -1,11 +0,0 @@
using System.Collections.Generic;
namespace Prowlarr.Api.V1.Search
{
public class SearchRequest
{
public List<int> IndexerIds { get; set; }
public string Query { get; set; }
public List<int> Categories { get; set; }
}
}

@ -1,17 +1,20 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Crypto; using NzbDrone.Common.Crypto;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Backup; using NzbDrone.Core.Backup;
using NzbDrone.Http.REST.Attributes;
using Prowlarr.Http; using Prowlarr.Http;
using Prowlarr.Http.REST; using Prowlarr.Http.REST;
namespace Prowlarr.Api.V1.System.Backup namespace Prowlarr.Api.V1.System.Backup
{ {
public class BackupModule : ProwlarrRestModule<BackupResource> [V1ApiController("system/backup")]
public class BackupController : Controller
{ {
private readonly IBackupService _backupService; private readonly IBackupService _backupService;
private readonly IAppFolderInfo _appFolderInfo; private readonly IAppFolderInfo _appFolderInfo;
@ -19,21 +22,16 @@ namespace Prowlarr.Api.V1.System.Backup
private static readonly List<string> ValidExtensions = new List<string> { ".zip", ".db", ".xml" }; private static readonly List<string> ValidExtensions = new List<string> { ".zip", ".db", ".xml" };
public BackupModule(IBackupService backupService, public BackupController(IBackupService backupService,
IAppFolderInfo appFolderInfo, IAppFolderInfo appFolderInfo,
IDiskProvider diskProvider) IDiskProvider diskProvider)
: base("system/backup")
{ {
_backupService = backupService; _backupService = backupService;
_appFolderInfo = appFolderInfo; _appFolderInfo = appFolderInfo;
_diskProvider = diskProvider; _diskProvider = diskProvider;
GetResourceAll = GetBackupFiles;
DeleteResource = DeleteBackup;
Post(@"/restore/(?<id>[\d]{1,10})", x => Restore((int)x.Id));
Post("/restore/upload", x => UploadAndRestore());
} }
[HttpGet]
public List<BackupResource> GetBackupFiles() public List<BackupResource> GetBackupFiles()
{ {
var backups = _backupService.GetBackups(); var backups = _backupService.GetBackups();
@ -50,7 +48,8 @@ namespace Prowlarr.Api.V1.System.Backup
.ToList(); .ToList();
} }
private void DeleteBackup(int id) [RestDeleteById]
public void DeleteBackup(int id)
{ {
var backup = GetBackup(id); var backup = GetBackup(id);
var path = GetBackupPath(backup); var path = GetBackupPath(backup);
@ -63,6 +62,7 @@ namespace Prowlarr.Api.V1.System.Backup
_diskProvider.DeleteFile(path); _diskProvider.DeleteFile(path);
} }
[HttpPost("restore/{id:int}")]
public object Restore(int id) public object Restore(int id)
{ {
var backup = GetBackup(id); var backup = GetBackup(id);
@ -82,9 +82,10 @@ namespace Prowlarr.Api.V1.System.Backup
}; };
} }
[HttpPost("restore/upload")]
public object UploadAndRestore() public object UploadAndRestore()
{ {
var files = Context.Request.Files.ToList(); var files = Request.Form.Files;
if (files.Empty()) if (files.Empty())
{ {
@ -92,7 +93,7 @@ namespace Prowlarr.Api.V1.System.Backup
} }
var file = files.First(); var file = files.First();
var extension = Path.GetExtension(file.Name); var extension = Path.GetExtension(file.FileName);
if (!ValidExtensions.Contains(extension)) if (!ValidExtensions.Contains(extension))
{ {
@ -101,7 +102,7 @@ namespace Prowlarr.Api.V1.System.Backup
var path = Path.Combine(_appFolderInfo.TempFolder, $"prowlarr_backup_restore{extension}"); var path = Path.Combine(_appFolderInfo.TempFolder, $"prowlarr_backup_restore{extension}");
_diskProvider.SaveStream(file.Value, path); _diskProvider.SaveStream(file.OpenReadStream(), path);
_backupService.Restore(path); _backupService.Restore(path);
// Cleanup restored file // Cleanup restored file

@ -1,52 +1,60 @@
using System.IO;
using System.Threading.Tasks; 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.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore;
using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Lifecycle;
using Prowlarr.Http;
using Prowlarr.Http.Validation;
namespace Prowlarr.Api.V1.System namespace Prowlarr.Api.V1.System
{ {
public class SystemModule : ProwlarrV1Module [V1ApiController]
public class SystemController : Controller
{ {
private readonly IAppFolderInfo _appFolderInfo; private readonly IAppFolderInfo _appFolderInfo;
private readonly IRuntimeInfo _runtimeInfo; private readonly IRuntimeInfo _runtimeInfo;
private readonly IPlatformInfo _platformInfo; private readonly IPlatformInfo _platformInfo;
private readonly IOsInfo _osInfo; private readonly IOsInfo _osInfo;
private readonly IRouteCacheProvider _routeCacheProvider;
private readonly IConfigFileProvider _configFileProvider; private readonly IConfigFileProvider _configFileProvider;
private readonly IMainDatabase _database; private readonly IMainDatabase _database;
private readonly ILifecycleService _lifecycleService; private readonly ILifecycleService _lifecycleService;
private readonly IDeploymentInfoProvider _deploymentInfoProvider; private readonly IDeploymentInfoProvider _deploymentInfoProvider;
private readonly EndpointDataSource _endpointData;
private readonly DfaGraphWriter _graphWriter;
private readonly DuplicateEndpointDetector _detector;
public SystemModule(IAppFolderInfo appFolderInfo, public SystemController(IAppFolderInfo appFolderInfo,
IRuntimeInfo runtimeInfo, IRuntimeInfo runtimeInfo,
IPlatformInfo platformInfo, IPlatformInfo platformInfo,
IOsInfo osInfo, IOsInfo osInfo,
IRouteCacheProvider routeCacheProvider, IConfigFileProvider configFileProvider,
IConfigFileProvider configFileProvider, IMainDatabase database,
IMainDatabase database, ILifecycleService lifecycleService,
ILifecycleService lifecycleService, IDeploymentInfoProvider deploymentInfoProvider,
IDeploymentInfoProvider deploymentInfoProvider) EndpointDataSource endpoints,
: base("system") DfaGraphWriter graphWriter,
DuplicateEndpointDetector detector)
{ {
_appFolderInfo = appFolderInfo; _appFolderInfo = appFolderInfo;
_runtimeInfo = runtimeInfo; _runtimeInfo = runtimeInfo;
_platformInfo = platformInfo; _platformInfo = platformInfo;
_osInfo = osInfo; _osInfo = osInfo;
_routeCacheProvider = routeCacheProvider;
_configFileProvider = configFileProvider; _configFileProvider = configFileProvider;
_database = database; _database = database;
_lifecycleService = lifecycleService; _lifecycleService = lifecycleService;
_deploymentInfoProvider = deploymentInfoProvider; _deploymentInfoProvider = deploymentInfoProvider;
Get("/status", x => GetStatus()); _endpointData = endpoints;
Get("/routes", x => GetRoutes()); _graphWriter = graphWriter;
Post("/shutdown", x => Shutdown()); _detector = detector;
Post("/restart", x => Restart());
} }
private object GetStatus() [HttpGet("status")]
public object GetStatus()
{ {
return new 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()); Task.Factory.StartNew(() => _lifecycleService.Shutdown());
return new { ShuttingDown = true }; return new { ShuttingDown = true };
} }
private object Restart() [HttpPost("restart")]
public object Restart()
{ {
Task.Factory.StartNew(() => _lifecycleService.Restart()); Task.Factory.StartNew(() => _lifecycleService.Restart());
return new { Restarting = true }; return new { Restarting = true };

@ -1,27 +1,29 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.Jobs; using NzbDrone.Core.Jobs;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
using NzbDrone.SignalR; using NzbDrone.SignalR;
using Prowlarr.Http; using Prowlarr.Http;
using Prowlarr.Http.REST;
namespace Prowlarr.Api.V1.System.Tasks namespace Prowlarr.Api.V1.System.Tasks
{ {
public class TaskModule : ProwlarrRestModuleWithSignalR<TaskResource, ScheduledTask>, IHandle<CommandExecutedEvent> [V1ApiController("system/task")]
public class TaskController : RestControllerWithSignalR<TaskResource, ScheduledTask>, IHandle<CommandExecutedEvent>
{ {
private readonly ITaskManager _taskManager; private readonly ITaskManager _taskManager;
public TaskModule(ITaskManager taskManager, IBroadcastSignalRMessage broadcastSignalRMessage) public TaskController(ITaskManager taskManager, IBroadcastSignalRMessage broadcastSignalRMessage)
: base(broadcastSignalRMessage, "system/task") : base(broadcastSignalRMessage)
{ {
_taskManager = taskManager; _taskManager = taskManager;
GetResourceAll = GetAll;
GetResourceById = GetTask;
} }
private List<TaskResource> GetAll() [HttpGet]
public List<TaskResource> GetAll()
{ {
return _taskManager.GetAll() return _taskManager.GetAll()
.Select(ConvertToResource) .Select(ConvertToResource)
@ -29,7 +31,7 @@ namespace Prowlarr.Api.V1.System.Tasks
.ToList(); .ToList();
} }
private TaskResource GetTask(int id) public override TaskResource GetResourceById(int id)
{ {
var task = _taskManager.GetAll() var task = _taskManager.GetAll()
.SingleOrDefault(t => t.Id == id); .SingleOrDefault(t => t.Id == id);
@ -58,6 +60,7 @@ namespace Prowlarr.Api.V1.System.Tasks
}; };
} }
[NonAction]
public void Handle(CommandExecutedEvent message) public void Handle(CommandExecutedEvent message)
{ {
BroadcastResourceChange(ModelAction.Sync); BroadcastResourceChange(ModelAction.Sync);

@ -1,54 +1,59 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Tags; using NzbDrone.Core.Tags;
using NzbDrone.Http.REST.Attributes;
using NzbDrone.SignalR; using NzbDrone.SignalR;
using Prowlarr.Http; using Prowlarr.Http;
using Prowlarr.Http.REST;
namespace Prowlarr.Api.V1.Tags namespace Prowlarr.Api.V1.Tags
{ {
public class TagModule : ProwlarrRestModuleWithSignalR<TagResource, Tag>, IHandle<TagsUpdatedEvent> [V1ApiController]
public class TagController : RestControllerWithSignalR<TagResource, Tag>, IHandle<TagsUpdatedEvent>
{ {
private readonly ITagService _tagService; private readonly ITagService _tagService;
public TagModule(IBroadcastSignalRMessage signalRBroadcaster, public TagController(IBroadcastSignalRMessage signalRBroadcaster,
ITagService tagService) ITagService tagService)
: base(signalRBroadcaster) : base(signalRBroadcaster)
{ {
_tagService = tagService; _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(); return _tagService.GetTag(id).ToResource();
} }
private List<TagResource> GetAll() [HttpGet]
public List<TagResource> GetAll()
{ {
return _tagService.All().ToResource(); return _tagService.All().ToResource();
} }
private int Create(TagResource resource) [RestPostById]
public ActionResult<TagResource> Create(TagResource resource)
{ {
return _tagService.Add(resource.ToModel()).Id; return Created(_tagService.Add(resource.ToModel()).Id);
} }
private void Update(TagResource resource) [RestPutById]
public ActionResult<TagResource> Update(TagResource resource)
{ {
_tagService.Update(resource.ToModel()); _tagService.Update(resource.ToModel());
return Accepted(resource.Id);
} }
private void DeleteTag(int id) [RestDeleteById]
public void DeleteTag(int id)
{ {
_tagService.Delete(id); _tagService.Delete(id);
} }
[NonAction]
public void Handle(TagsUpdatedEvent message) public void Handle(TagsUpdatedEvent message)
{ {
BroadcastResourceChange(ModelAction.Sync); BroadcastResourceChange(ModelAction.Sync);

@ -1,28 +1,28 @@
using System.Collections.Generic; using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Tags; using NzbDrone.Core.Tags;
using Prowlarr.Http; using Prowlarr.Http;
using Prowlarr.Http.REST;
namespace Prowlarr.Api.V1.Tags namespace Prowlarr.Api.V1.Tags
{ {
public class TagDetailsModule : ProwlarrRestModule<TagDetailsResource> [V1ApiController("tag/detail")]
public class TagDetailsController : RestController<TagDetailsResource>
{ {
private readonly ITagService _tagService; private readonly ITagService _tagService;
public TagDetailsModule(ITagService tagService) public TagDetailsController(ITagService tagService)
: base("/tag/detail")
{ {
_tagService = tagService; _tagService = tagService;
GetResourceById = GetById;
GetResourceAll = GetAll;
} }
private TagDetailsResource GetById(int id) public override TagDetailsResource GetResourceById(int id)
{ {
return _tagService.Details(id).ToResource(); return _tagService.Details(id).ToResource();
} }
private List<TagDetailsResource> GetAll() [HttpGet]
public List<TagDetailsResource> GetAll()
{ {
return _tagService.Details().ToResource(); return _tagService.Details().ToResource();
} }

@ -1,22 +1,24 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Update; using NzbDrone.Core.Update;
using Prowlarr.Http; using Prowlarr.Http;
namespace Prowlarr.Api.V1.Update namespace Prowlarr.Api.V1.Update
{ {
public class UpdateModule : ProwlarrRestModule<UpdateResource> [V1ApiController]
public class UpdateController : Controller
{ {
private readonly IRecentUpdateProvider _recentUpdateProvider; private readonly IRecentUpdateProvider _recentUpdateProvider;
public UpdateModule(IRecentUpdateProvider recentUpdateProvider) public UpdateController(IRecentUpdateProvider recentUpdateProvider)
{ {
_recentUpdateProvider = recentUpdateProvider; _recentUpdateProvider = recentUpdateProvider;
GetResourceAll = GetRecentUpdates;
} }
private List<UpdateResource> GetRecentUpdates() [HttpGet]
public List<UpdateResource> GetRecentUpdates()
{ {
var resources = _recentUpdateProvider.GetRecentUpdatePackages() var resources = _recentUpdateProvider.GetRecentUpdatePackages()
.OrderByDescending(u => u.Version) .OrderByDescending(u => u.Version)

@ -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<ApiKeyAuthenticationOptions>
{
public ApiKeyAuthenticationHandler(IOptionsMonitor<ApiKeyAuthenticationOptions> 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<AuthenticateResult> HandleAuthenticateAsync()
{
var providedApiKey = ParseApiKey();
if (string.IsNullOrWhiteSpace(providedApiKey))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
if (Options.ApiKey == providedApiKey)
{
var claims = new List<Claim>
{
new Claim("ApiKey", "true")
};
var identity = new ClaimsIdentity(claims, Options.AuthenticationType);
var identities = new List<ClaimsIdentity> { 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;
}
}
}

@ -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<ApiKeyAuthenticationOptions> options)
{
return authenticationBuilder.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(name, options);
}
public static AuthenticationBuilder AddBasicAuthentication(this AuthenticationBuilder authenticationBuilder)
{
return authenticationBuilder.AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>(AuthenticationType.Basic.ToString(), options => { });
}
public static AuthenticationBuilder AddNoAuthentication(this AuthenticationBuilder authenticationBuilder)
{
return authenticationBuilder.AddScheme<AuthenticationSchemeOptions, NoAuthenticationHandler>(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;
}
}
}

@ -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<IActionResult> 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<Claim>
{
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<IActionResult> Logout()
{
_authService.Logout(HttpContext);
await HttpContext.SignOutAsync();
return Redirect("/");
}
}
}

@ -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<LoginResource>()));
Get("/logout", x => Logout());
}
private Response Login(LoginResource resource)
{
var user = _authService.Login(Context, resource.Username, resource.Password);
if (user == null)
{
var returnUrl = (string)Request.Query.returnUrl;
return Context.GetRedirect($"~/login?returnUrl={returnUrl}&loginFailed=true");
}
DateTime? expiry = null;
if (resource.RememberMe)
{
expiry = DateTime.UtcNow.AddDays(7);
}
return this.LoginAndRedirect(user.Identifier, expiry, _configFileProvider.UrlBase + "/");
}
private Response Logout()
{
_authService.Logout(Context);
return this.LogoutAndRedirect(_configFileProvider.UrlBase + "/");
}
}
}

@ -1,26 +1,16 @@
using System; using Microsoft.AspNetCore.Http;
using System.Linq;
using System.Security.Claims;
using System.Security.Principal;
using Nancy;
using Nancy.Authentication.Basic;
using Nancy.Authentication.Forms;
using NLog; using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Authentication; using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using Prowlarr.Http.Extensions; using Prowlarr.Http.Extensions;
namespace Prowlarr.Http.Authentication namespace Prowlarr.Http.Authentication
{ {
public interface IAuthenticationService : IUserValidator, IUserMapper public interface IAuthenticationService
{ {
void SetContext(NancyContext context); void LogUnauthorized(HttpRequest context);
User Login(HttpRequest request, string username, string password);
void LogUnauthorized(NancyContext context); void Logout(HttpContext context);
User Login(NancyContext context, string username, string password);
void Logout(NancyContext context);
bool IsAuthenticated(NancyContext context);
} }
public class AuthenticationService : IAuthenticationService public class AuthenticationService : IAuthenticationService
@ -32,9 +22,6 @@ namespace Prowlarr.Http.Authentication
private static string API_KEY; private static string API_KEY;
private static AuthenticationType AUTH_METHOD; private static AuthenticationType AUTH_METHOD;
[ThreadStatic]
private static NancyContext _context;
public AuthenticationService(IConfigFileProvider configFileProvider, IUserService userService) public AuthenticationService(IConfigFileProvider configFileProvider, IUserService userService)
{ {
_userService = userService; _userService = userService;
@ -42,13 +29,7 @@ namespace Prowlarr.Http.Authentication
AUTH_METHOD = configFileProvider.AuthenticationMethod; AUTH_METHOD = configFileProvider.AuthenticationMethod;
} }
public void SetContext(NancyContext context) public User Login(HttpRequest request, string username, string password)
{
// Validate and GetUserIdentifier don't have access to the NancyContext so get it from the pipeline earlier
_context = context;
}
public User Login(NancyContext context, string username, string password)
{ {
if (AUTH_METHOD == AuthenticationType.None) if (AUTH_METHOD == AuthenticationType.None)
{ {
@ -59,174 +40,50 @@ namespace Prowlarr.Http.Authentication
if (user != null) if (user != null)
{ {
LogSuccess(context, username); LogSuccess(request, username);
return user; return user;
} }
LogFailure(context, username); LogFailure(request, username);
return null; return null;
} }
public void Logout(NancyContext context) public void Logout(HttpContext context)
{ {
if (AUTH_METHOD == AuthenticationType.None) if (AUTH_METHOD == AuthenticationType.None)
{ {
return; return;
} }
if (context.CurrentUser != null) if (context.User != null)
{
LogLogout(context, context.CurrentUser.Identity.Name);
}
}
public ClaimsPrincipal Validate(string username, string password)
{
if (AUTH_METHOD == AuthenticationType.None)
{
return new ClaimsPrincipal(new GenericIdentity(AnonymousUser));
}
var user = _userService.FindUser(username, password);
if (user != null)
{
if (AUTH_METHOD != AuthenticationType.Basic)
{
// Don't log success for basic auth
LogSuccess(_context, username);
}
return new ClaimsPrincipal(new GenericIdentity(user.Username));
}
LogFailure(_context, username);
return null;
}
public ClaimsPrincipal GetUserFromIdentifier(Guid identifier, NancyContext context)
{
if (AUTH_METHOD == AuthenticationType.None)
{
return new ClaimsPrincipal(new GenericIdentity(AnonymousUser));
}
var user = _userService.FindUser(identifier);
if (user != null)
{
return new ClaimsPrincipal(new GenericIdentity(user.Username));
}
LogInvalidated(_context);
return null;
}
public bool IsAuthenticated(NancyContext context)
{
var apiKey = GetApiKey(context);
if (context.Request.IsApiRequest())
{ {
return ValidApiKey(apiKey); 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()); _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); _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); _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); _authLogger.Info("Auth-Logout ip {0} username '{1}'", context.GetRemoteIP(), username);
} }

@ -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<AuthenticationSchemeOptions>
{
private readonly IAuthenticationService _authService;
public BasicAuthenticationHandler(IAuthenticationService authService,
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
: base(options, logger, encoder, clock)
{
_authService = authService;
}
protected override Task<AuthenticateResult> 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<Claim>
{
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;
}
}
}

@ -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<NancyContext>)SlidingAuthenticationForFormsAuth);
}
else if (_configFileProvider.AuthenticationMethod == AuthenticationType.Basic)
{
pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, "Prowlarr"));
pipelines.BeforeRequest.AddItemToStartOfPipeline(CaptureContext);
}
pipelines.BeforeRequest.AddItemToEndOfPipeline((Func<NancyContext, Response>)RequiresAuthentication);
pipelines.AfterRequest.AddItemToEndOfPipeline((Action<NancyContext>)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;
}
}
}

@ -4,6 +4,6 @@
{ {
public string Username { get; set; } public string Username { get; set; }
public string Password { get; set; } public string Password { get; set; }
public bool RememberMe { get; set; } public string RememberMe { get; set; }
} }
} }

@ -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<AuthenticationSchemeOptions>
{
public NoAuthenticationHandler(IAuthenticationService authService,
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new List<Claim>
{
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));
}
}
}

@ -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);
}
}
}
}

@ -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 namespace Prowlarr.Http.ErrorManagement
{ {
@ -17,5 +21,12 @@ namespace Prowlarr.Http.ErrorManagement
public ErrorModel() 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);
}
} }
} }

@ -1,15 +1,14 @@
using System;
using System.Data.SQLite; using System.Data.SQLite;
using System.Net;
using System.Threading.Tasks;
using FluentValidation; using FluentValidation;
using Nancy; using Microsoft.AspNetCore.Diagnostics;
using Nancy.Extensions; using Microsoft.AspNetCore.Http;
using Nancy.IO;
using NLog; using NLog;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore;
using NzbDrone.Core.Exceptions; using NzbDrone.Core.Exceptions;
using Prowlarr.Http.Exceptions; using Prowlarr.Http.Exceptions;
using Prowlarr.Http.Extensions;
using HttpStatusCode = Nancy.HttpStatusCode;
namespace Prowlarr.Http.ErrorManagement namespace Prowlarr.Http.ErrorManagement
{ {
@ -22,63 +21,81 @@ namespace Prowlarr.Http.ErrorManagement
_logger = logger; _logger = logger;
} }
public Response HandleException(NancyContext context, Exception exception) public async Task HandleException(HttpContext context)
{ {
_logger.Trace("Handling Exception"); _logger.Trace("Handling Exception");
var response = context.Response;
var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
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) if (exception is ApiException apiException)
{ {
_logger.Warn(apiException, "API Error:\n{0}", apiException.Message); _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); _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;
} }
else if (exception is NzbDroneClientException clientException)
if (exception is NzbDroneClientException clientException)
{ {
return new ErrorModel errorModel = new ErrorModel
{ {
Message = exception.Message, Message = exception.Message,
Description = exception.ToString() Description = exception.ToString()
}.AsResponse(context, (HttpStatusCode)clientException.StatusCode); };
statusCode = clientException.StatusCode;
} }
else if (exception is ModelNotFoundException notFoundException)
if (exception is ModelNotFoundException notFoundException)
{ {
return new ErrorModel errorModel = new ErrorModel
{ {
Message = exception.Message, Message = exception.Message,
Description = exception.ToString() Description = exception.ToString()
}.AsResponse(context, HttpStatusCode.NotFound); };
statusCode = HttpStatusCode.NotFound;
} }
else if (exception is ModelConflictException conflictException)
if (exception is ModelConflictException conflictException)
{ {
return new ErrorModel _logger.Error(exception, "DB error");
errorModel = new ErrorModel
{ {
Message = exception.Message, Message = exception.Message,
Description = exception.ToString() Description = exception.ToString()
}.AsResponse(context, HttpStatusCode.Conflict); };
statusCode = HttpStatusCode.Conflict;
} }
else if (exception is SQLiteException sqLiteException)
if (exception is SQLiteException sqLiteException)
{ {
if (context.Request.Method == "PUT" || context.Request.Method == "POST") if (context.Request.Method == "PUT" || context.Request.Method == "POST")
{ {
if (sqLiteException.Message.Contains("constraint failed")) if (sqLiteException.Message.Contains("constraint failed"))
{ {
return new ErrorModel errorModel = new ErrorModel
{ {
Message = exception.Message, 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); _logger.Fatal(exception, "Request Failed. {0} {1}", context.Request.Method, context.Request.Path);
return new ErrorModel await errorModel.WriteToResponse(response, statusCode);
{
Message = exception.Message,
Description = exception.ToString()
}.AsResponse(context, HttpStatusCode.InternalServerError);
} }
} }
} }

@ -1,8 +1,5 @@
using System; using System;
using Nancy; using System.Net;
using Nancy.Responses;
using Prowlarr.Http.ErrorManagement;
using Prowlarr.Http.Extensions;
namespace Prowlarr.Http.Exceptions namespace Prowlarr.Http.Exceptions
{ {
@ -19,11 +16,6 @@ namespace Prowlarr.Http.Exceptions
Content = content; Content = content;
} }
public JsonResponse<ErrorModel> ToErrorResponse(NancyContext context)
{
return new ErrorModel(this).AsResponse(context, StatusCode);
}
private static string GetMessage(HttpStatusCode statusCode, object content) private static string GetMessage(HttpStatusCode statusCode, object content)
{ {
var result = statusCode.ToString(); var result = statusCode.ToString();

@ -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<TModel>(MediaRange contentType, TModel model, Stream outputStream)
{
STJson.Serialize(model, outputStream, _serializerSettings);
}
public IEnumerable<string> Extensions { get; private set; }
}
}

@ -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<NancyContext>)Handle);
}
private void Handle(NancyContext context)
{
if (context.Request.Method == "OPTIONS")
{
return;
}
if (_cacheableSpecification.IsCacheable(context))
{
context.Response.Headers.EnableCache();
}
else
{
context.Response.Headers.DisableCache();
}
}
}
}

@ -1,80 +0,0 @@
using System.Linq;
using Nancy;
using Nancy.Bootstrapper;
using NzbDrone.Common.Extensions;
namespace 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);
}
}
}
}
}

@ -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<Action<Stream>, Stream> _writeGZipStream;
public GzipCompressionPipeline(Logger logger)
{
_logger = logger;
// On Mono GZipStream/DeflateStream leaks memory if an exception is thrown, use an intermediate buffer in that case.
_writeGZipStream = PlatformInfo.IsMono ? WriteGZipStreamMono : (Action<Action<Stream>, Stream>)WriteGZipStream;
}
public void Register(IPipelines pipelines)
{
pipelines.AfterRequest.AddItemToEndOfPipeline(CompressResponse);
}
private void CompressResponse(NancyContext context)
{
var request = context.Request;
var response = context.Response;
try
{
if (
response.Contents != Response.NoBody
&& !response.ContentType.Contains("image")
&& !response.ContentType.Contains("font")
&& request.Headers.AcceptEncoding.Any(x => x.Contains("gzip"))
&& !AlreadyGzipEncoded(response)
&& !ContentLengthIsTooSmall(response))
{
var contents = response.Contents;
response.Headers["Content-Encoding"] = "gzip";
response.Contents = responseStream => _writeGZipStream(contents, responseStream);
}
}
catch (Exception ex)
{
_logger.Error(ex, "Unable to gzip response");
throw;
}
}
private static void WriteGZipStreamMono(Action<Stream> innerContent, Stream targetStream)
{
using (var membuffer = new MemoryStream())
{
WriteGZipStream(innerContent, membuffer);
membuffer.Position = 0;
membuffer.CopyTo(targetStream);
}
}
private static void WriteGZipStream(Action<Stream> innerContent, Stream targetStream)
{
using (var gzip = new GZipStream(targetStream, CompressionMode.Compress, true))
using (var buffered = new BufferedStream(gzip, 8192))
{
innerContent.Invoke(buffered);
}
}
private static bool ContentLengthIsTooSmall(Response response)
{
var contentLength = response.Headers.TryGetValue("Content-Length", out var value) ? value : null;
if (contentLength != null && long.Parse(contentLength) < 1024)
{
return true;
}
return false;
}
private static bool AlreadyGzipEncoded(Response response)
{
var contentEncoding = response.Headers.TryGetValue("Content-Encoding", out var value) ? value : null;
if (contentEncoding == "gzip")
{
return true;
}
return false;
}
}
}

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

@ -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<NancyContext, Response>)Handle);
}
private Response Handle(NancyContext context)
{
if (_cacheableSpecification.IsCacheable(context) && context.Request.Headers.IfModifiedSince.HasValue)
{
var response = new Response { ContentType = MimeTypes.GetMimeType(context.Request.Path), StatusCode = HttpStatusCode.NotModified };
response.Headers.EnableCache();
return response;
}
return null;
}
}
}

@ -1,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<NancyContext>)Handle);
}
private void Handle(NancyContext context)
{
if (!context.Response.Headers.ContainsKey("X-ApplicationVersion"))
{
context.Response.Headers.Add("X-ApplicationVersion", BuildInfo.Version.ToString());
}
}
}
}

@ -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}";
}
}
}
}

@ -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<NancyContext, Response>)Handle);
}
}
private Response Handle(NancyContext context)
{
var basePath = context.Request.Url.BasePath;
if (basePath.IsNullOrWhiteSpace())
{
return new RedirectResponse($"{_urlBase}{context.Request.Path}{context.Request.Url.Query}");
}
if (_urlBase != basePath)
{
return new NotFoundResponse();
}
return null;
}
}
}

@ -1,61 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using Nancy;
using Nancy.Responses;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Serializer;
namespace 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<T>(this Stream body)
where T : class, new()
{
return FromJson<T>(body, typeof(T));
}
public static T FromJson<T>(this Stream body, Type type)
{
return (T)FromJson(body, type);
}
public static object FromJson(this Stream body, Type type)
{
body.Position = 0;
return STJson.Deserialize(body, type);
}
public static JsonResponse<TModel> AsResponse<TModel>(this TModel model, NancyContext context, HttpStatusCode statusCode = HttpStatusCode.OK)
{
var response = new JsonResponse<TModel>(model, NancySerializer, context.Environment) { StatusCode = statusCode };
response.Headers.DisableCache();
return response;
}
public static IDictionary<string, string> DisableCache(this IDictionary<string, string> headers)
{
headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0";
headers["Pragma"] = "no-cache";
headers["Expires"] = "0";
return headers;
}
public static IDictionary<string, string> EnableCache(this IDictionary<string, string> headers)
{
headers["Cache-Control"] = "max-age=31536000 , public";
headers["Expires"] = "Sat, 29 Jun 2020 00:00:00 GMT";
headers["Last-Modified"] = LastModified;
headers["Age"] = "193266";
return headers;
}
}
}

@ -1,107 +1,137 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using Nancy; using Microsoft.AspNetCore.Http;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Exceptions;
namespace Prowlarr.Http.Extensions namespace Prowlarr.Http.Extensions
{ {
public static class RequestExtensions 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); var parameterValue = request.Query[parameter];
}
public static bool IsSignalRRequest(this Request request)
{
return request.Path.StartsWith("/signalr/", StringComparison.InvariantCultureIgnoreCase);
}
public static bool IsLocalRequest(this Request request) if (parameterValue.Any())
{ {
return request.UserHostAddress.Equals("localhost") || return bool.Parse(parameterValue.ToString());
request.UserHostAddress.Equals("127.0.0.1") || }
request.UserHostAddress.Equals("::1");
}
public static bool IsLoginRequest(this Request request) return defaultValue;
{
return request.Path.Equals("/login", StringComparison.InvariantCultureIgnoreCase);
} }
public static bool IsContentRequest(this Request request) public static PagingResource<TResource> ReadPagingResourceFromRequest<TResource>(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) if (!int.TryParse(request.Query["Page"].ToString(), out var page))
{ {
return request.Path.StartsWith("/MediaCover/", StringComparison.InvariantCultureIgnoreCase) || page = 1;
request.Path.StartsWith("/Content/Images/", StringComparison.InvariantCultureIgnoreCase); }
}
public static bool GetBooleanQueryParameter(this Request request, string parameter, bool defaultValue = false) var pagingResource = new PagingResource<TResource>
{ {
var parameterValue = request.Query[parameter]; PageSize = pageSize,
Page = page,
Filters = new List<PagingResourceFilter>()
};
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) if (request.Query["FilterValue"].Any())
{ {
var parameterValue = request.Query[parameter]; 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<TResource> ApplyToPage<TResource, TModel>(this PagingSpec<TModel> pagingSpec, Func<PagingSpec<TModel>, PagingSpec<TModel>> function, Converter<TModel, TResource> mapper)
{ {
var parameterValue = request.Query[parameter]; pagingSpec = function(pagingSpec);
if (parameterValue.HasValue) return new PagingResource<TResource>
{ {
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"; return "Unknown";
} }
var remoteAddress = context.Request.UserHostAddress; var remoteIP = request.HttpContext.Connection.RemoteIpAddress;
IPAddress remoteIP; var remoteAddress = remoteIP.ToString();
// Only check if forwarded by a local network reverse proxy // 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()) if (realIPHeader.Any())
{ {
return realIPHeader.First().ToString(); return realIPHeader.First().ToString();
} }
var forwardedForHeader = context.Request.Headers["X-Forwarded-For"]; var forwardedForHeader = request.Headers["X-Forwarded-For"];
if (forwardedForHeader.Any()) if (forwardedForHeader.Any())
{ {
// Get the first address that was forwarded by a local IP to prevent remote clients faking another proxy // 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; return remoteAddress;
} }
public static string GetServerUrl(this Request request) public static string GetServerUrl(this HttpRequest request)
{ {
var scheme = request.Url.Scheme; var scheme = request.Scheme;
var port = request.Url.Port; var port = request.HttpContext.Connection.LocalPort;
// Check for protocol headers added by reverse proxys // Check for protocol headers added by reverse proxys
// X-Forwarded-Proto: A de facto standard for identifying the originating protocol of an HTTP request // 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(); 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(); 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) //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; 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");
} }
} }
} }

@ -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);
}
};
}
}
}

@ -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;
}
}
}

@ -1,7 +1,6 @@
using System.IO;
using System.Text; using System.Text;
using Nancy; using Microsoft.AspNetCore.Authorization;
using Nancy.Responses; using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common; using NzbDrone.Common;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Analytics; using NzbDrone.Core.Analytics;
@ -9,7 +8,9 @@ using NzbDrone.Core.Configuration;
namespace Prowlarr.Http.Frontend namespace Prowlarr.Http.Frontend
{ {
public class InitializeJsModule : NancyModule [Authorize(Policy = "UI")]
[ApiController]
public class InitializeJsController : Controller
{ {
private readonly IConfigFileProvider _configFileProvider; private readonly IConfigFileProvider _configFileProvider;
private readonly IAnalyticsService _analyticsService; private readonly IAnalyticsService _analyticsService;
@ -18,35 +19,21 @@ namespace Prowlarr.Http.Frontend
private static string _urlBase; private static string _urlBase;
private string _generatedContent; private string _generatedContent;
public InitializeJsModule(IConfigFileProvider configFileProvider, public InitializeJsController(IConfigFileProvider configFileProvider,
IAnalyticsService analyticsService) IAnalyticsService analyticsService)
{ {
_configFileProvider = configFileProvider; _configFileProvider = configFileProvider;
_analyticsService = analyticsService; _analyticsService = analyticsService;
_apiKey = configFileProvider.ApiKey; _apiKey = configFileProvider.ApiKey;
_urlBase = configFileProvider.UrlBase; _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(); // TODO: Move away from window.Prowlarr and prefetch the information returned here when starting the UI
return Content(GetContent(), "application/javascript");
var stream = new MemoryStream();
var writer = new StreamWriter(stream);
writer.Write(text);
writer.Flush();
stream.Position = 0;
return stream;
} }
private string GetContent() private string GetContent()

@ -1,7 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Nancy;
using NLog; using NLog;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
@ -40,13 +39,14 @@ namespace Prowlarr.Http.Frontend.Mappers
return stream; return stream;
} }
public override Response GetResponse(string resourceUrl) /*
public override IActionResult GetResponse(string resourceUrl)
{ {
var response = base.GetResponse(resourceUrl); var response = base.GetResponse(resourceUrl);
response.Headers["X-UA-Compatible"] = "IE=edge"; response.Headers["X-UA-Compatible"] = "IE=edge";
return response; return response;
} }*/
protected string GetHtmlText() protected string GetHtmlText()
{ {

@ -1,4 +1,4 @@
using Nancy; using Microsoft.AspNetCore.Mvc;
namespace Prowlarr.Http.Frontend.Mappers namespace Prowlarr.Http.Frontend.Mappers
{ {
@ -6,6 +6,6 @@ namespace Prowlarr.Http.Frontend.Mappers
{ {
string Map(string resourceUrl); string Map(string resourceUrl);
bool CanHandle(string resourceUrl); bool CanHandle(string resourceUrl);
Response GetResponse(string resourceUrl); IActionResult GetResponse(string resourceUrl);
} }
} }

@ -1,7 +1,7 @@
using System; using System;
using System.IO; using System.IO;
using Nancy; using Microsoft.AspNetCore.Mvc;
using Nancy.Responses; using Microsoft.AspNetCore.StaticFiles;
using NLog; using NLog;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
@ -13,14 +13,14 @@ namespace Prowlarr.Http.Frontend.Mappers
private readonly IDiskProvider _diskProvider; private readonly IDiskProvider _diskProvider;
private readonly Logger _logger; private readonly Logger _logger;
private readonly StringComparison _caseSensitive; private readonly StringComparison _caseSensitive;
private readonly IContentTypeProvider _mimeTypeProvider;
private static readonly NotFoundResponse NotFoundResponse = new NotFoundResponse();
protected StaticResourceMapperBase(IDiskProvider diskProvider, Logger logger) protected StaticResourceMapperBase(IDiskProvider diskProvider, Logger logger)
{ {
_diskProvider = diskProvider; _diskProvider = diskProvider;
_logger = logger; _logger = logger;
_mimeTypeProvider = new FileExtensionContentTypeProvider();
_caseSensitive = RuntimeInfo.IsProduction ? DiskProviderBase.PathStringComparison : StringComparison.OrdinalIgnoreCase; _caseSensitive = RuntimeInfo.IsProduction ? DiskProviderBase.PathStringComparison : StringComparison.OrdinalIgnoreCase;
} }
@ -28,19 +28,23 @@ namespace Prowlarr.Http.Frontend.Mappers
public abstract bool CanHandle(string resourceUrl); public abstract bool CanHandle(string resourceUrl);
public virtual Response GetResponse(string resourceUrl) public virtual IActionResult GetResponse(string resourceUrl)
{ {
var filePath = Map(resourceUrl); var filePath = Map(resourceUrl);
if (_diskProvider.FileExists(filePath, _caseSensitive)) if (_diskProvider.FileExists(filePath, _caseSensitive))
{ {
var response = new StreamResponse(() => GetContentStream(filePath), MimeTypes.GetMimeType(filePath)); if (!_mimeTypeProvider.TryGetContentType(filePath, out var contentType))
return new MaterialisingResponse(response); {
contentType = "application/octet-stream";
}
return new FileStreamResult(GetContentStream(filePath), contentType);
} }
_logger.Warn("File {0} not found", filePath); _logger.Warn("File {0} not found", filePath);
return NotFoundResponse; return null;
} }
protected virtual Stream GetContentStream(string filePath) protected virtual Stream GetContentStream(string filePath)

@ -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<IMapHttpRequestsToDisk> _requestMappers;
private readonly Logger _logger;
public StaticResourceController(IConfigFileProvider configFileProvider,
IAppFolderInfo appFolderInfo,
IEnumerable<IMapHttpRequestsToDisk> 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();
}
}
}

@ -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<IMapHttpRequestsToDisk> _requestMappers;
private readonly Logger _logger;
public StaticResourceModule(IEnumerable<IMapHttpRequestsToDisk> requestMappers, Logger logger)
{
_requestMappers = requestMappers;
_logger = logger;
Get("/{resource*}", x => Index());
Get("/", x => Index());
}
private Response Index()
{
var path = Request.Url.Path;
if (
string.IsNullOrWhiteSpace(path) ||
path.StartsWith("/api", StringComparison.CurrentCultureIgnoreCase) ||
path.StartsWith("/signalr", StringComparison.CurrentCultureIgnoreCase))
{
return new NotFoundResponse();
}
var mapper = _requestMappers.SingleOrDefault(m => m.CanHandle(path));
if (mapper != null)
{
return mapper.GetResponse(path);
}
_logger.Warn("Couldn't find handler for {0}", path);
return new NotFoundResponse();
}
}
}

@ -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);
}
}
}

@ -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;
}
}
}

@ -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);
}
}
}

@ -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"]}";
}
}
}
}

@ -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);
}
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save