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({
url: '/command',
method: 'POST',
data: JSON.stringify(payload)
data: JSON.stringify(payload),
dataType: 'json'
}).request;
return promise.then((data) => {

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

@ -2,6 +2,7 @@ using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace NzbDrone.Common.Serializer
{
@ -15,15 +16,19 @@ namespace NzbDrone.Common.Serializer
public static JsonSerializerOptions GetSerializerSettings()
{
var serializerSettings = new JsonSerializerOptions
{
AllowTrailingCommas = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
var settings = new JsonSerializerOptions();
ApplySerializerSettings(settings);
return settings;
}
public static void ApplySerializerSettings(JsonSerializerOptions serializerSettings)
{
serializerSettings.AllowTrailingCommas = true;
serializerSettings.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
serializerSettings.PropertyNameCaseInsensitive = true;
serializerSettings.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
serializerSettings.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
serializerSettings.WriteIndented = true;
serializerSettings.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, true));
serializerSettings.Converters.Add(new STJVersionConverter());
@ -31,8 +36,6 @@ namespace NzbDrone.Common.Serializer
serializerSettings.Converters.Add(new STJTimeSpanConverter());
serializerSettings.Converters.Add(new STJUtcConverter());
serializerSettings.Converters.Add(new DictionaryStringObjectConverter());
return serializerSettings;
}
public static T Deserialize<T>(string json)
@ -85,5 +88,15 @@ namespace NzbDrone.Common.Serializer
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 int id { get; set; }
public string t { get; set; }
public string q { get; set; }
public string cat { get; set; }

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

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

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

@ -1,4 +1,5 @@
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.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.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Internal;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NLog;
using NLog.Extensions.Logging;
using NzbDrone.Common.Composition;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Exceptions;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Configuration;
using Prowlarr.Host.AccessControl;
using Prowlarr.Host.Middleware;
using NzbDrone.Host;
using NzbDrone.Host.AccessControl;
using NzbDrone.SignalR;
using Prowlarr.Api.V1.System;
using Prowlarr.Http;
using Prowlarr.Http.Authentication;
using Prowlarr.Http.ErrorManagement;
using Prowlarr.Http.Frontend;
using Prowlarr.Http.Middleware;
using LogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace Prowlarr.Host
{
public class WebHostController : IHostController
{
private readonly IContainer _container;
private readonly IRuntimeInfo _runtimeInfo;
private readonly IConfigFileProvider _configFileProvider;
private readonly IFirewallAdapter _firewallAdapter;
private readonly IEnumerable<IAspNetCoreMiddleware> _middlewares;
private readonly ProwlarrErrorPipeline _errorHandler;
private readonly Logger _logger;
private IWebHost _host;
public WebHostController(IRuntimeInfo runtimeInfo,
public WebHostController(IContainer container,
IRuntimeInfo runtimeInfo,
IConfigFileProvider configFileProvider,
IFirewallAdapter firewallAdapter,
IEnumerable<IAspNetCoreMiddleware> middlewares,
ProwlarrErrorPipeline errorHandler,
Logger logger)
{
_container = container;
_runtimeInfo = runtimeInfo;
_configFileProvider = configFileProvider;
_firewallAdapter = firewallAdapter;
_middlewares = middlewares;
_errorHandler = errorHandler;
_logger = logger;
}
@ -105,24 +123,125 @@ namespace Prowlarr.Host
})
.ConfigureServices(services =>
{
// So that we can resolve containers with our TinyIoC services
services.AddSingleton(_container);
services.AddSingleton<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
.AddSignalR()
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions = STJson.GetSerializerSettings();
});
services.AddAuthorization(options =>
{
options.AddPolicy("UI", policy =>
{
policy.AuthenticationSchemes.Add(_configFileProvider.AuthenticationMethod.ToString());
policy.RequireAuthenticatedUser();
});
options.AddPolicy("SignalR", policy =>
{
policy.AuthenticationSchemes.Add("SignalR");
policy.RequireAuthenticatedUser();
});
// Require auth on everything except those marked [AllowAnonymous]
options.FallbackPolicy = new AuthorizationPolicyBuilder("API")
.RequireAuthenticatedUser()
.Build();
});
services.AddAppAuthentication(_configFileProvider);
})
.Configure(app =>
{
app.UseMiddleware<LoggingMiddleware>();
app.UsePathBase(new PathString(_configFileProvider.UrlBase));
app.UseExceptionHandler(new ExceptionHandlerOptions
{
AllowStatusCode404Response = true,
ExceptionHandler = _errorHandler.HandleException
});
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseResponseCompression();
app.Properties["host.AppName"] = BuildInfo.AppName;
app.UsePathBase(_configFileProvider.UrlBase);
foreach (var middleWare in _middlewares.OrderBy(c => c.Order))
app.UseMiddleware<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);
middleWare.Attach(app);
}
if (context.Request.Path.StartsWithSegments("/api/v1/command", StringComparison.CurrentCultureIgnoreCase))
{
context.Request.EnableBuffering();
}
return next();
});
app.UseWebSockets();
app.UseEndpoints(x =>
{
x.MapHub<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())
.Build();

@ -51,11 +51,7 @@ namespace NzbDrone.Integration.Test.Client
throw response.ErrorException;
}
// cache control header gets reordered on net core
((string)response.Headers.Single(c => c.Name == "Cache-Control").Value).Split(',').Select(x => x.Trim())
.Should().BeEquivalentTo("no-store, must-revalidate, no-cache, max-age=0".Split(',').Select(x => x.Trim()));
response.Headers.Single(c => c.Name == "Pragma").Value.Should().Be("no-cache");
response.Headers.Single(c => c.Name == "Expires").Value.Should().Be("0");
AssertDisableCache(response);
response.ErrorMessage.Should().BeNullOrWhiteSpace();
@ -71,6 +67,16 @@ namespace NzbDrone.Integration.Test.Client
return Json.Deserialize<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

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

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

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

@ -131,22 +131,6 @@ namespace NzbDrone.Integration.Test
}
}
protected void IgnoreOnMonoVersions(params string[] version_strings)
{
if (!PlatformInfo.IsMono)
{
return;
}
var current = PlatformInfo.GetVersion();
var versions = version_strings.Select(x => new Version(x)).ToList();
if (versions.Any(x => x.Major == current.Major && x.Minor == current.Minor))
{
throw new IgnoreException($"Ignored on mono {PlatformInfo.GetVersion()}");
}
}
public string GetTempDirectory(params string[] args)
{
var path = Path.Combine(TempDirectory, Path.Combine(args));

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

@ -1,12 +1,14 @@
using NzbDrone.Core.Applications;
using Prowlarr.Http;
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 ApplicationModule(ApplicationFactory applicationsFactory)
public ApplicationController(ApplicationFactory applicationsFactory)
: base(applicationsFactory, "applications", ResourceMapper)
{
}

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

@ -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 Prowlarr.Http;
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)
{
}

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

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

@ -3,12 +3,14 @@ using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
using Prowlarr.Http;
namespace Prowlarr.Api.V1.Config
{
public class MediaManagementConfigModule : ProwlarrConfigModule<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)
{
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 Prowlarr.Http;
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)
{
}

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

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

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

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

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

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

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

@ -1,21 +1,25 @@
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Instrumentation;
using Prowlarr.Http;
using Prowlarr.Http.Extensions;
namespace Prowlarr.Api.V1.Logs
{
public class LogModule : ProwlarrRestModule<LogResource>
[V1ApiController]
public class LogController : Controller
{
private readonly ILogService _logService;
public LogModule(ILogService logService)
public LogController(ILogService 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>();
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")
{

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

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

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

@ -1,12 +1,14 @@
using NzbDrone.Core.Notifications;
using Prowlarr.Http;
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 NotificationModule(NotificationFactory notificationFactory)
public NotificationController(NotificationFactory notificationFactory)
: base(notificationFactory, "notification", ResourceMapper)
{
}

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

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

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

@ -1,52 +1,60 @@
using System.IO;
using System.Threading.Tasks;
using Nancy.Routing;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Internal;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Lifecycle;
using Prowlarr.Http;
using Prowlarr.Http.Validation;
namespace Prowlarr.Api.V1.System
{
public class SystemModule : ProwlarrV1Module
[V1ApiController]
public class SystemController : Controller
{
private readonly IAppFolderInfo _appFolderInfo;
private readonly IRuntimeInfo _runtimeInfo;
private readonly IPlatformInfo _platformInfo;
private readonly IOsInfo _osInfo;
private readonly IRouteCacheProvider _routeCacheProvider;
private readonly IConfigFileProvider _configFileProvider;
private readonly IMainDatabase _database;
private readonly ILifecycleService _lifecycleService;
private readonly IDeploymentInfoProvider _deploymentInfoProvider;
private readonly EndpointDataSource _endpointData;
private readonly DfaGraphWriter _graphWriter;
private readonly DuplicateEndpointDetector _detector;
public SystemModule(IAppFolderInfo appFolderInfo,
IRuntimeInfo runtimeInfo,
IPlatformInfo platformInfo,
IOsInfo osInfo,
IRouteCacheProvider routeCacheProvider,
IConfigFileProvider configFileProvider,
IMainDatabase database,
ILifecycleService lifecycleService,
IDeploymentInfoProvider deploymentInfoProvider)
: base("system")
public SystemController(IAppFolderInfo appFolderInfo,
IRuntimeInfo runtimeInfo,
IPlatformInfo platformInfo,
IOsInfo osInfo,
IConfigFileProvider configFileProvider,
IMainDatabase database,
ILifecycleService lifecycleService,
IDeploymentInfoProvider deploymentInfoProvider,
EndpointDataSource endpoints,
DfaGraphWriter graphWriter,
DuplicateEndpointDetector detector)
{
_appFolderInfo = appFolderInfo;
_runtimeInfo = runtimeInfo;
_platformInfo = platformInfo;
_osInfo = osInfo;
_routeCacheProvider = routeCacheProvider;
_configFileProvider = configFileProvider;
_database = database;
_lifecycleService = lifecycleService;
_deploymentInfoProvider = deploymentInfoProvider;
Get("/status", x => GetStatus());
Get("/routes", x => GetRoutes());
Post("/shutdown", x => Shutdown());
Post("/restart", x => Restart());
_endpointData = endpoints;
_graphWriter = graphWriter;
_detector = detector;
}
private object GetStatus()
[HttpGet("status")]
public object GetStatus()
{
return new
{
@ -82,18 +90,32 @@ namespace Prowlarr.Api.V1.System
};
}
private object GetRoutes()
[HttpGet("routes")]
public IActionResult GetRoutes()
{
return _routeCacheProvider.GetCache().Values;
using (var sw = new StringWriter())
{
_graphWriter.Write(_endpointData, sw);
var graph = sw.ToString();
return Content(graph, "text/plain");
}
}
[HttpGet("routes/duplicate")]
public object DuplicateRoutes()
{
return _detector.GetDuplicateEndpoints(_endpointData);
}
private object Shutdown()
[HttpPost("shutdown")]
public object Shutdown()
{
Task.Factory.StartNew(() => _lifecycleService.Shutdown());
return new { ShuttingDown = true };
}
private object Restart()
[HttpPost("restart")]
public object Restart()
{
Task.Factory.StartNew(() => _lifecycleService.Restart());
return new { Restarting = true };

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

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

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

@ -1,22 +1,24 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Update;
using Prowlarr.Http;
namespace Prowlarr.Api.V1.Update
{
public class UpdateModule : ProwlarrRestModule<UpdateResource>
[V1ApiController]
public class UpdateController : Controller
{
private readonly IRecentUpdateProvider _recentUpdateProvider;
public UpdateModule(IRecentUpdateProvider recentUpdateProvider)
public UpdateController(IRecentUpdateProvider recentUpdateProvider)
{
_recentUpdateProvider = recentUpdateProvider;
GetResourceAll = GetRecentUpdates;
}
private List<UpdateResource> GetRecentUpdates()
[HttpGet]
public List<UpdateResource> GetRecentUpdates()
{
var resources = _recentUpdateProvider.GetRecentUpdatePackages()
.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 System.Linq;
using System.Security.Claims;
using System.Security.Principal;
using Nancy;
using Nancy.Authentication.Basic;
using Nancy.Authentication.Forms;
using Microsoft.AspNetCore.Http;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration;
using Prowlarr.Http.Extensions;
namespace Prowlarr.Http.Authentication
{
public interface IAuthenticationService : IUserValidator, IUserMapper
public interface IAuthenticationService
{
void SetContext(NancyContext context);
void LogUnauthorized(NancyContext context);
User Login(NancyContext context, string username, string password);
void Logout(NancyContext context);
bool IsAuthenticated(NancyContext context);
void LogUnauthorized(HttpRequest context);
User Login(HttpRequest request, string username, string password);
void Logout(HttpContext context);
}
public class AuthenticationService : IAuthenticationService
@ -32,9 +22,6 @@ namespace Prowlarr.Http.Authentication
private static string API_KEY;
private static AuthenticationType AUTH_METHOD;
[ThreadStatic]
private static NancyContext _context;
public AuthenticationService(IConfigFileProvider configFileProvider, IUserService userService)
{
_userService = userService;
@ -42,13 +29,7 @@ namespace Prowlarr.Http.Authentication
AUTH_METHOD = configFileProvider.AuthenticationMethod;
}
public void SetContext(NancyContext context)
{
// Validate and GetUserIdentifier don't have access to the NancyContext so get it from the pipeline earlier
_context = context;
}
public User Login(NancyContext context, string username, string password)
public User Login(HttpRequest request, string username, string password)
{
if (AUTH_METHOD == AuthenticationType.None)
{
@ -59,174 +40,50 @@ namespace Prowlarr.Http.Authentication
if (user != null)
{
LogSuccess(context, username);
LogSuccess(request, username);
return user;
}
LogFailure(context, username);
LogFailure(request, username);
return null;
}
public void Logout(NancyContext context)
public void Logout(HttpContext context)
{
if (AUTH_METHOD == AuthenticationType.None)
{
return;
}
if (context.CurrentUser != null)
{
LogLogout(context, context.CurrentUser.Identity.Name);
}
}
public ClaimsPrincipal Validate(string username, string password)
{
if (AUTH_METHOD == AuthenticationType.None)
{
return new ClaimsPrincipal(new GenericIdentity(AnonymousUser));
}
var user = _userService.FindUser(username, password);
if (user != null)
{
if (AUTH_METHOD != AuthenticationType.Basic)
{
// Don't log success for basic auth
LogSuccess(_context, username);
}
return new ClaimsPrincipal(new GenericIdentity(user.Username));
}
LogFailure(_context, username);
return null;
}
public ClaimsPrincipal GetUserFromIdentifier(Guid identifier, NancyContext context)
{
if (AUTH_METHOD == AuthenticationType.None)
{
return new ClaimsPrincipal(new GenericIdentity(AnonymousUser));
}
var user = _userService.FindUser(identifier);
if (user != null)
{
return new ClaimsPrincipal(new GenericIdentity(user.Username));
}
LogInvalidated(_context);
return null;
}
public bool IsAuthenticated(NancyContext context)
{
var apiKey = GetApiKey(context);
if (context.Request.IsApiRequest())
if (context.User != null)
{
return ValidApiKey(apiKey);
LogLogout(context.Request, context.User.Identity.Name);
}
if (AUTH_METHOD == AuthenticationType.None)
{
return true;
}
if (context.Request.IsFeedRequest())
{
if (ValidUser(context) || ValidApiKey(apiKey))
{
return true;
}
return false;
}
if (context.Request.IsLoginRequest())
{
return true;
}
if (context.Request.IsContentRequest())
{
return true;
}
if (ValidUser(context))
{
return true;
}
return false;
}
private bool ValidUser(NancyContext context)
{
if (context.CurrentUser != null)
{
return true;
}
return false;
}
private bool ValidApiKey(string apiKey)
{
if (API_KEY.Equals(apiKey))
{
return true;
}
return false;
}
private string GetApiKey(NancyContext context)
{
var apiKeyHeader = context.Request.Headers["X-Api-Key"].FirstOrDefault();
var apiKeyQueryString = context.Request.Query["ApiKey"];
if (!apiKeyHeader.IsNullOrWhiteSpace())
{
return apiKeyHeader;
}
if (apiKeyQueryString.HasValue)
{
return apiKeyQueryString.Value;
}
return context.Request.Headers.Authorization;
}
public void LogUnauthorized(NancyContext context)
public void LogUnauthorized(HttpRequest context)
{
_authLogger.Info("Auth-Unauthorized ip {0} url '{1}'", context.GetRemoteIP(), context.Request.Url.ToString());
_authLogger.Info("Auth-Unauthorized ip {0} url '{1}'", context.GetRemoteIP(), context.Path);
}
private void LogInvalidated(NancyContext context)
private void LogInvalidated(HttpRequest context)
{
_authLogger.Info("Auth-Invalidated ip {0}", context.GetRemoteIP());
}
private void LogFailure(NancyContext context, string username)
private void LogFailure(HttpRequest context, string username)
{
_authLogger.Warn("Auth-Failure ip {0} username '{1}'", context.GetRemoteIP(), username);
}
private void LogSuccess(NancyContext context, string username)
private void LogSuccess(HttpRequest context, string username)
{
_authLogger.Info("Auth-Success ip {0} username '{1}'", context.GetRemoteIP(), username);
}
private void LogLogout(NancyContext context, string username)
private void LogLogout(HttpRequest context, string username)
{
_authLogger.Info("Auth-Logout ip {0} username '{1}'", context.GetRemoteIP(), username);
}

@ -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 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
{
@ -17,5 +21,12 @@ namespace Prowlarr.Http.ErrorManagement
public ErrorModel()
{
}
public Task WriteToResponse(HttpResponse response, HttpStatusCode statusCode = HttpStatusCode.InternalServerError)
{
response.StatusCode = (int)statusCode;
response.ContentType = "application/json";
return STJson.SerializeAsync(this, response.Body);
}
}
}

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

@ -1,8 +1,5 @@
using System;
using Nancy;
using Nancy.Responses;
using Prowlarr.Http.ErrorManagement;
using Prowlarr.Http.Extensions;
using System.Net;
namespace Prowlarr.Http.Exceptions
{
@ -19,11 +16,6 @@ namespace Prowlarr.Http.Exceptions
Content = content;
}
public JsonResponse<ErrorModel> ToErrorResponse(NancyContext context)
{
return new ErrorModel(this).AsResponse(context, StatusCode);
}
private static string GetMessage(HttpStatusCode statusCode, object content)
{
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.Collections.Generic;
using System.Linq;
using System.Net;
using Nancy;
using Microsoft.AspNetCore.Http;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Exceptions;
namespace Prowlarr.Http.Extensions
{
public static class RequestExtensions
{
public static bool IsApiRequest(this Request request)
public static bool IsApiRequest(this HttpRequest request)
{
return request.Path.StartsWith("/api/", StringComparison.InvariantCultureIgnoreCase);
return request.Path.StartsWithSegments("/api", StringComparison.InvariantCultureIgnoreCase);
}
public static bool IsFeedRequest(this Request request)
public static bool GetBooleanQueryParameter(this HttpRequest request, string parameter, bool defaultValue = false)
{
return request.Path.StartsWith("/feed/", StringComparison.InvariantCultureIgnoreCase);
}
public static bool IsSignalRRequest(this Request request)
{
return request.Path.StartsWith("/signalr/", StringComparison.InvariantCultureIgnoreCase);
}
var parameterValue = request.Query[parameter];
public static bool IsLocalRequest(this Request request)
{
return request.UserHostAddress.Equals("localhost") ||
request.UserHostAddress.Equals("127.0.0.1") ||
request.UserHostAddress.Equals("::1");
}
if (parameterValue.Any())
{
return bool.Parse(parameterValue.ToString());
}
public static bool IsLoginRequest(this Request request)
{
return request.Path.Equals("/login", StringComparison.InvariantCultureIgnoreCase);
return defaultValue;
}
public static bool IsContentRequest(this Request request)
public static PagingResource<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)
{
return request.Path.StartsWith("/MediaCover/", StringComparison.InvariantCultureIgnoreCase) ||
request.Path.StartsWith("/Content/Images/", StringComparison.InvariantCultureIgnoreCase);
}
if (!int.TryParse(request.Query["Page"].ToString(), out var page))
{
page = 1;
}
public static bool GetBooleanQueryParameter(this Request request, string parameter, bool defaultValue = false)
{
var parameterValue = request.Query[parameter];
var pagingResource = new PagingResource<TResource>
{
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)
{
var parameterValue = request.Query[parameter];
if (request.Query["FilterValue"].Any())
{
filter.Value = request.Query["FilterValue"].ToString();
}
if (parameterValue.HasValue)
pagingResource.Filters.Add(filter);
}
// v3 uses filters in key=value format
foreach (var pair in request.Query)
{
return int.Parse(parameterValue.Value);
pagingResource.Filters.Add(new PagingResourceFilter
{
Key = pair.Key,
Value = pair.Value.ToString()
});
}
return defaultValue;
return pagingResource;
}
public static int? GetNullableIntegerQueryParameter(this Request request, string parameter, int? defaultValue = null)
public static PagingResource<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";
}
var remoteAddress = context.Request.UserHostAddress;
IPAddress remoteIP;
var remoteIP = request.HttpContext.Connection.RemoteIpAddress;
var remoteAddress = remoteIP.ToString();
// Only check if forwarded by a local network reverse proxy
if (IPAddress.TryParse(remoteAddress, out remoteIP) && remoteIP.IsLocalAddress())
if (remoteIP.IsLocalAddress())
{
var realIPHeader = context.Request.Headers["X-Real-IP"];
var realIPHeader = request.Headers["X-Real-IP"];
if (realIPHeader.Any())
{
return realIPHeader.First().ToString();
}
var forwardedForHeader = context.Request.Headers["X-Forwarded-For"];
var forwardedForHeader = request.Headers["X-Forwarded-For"];
if (forwardedForHeader.Any())
{
// Get the first address that was forwarded by a local IP to prevent remote clients faking another proxy
@ -125,16 +155,16 @@ namespace Prowlarr.Http.Extensions
return remoteAddress;
}
public static string GetServerUrl(this Request request)
public static string GetServerUrl(this HttpRequest request)
{
var scheme = request.Url.Scheme;
var port = request.Url.Port;
var scheme = request.Scheme;
var port = request.HttpContext.Connection.LocalPort;
// Check for protocol headers added by reverse proxys
// X-Forwarded-Proto: A de facto standard for identifying the originating protocol of an HTTP request
var xForwardedProto = request.Headers.Where(x => x.Key == "X-Forwarded-Proto").Select(x => x.Value).FirstOrDefault();
if (xForwardedProto != null)
if (xForwardedProto.Any())
{
scheme = xForwardedProto.First();
}
@ -146,12 +176,25 @@ namespace Prowlarr.Http.Extensions
}
//default to 443 if the Host header doesn't contain the port (needed for reverse proxy setups)
if (scheme == "https" && !request.Url.HostName.Contains(":"))
if (scheme == "https" && !request.Host.Port.HasValue)
{
port = 443;
}
return $"{scheme}://{request.Url.HostName}:{port}";
return $"{scheme}://{request.Host.Host}:{port}";
}
public static void DisableCache(this IHeaderDictionary headers)
{
headers["Cache-Control"] = "no-cache, no-store";
headers["Expires"] = "-1";
headers["Pragma"] = "no-cache";
}
public static void EnableCache(this IHeaderDictionary headers)
{
headers["Cache-Control"] = "max-age=31536000, public";
headers["Last-Modified"] = BuildInfo.BuildDateTime.ToString("r");
}
}
}

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

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

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

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

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