From 4c0fe62dda7ba87eec08d628f79e4fae8fdb1a0f Mon Sep 17 00:00:00 2001 From: Qstick Date: Sat, 13 Nov 2021 15:10:42 -0600 Subject: [PATCH] Use modern HttpClient Co-Authored-By: ta264 --- .editorconfig | 1 - .../Http/HttpClientFixture.cs | 233 ++++++++----- .../Extensions/StringExtensions.cs | 21 ++ .../Http/BasicNetworkCredential.cs | 12 + .../ICertificationValidationService.cs | 10 + .../Http/Dispatchers/ManagedHttpDispatcher.cs | 307 ++++++++++++------ src/NzbDrone.Common/Http/GZipWebClient.cs | 15 - src/NzbDrone.Common/Http/HttpClient.cs | 59 ++-- src/NzbDrone.Common/Http/HttpHeader.cs | 22 +- src/NzbDrone.Common/Http/HttpMethod.cs | 14 - src/NzbDrone.Common/Http/HttpRequest.cs | 10 +- .../Http/HttpRequestBuilder.cs | 20 +- .../Http/JsonRpcRequestBuilder.cs | 7 +- src/NzbDrone.Common/Http/NzbDroneWebClient.cs | 20 -- .../Http/Proxy/IHttpProxySettingsProvider.cs | 2 +- .../Http/XmlRpcRequestBuilder.cs | 103 ++++++ src/NzbDrone.Core.Test/Framework/CoreTest.cs | 6 +- .../BroadcastheNetFixture.cs | 7 +- .../IndexerTests/FanzubTests/FanzubFixture.cs | 5 +- .../FileListTests/FileListFixture.cs | 5 +- .../IndexerTests/HDBitsTests/HDBitsFixture.cs | 3 +- .../IPTorrentsTests/IPTorrentsFixture.cs | 3 +- .../NewznabTests/NewznabFixture.cs | 7 +- .../IndexerTests/NyaaTests/NyaaFixture.cs | 7 +- .../OmgwtfnzbsTests/OmgwtfnzbsFixture.cs | 5 +- .../IndexerTests/RarbgTests/RarbgFixture.cs | 9 +- .../IndexerTests/SeasonSearchFixture.cs | 7 +- .../TorrentleechTests/TorrentleechFixture.cs | 5 +- .../TorznabTests/TorznabFixture.cs | 12 +- .../Download/Clients/Aria2/Aria2.cs | 8 +- .../Download/Clients/Aria2/Aria2Containers.cs | 220 ++++++++----- .../Download/Clients/Aria2/Aria2Proxy.cs | 197 ++++------- .../Proxies/DiskStationProxyBase.cs | 13 +- .../Proxies/DownloadStationTaskProxyV1.cs | 5 +- .../Proxies/DownloadStationTaskProxyV2.cs | 5 +- .../Download/Clients/Flood/FloodProxy.cs | 11 +- .../Clients/Hadouken/HadoukenProxy.cs | 2 +- .../Download/Clients/Nzbget/NzbgetProxy.cs | 2 +- .../Clients/QBittorrent/QBittorrentProxyV1.cs | 2 +- .../Clients/QBittorrent/QBittorrentProxyV2.cs | 2 +- .../Clients/Transmission/TransmissionProxy.cs | 2 +- .../Download/Clients/rTorrent/RTorrent.cs | 6 + .../Clients/rTorrent/RTorrentFault.cs | 28 ++ .../Clients/rTorrent/RTorrentProxy.cs | 250 +++++--------- .../Clients/rTorrent/RTorrentTorrent.cs | 30 +- .../Clients/uTorrent/UTorrentProxy.cs | 2 +- .../Download/Extensions/XmlExtensions.cs | 55 ++++ .../Http/HttpProxySettingsProvider.cs | 6 +- .../FileList/FileListRequestGenerator.cs | 4 +- .../Indexers/HDBits/HDBitsRequestGenerator.cs | 5 +- .../Notifications/Discord/DiscordProxy.cs | 3 +- .../Notifications/Email/Email.cs | 8 +- .../Notifications/Join/JoinProxy.cs | 3 +- .../Notifications/Mailgun/MailgunProxy.cs | 4 +- .../Plex/PlexTv/PlexTvService.cs | 3 +- .../Plex/Server/PlexServerProxy.cs | 13 +- .../PushBullet/PushBulletProxy.cs | 7 +- .../Notifications/SendGrid/SendGridProxy.cs | 5 +- .../Notifications/Slack/SlackProxy.cs | 3 +- .../Notifications/Trakt/TraktProxy.cs | 7 +- .../Notifications/Twitter/TwitterProxy.cs | 110 +++++++ .../Notifications/Twitter/TwitterService.cs | 48 +-- .../Notifications/Webhook/WebhookMethod.cs | 6 +- .../Notifications/Webhook/WebhookProxy.cs | 12 +- .../Notifications/Xbmc/XbmcJsonApiProxy.cs | 2 +- .../X509CertificateValidationService.cs | 40 ++- src/NzbDrone.Core/Sonarr.Core.csproj | 1 - src/NzbDrone.Core/TinyTwitter.cs | 238 -------------- .../IndexHtmlFixture.cs | 26 +- 69 files changed, 1271 insertions(+), 1060 deletions(-) create mode 100644 src/NzbDrone.Common/Http/BasicNetworkCredential.cs create mode 100644 src/NzbDrone.Common/Http/Dispatchers/ICertificationValidationService.cs delete mode 100644 src/NzbDrone.Common/Http/GZipWebClient.cs delete mode 100644 src/NzbDrone.Common/Http/HttpMethod.cs delete mode 100644 src/NzbDrone.Common/Http/NzbDroneWebClient.cs create mode 100644 src/NzbDrone.Common/Http/XmlRpcRequestBuilder.cs create mode 100644 src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentFault.cs create mode 100644 src/NzbDrone.Core/Download/Extensions/XmlExtensions.cs create mode 100644 src/NzbDrone.Core/Notifications/Twitter/TwitterProxy.cs delete mode 100644 src/NzbDrone.Core/TinyTwitter.cs diff --git a/.editorconfig b/.editorconfig index b74add49a..8a3c548fc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -262,7 +262,6 @@ dotnet_diagnostic.CA5394.severity = suggestion dotnet_diagnostic.CA5397.severity = suggestion dotnet_diagnostic.SYSLIB0006.severity = none -dotnet_diagnostic.SYSLIB0014.severity = none [*.{js,html,js,hbs,less,css}] charset = utf-8 diff --git a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs index 2fa05cea4..dca6b58a4 100644 --- a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs +++ b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using System.Threading; using FluentAssertions; using Moq; @@ -15,12 +16,14 @@ using NzbDrone.Common.Http; using NzbDrone.Common.Http.Dispatchers; using NzbDrone.Common.Http.Proxy; using NzbDrone.Common.TPL; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Security; using NzbDrone.Test.Common; using NzbDrone.Test.Common.Categories; +using HttpClient = NzbDrone.Common.Http.HttpClient; namespace NzbDrone.Common.Test.Http { - [Ignore("httpbin is bugged")] [IntegrationTest] [TestFixture(typeof(ManagedHttpDispatcher))] public class HttpClientFixture : TestBase @@ -32,39 +35,38 @@ namespace NzbDrone.Common.Test.Http private string _httpBinHost; private string _httpBinHost2; + private System.Net.Http.HttpClient _httpClient = new (); + [OneTimeSetUp] public void FixtureSetUp() { - var candidates = new[] { "eu.httpbin.org", /*"httpbin.org",*/ "www.httpbin.org" }; + // Always use our server for main tests + var mainHost = "httpbin.servarr.com"; + + // Use mirrors for tests that use two hosts + var candidates = new[] { "httpbin1.servarr.com" }; // httpbin.org is broken right now, occassionally redirecting to https if it's unavailable. + _httpBinHost = mainHost; _httpBinHosts = candidates.Where(IsTestSiteAvailable).ToArray(); TestLogger.Info($"{candidates.Length} TestSites available."); - _httpBinSleep = _httpBinHosts.Count() < 2 ? 100 : 10; + _httpBinSleep = 10; } private bool IsTestSiteAvailable(string site) { try { - var req = WebRequest.Create($"http://{site}/get") as HttpWebRequest; - var res = req.GetResponse() as HttpWebResponse; + var res = _httpClient.GetAsync($"https://{site}/get").GetAwaiter().GetResult(); + if (res.StatusCode != HttpStatusCode.OK) { return false; } - try - { - req = WebRequest.Create($"http://{site}/status/429") as HttpWebRequest; - res = req.GetResponse() as HttpWebResponse; - } - catch (WebException ex) - { - res = ex.Response as HttpWebResponse; - } + res = _httpClient.GetAsync($"https://{site}/status/429").GetAwaiter().GetResult(); if (res == null || res.StatusCode != (HttpStatusCode)429) { @@ -91,10 +93,14 @@ namespace NzbDrone.Common.Test.Http Mocker.GetMock().Setup(c => c.Name).Returns("TestOS"); Mocker.GetMock().Setup(c => c.Version).Returns("9.0.0"); + Mocker.GetMock().SetupGet(x => x.CertificateValidation).Returns(CertificateValidationType.Enabled); + Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(new X509CertificateValidationService(Mocker.GetMock().Object, TestLogger)); + Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant>(new IHttpRequestInterceptor[0]); Mocker.SetConstant(Mocker.Resolve()); @@ -105,8 +111,7 @@ namespace NzbDrone.Common.Test.Http // .Returns(new HttpProxySettings(ProxyType.Socks5, "127.0.0.1", 5476, "", false)); // Roundrobin over the two servers, to reduce the chance of hitting the ratelimiter. - _httpBinHost = _httpBinHosts[_httpBinRandom++ % _httpBinHosts.Length]; - _httpBinHost2 = _httpBinHosts[_httpBinRandom % _httpBinHosts.Length]; + _httpBinHost2 = _httpBinHosts[_httpBinRandom++ % _httpBinHosts.Length]; } [TearDown] @@ -118,7 +123,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_execute_simple_get() { - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); var response = Subject.Execute(request); @@ -135,10 +140,32 @@ namespace NzbDrone.Common.Test.Http response.Content.Should().NotBeNullOrWhiteSpace(); } + [TestCase(CertificateValidationType.Enabled)] + [TestCase(CertificateValidationType.DisabledForLocalAddresses)] + public void bad_ssl_should_fail_when_remote_validation_enabled(CertificateValidationType validationType) + { + Mocker.GetMock().SetupGet(x => x.CertificateValidation).Returns(validationType); + var request = new HttpRequest($"https://expired.badssl.com"); + + Assert.Throws(() => Subject.Execute(request)); + ExceptionVerification.ExpectedErrors(2); + } + + [Test] + public void bad_ssl_should_pass_if_remote_validation_disabled() + { + Mocker.GetMock().SetupGet(x => x.CertificateValidation).Returns(CertificateValidationType.Disabled); + + var request = new HttpRequest($"https://expired.badssl.com"); + + Subject.Execute(request); + ExceptionVerification.ExpectedErrors(0); + } + [Test] public void should_execute_typed_get() { - var request = new HttpRequest($"http://{_httpBinHost}/get?test=1"); + var request = new HttpRequest($"https://{_httpBinHost}/get?test=1"); var response = Subject.Get(request); @@ -151,7 +178,7 @@ namespace NzbDrone.Common.Test.Http { var message = "{ my: 1 }"; - var request = new HttpRequest($"http://{_httpBinHost}/post"); + var request = new HttpRequest($"https://{_httpBinHost}/post"); request.SetContent(message); var response = Subject.Post(request); @@ -159,15 +186,42 @@ namespace NzbDrone.Common.Test.Http response.Resource.Data.Should().Be(message); } - [TestCase("gzip")] - public void should_execute_get_using_gzip(string compression) + [Test] + public void should_execute_post_with_content_type() { - var request = new HttpRequest($"http://{_httpBinHost}/{compression}"); + var message = "{ my: 1 }"; + var request = new HttpRequest($"https://{_httpBinHost}/post"); + request.SetContent(message); + request.Headers.ContentType = "application/json"; + + var response = Subject.Post(request); + + response.Resource.Data.Should().Be(message); + } + + [Test] + public void should_execute_get_using_gzip() + { + var request = new HttpRequest($"https://{_httpBinHost}/gzip"); var response = Subject.Get(request); - response.Resource.Headers["Accept-Encoding"].ToString().Should().Be(compression); - response.Headers.ContentLength.Should().BeLessOrEqualTo(response.Content.Length); + response.Resource.Headers["Accept-Encoding"].ToString().Should().Contain("gzip"); + + response.Resource.Gzipped.Should().BeTrue(); + response.Resource.Brotli.Should().BeFalse(); + } + + [Test] + public void should_execute_get_using_brotli() + { + var request = new HttpRequest($"https://{_httpBinHost}/brotli"); + var response = Subject.Get(request); + + response.Resource.Headers["Accept-Encoding"].ToString().Should().Contain("br"); + + response.Resource.Gzipped.Should().BeFalse(); + response.Resource.Brotli.Should().BeTrue(); } [TestCase(HttpStatusCode.Unauthorized)] @@ -178,7 +232,7 @@ namespace NzbDrone.Common.Test.Http [TestCase(HttpStatusCode.BadGateway)] public void should_throw_on_unsuccessful_status_codes(int statusCode) { - var request = new HttpRequest($"http://{_httpBinHost}/status/{statusCode}"); + var request = new HttpRequest($"https://{_httpBinHost}/status/{statusCode}"); var exception = Assert.Throws(() => Subject.Get(request)); @@ -190,10 +244,10 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_not_throw_on_suppressed_status_codes() { - var request = new HttpRequest($"http://{_httpBinHost}/status/{HttpStatusCode.NotFound}"); + var request = new HttpRequest($"https://{_httpBinHost}/status/{HttpStatusCode.NotFound}"); request.SuppressHttpErrorStatusCodes = new[] { HttpStatusCode.NotFound }; - var exception = Assert.Throws(() => Subject.Get(request)); + Assert.Throws(() => Subject.Get(request)); ExceptionVerification.IgnoreWarns(); } @@ -201,7 +255,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_log_unsuccessful_status_codes() { - var request = new HttpRequest($"http://{_httpBinHost}/status/{HttpStatusCode.NotFound}"); + var request = new HttpRequest($"https://{_httpBinHost}/status/{HttpStatusCode.NotFound}"); var exception = Assert.Throws(() => Subject.Get(request)); @@ -211,10 +265,10 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_not_log_unsuccessful_status_codes() { - var request = new HttpRequest($"http://{_httpBinHost}/status/{HttpStatusCode.NotFound}"); + var request = new HttpRequest($"https://{_httpBinHost}/status/{HttpStatusCode.NotFound}"); request.LogHttpError = false; - var exception = Assert.Throws(() => Subject.Get(request)); + Assert.Throws(() => Subject.Get(request)); ExceptionVerification.ExpectedWarns(0); } @@ -222,7 +276,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_not_follow_redirects_when_not_in_production() { - var request = new HttpRequest($"http://{_httpBinHost}/redirect/1"); + var request = new HttpRequest($"https://{_httpBinHost}/redirect/1"); Subject.Get(request); @@ -232,7 +286,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_follow_redirects() { - var request = new HttpRequest($"http://{_httpBinHost}/redirect/1"); + var request = new HttpRequest($"https://{_httpBinHost}/redirect/1"); request.AllowAutoRedirect = true; var response = Subject.Get(request); @@ -245,7 +299,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_not_follow_redirects() { - var request = new HttpRequest($"http://{_httpBinHost}/redirect/1"); + var request = new HttpRequest($"https://{_httpBinHost}/redirect/1"); request.AllowAutoRedirect = false; var response = Subject.Get(request); @@ -258,7 +312,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_follow_redirects_to_https() { - var request = new HttpRequestBuilder($"http://{_httpBinHost}/redirect-to") + var request = new HttpRequestBuilder($"https://{_httpBinHost}/redirect-to") .AddQueryParam("url", $"https://sonarr.tv/") .Build(); request.AllowAutoRedirect = true; @@ -274,7 +328,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_throw_on_too_many_redirects() { - var request = new HttpRequest($"http://{_httpBinHost}/redirect/6"); + var request = new HttpRequest($"https://{_httpBinHost}/redirect/6"); request.AllowAutoRedirect = true; Assert.Throws(() => Subject.Get(request)); @@ -285,7 +339,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_send_user_agent() { - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); var response = Subject.Get(request); @@ -299,7 +353,7 @@ namespace NzbDrone.Common.Test.Http [TestCase("Accept", "text/xml, text/rss+xml, application/rss+xml")] public void should_send_headers(string header, string value) { - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); request.Headers.Add(header, value); var response = Subject.Get(request); @@ -324,12 +378,30 @@ namespace NzbDrone.Common.Test.Http fileInfo.Length.Should().Be(307054); } + [Test] + public void should_download_file_with_redirect() + { + var file = GetTempFilePath(); + + var request = new HttpRequestBuilder($"https://{_httpBinHost}/redirect-to") + .AddQueryParam("url", $"https://sonarr.tv/img/slider/seriesdetails.png") + .Build(); + + Subject.DownloadFile(request.Url.FullUri, file); + + ExceptionVerification.ExpectedErrors(0); + + var fileInfo = new FileInfo(file); + fileInfo.Exists.Should().BeTrue(); + fileInfo.Length.Should().Be(307054); + } + [Test] public void should_not_download_file_with_error() { var file = GetTempFilePath(); - Assert.Throws(() => Subject.DownloadFile("http://download.sonarr.tv/wrongpath", file)); + Assert.Throws(() => Subject.DownloadFile("https://download.sonarr.tv/wrongpath", file)); File.Exists(file).Should().BeFalse(); File.Exists(file + ".part").Should().BeFalse(); @@ -344,7 +416,7 @@ namespace NzbDrone.Common.Test.Http using (var fileStream = new FileStream(file, FileMode.Create)) { - var request = new HttpRequest($"http://{_httpBinHost}/redirect/1"); + var request = new HttpRequest($"https://{_httpBinHost}/redirect/1"); request.AllowAutoRedirect = false; request.ResponseStream = fileStream; @@ -365,7 +437,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_send_cookie() { - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); request.Cookies["my"] = "cookie"; var response = Subject.Get(request); @@ -384,7 +456,7 @@ namespace NzbDrone.Common.Test.Http Assert.Inconclusive("Need both httpbin.org and eu.httpbin.org to run this test."); } - var oldRequest = new HttpRequest($"http://{_httpBinHost2}/get"); + var oldRequest = new HttpRequest($"https://{_httpBinHost2}/get"); oldRequest.Cookies["my"] = "cookie"; var oldClient = new HttpClient(new IHttpRequestInterceptor[0], Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.GetMock().Object, Mocker.Resolve()); @@ -401,7 +473,7 @@ namespace NzbDrone.Common.Test.Http { GivenOldCookie(); - var request = new HttpRequest($"http://{_httpBinHost2}/get"); + var request = new HttpRequest($"https://{_httpBinHost2}/get"); var response = Subject.Get(request); @@ -417,7 +489,7 @@ namespace NzbDrone.Common.Test.Http { GivenOldCookie(); - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); var response = Subject.Get(request); @@ -427,14 +499,14 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_not_store_request_cookie() { - var requestGet = new HttpRequest($"http://{_httpBinHost}/get"); + var requestGet = new HttpRequest($"https://{_httpBinHost}/get"); requestGet.Cookies.Add("my", "cookie"); requestGet.AllowAutoRedirect = false; requestGet.StoreRequestCookie = false; requestGet.StoreResponseCookie = false; var responseGet = Subject.Get(requestGet); - var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies"); requestCookies.AllowAutoRedirect = false; var responseCookies = Subject.Get(requestCookies); @@ -446,14 +518,14 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_store_request_cookie() { - var requestGet = new HttpRequest($"http://{_httpBinHost}/get"); + var requestGet = new HttpRequest($"https://{_httpBinHost}/get"); requestGet.Cookies.Add("my", "cookie"); requestGet.AllowAutoRedirect = false; requestGet.StoreRequestCookie.Should().BeTrue(); requestGet.StoreResponseCookie = false; var responseGet = Subject.Get(requestGet); - var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies"); requestCookies.AllowAutoRedirect = false; var responseCookies = Subject.Get(requestCookies); @@ -465,7 +537,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_delete_request_cookie() { - var requestDelete = new HttpRequest($"http://{_httpBinHost}/cookies/delete?my"); + var requestDelete = new HttpRequest($"https://{_httpBinHost}/cookies/delete?my"); requestDelete.Cookies.Add("my", "cookie"); requestDelete.AllowAutoRedirect = true; requestDelete.StoreRequestCookie = false; @@ -480,7 +552,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_clear_request_cookie() { - var requestSet = new HttpRequest($"http://{_httpBinHost}/cookies"); + var requestSet = new HttpRequest($"https://{_httpBinHost}/cookies"); requestSet.Cookies.Add("my", "cookie"); requestSet.AllowAutoRedirect = false; requestSet.StoreRequestCookie = true; @@ -488,7 +560,7 @@ namespace NzbDrone.Common.Test.Http var responseSet = Subject.Get(requestSet); - var requestClear = new HttpRequest($"http://{_httpBinHost}/cookies"); + var requestClear = new HttpRequest($"https://{_httpBinHost}/cookies"); requestClear.Cookies.Add("my", null); requestClear.AllowAutoRedirect = false; requestClear.StoreRequestCookie = true; @@ -502,14 +574,14 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_not_store_response_cookie() { - var requestSet = new HttpRequest($"http://{_httpBinHost}/cookies/set?my=cookie"); + var requestSet = new HttpRequest($"https://{_httpBinHost}/cookies/set?my=cookie"); requestSet.AllowAutoRedirect = false; requestSet.StoreRequestCookie = false; requestSet.StoreResponseCookie.Should().BeFalse(); var responseSet = Subject.Get(requestSet); - var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies"); var responseCookies = Subject.Get(requestCookies); @@ -521,14 +593,14 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_store_response_cookie() { - var requestSet = new HttpRequest($"http://{_httpBinHost}/cookies/set?my=cookie"); + var requestSet = new HttpRequest($"https://{_httpBinHost}/cookies/set?my=cookie"); requestSet.AllowAutoRedirect = false; requestSet.StoreRequestCookie = false; requestSet.StoreResponseCookie = true; var responseSet = Subject.Get(requestSet); - var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies"); var responseCookies = Subject.Get(requestCookies); @@ -540,7 +612,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_temp_store_response_cookie() { - var requestSet = new HttpRequest($"http://{_httpBinHost}/cookies/set?my=cookie"); + var requestSet = new HttpRequest($"https://{_httpBinHost}/cookies/set?my=cookie"); requestSet.AllowAutoRedirect = true; requestSet.StoreRequestCookie = false; requestSet.StoreResponseCookie.Should().BeFalse(); @@ -555,7 +627,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_overwrite_response_cookie() { - var requestSet = new HttpRequest($"http://{_httpBinHost}/cookies/set?my=cookie"); + var requestSet = new HttpRequest($"https://{_httpBinHost}/cookies/set?my=cookie"); requestSet.Cookies.Add("my", "oldcookie"); requestSet.AllowAutoRedirect = false; requestSet.StoreRequestCookie = false; @@ -563,7 +635,7 @@ namespace NzbDrone.Common.Test.Http var responseSet = Subject.Get(requestSet); - var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies"); var responseCookies = Subject.Get(requestCookies); @@ -575,7 +647,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_overwrite_temp_response_cookie() { - var requestSet = new HttpRequest($"http://{_httpBinHost}/cookies/set?my=cookie"); + var requestSet = new HttpRequest($"https://{_httpBinHost}/cookies/set?my=cookie"); requestSet.Cookies.Add("my", "oldcookie"); requestSet.AllowAutoRedirect = true; requestSet.StoreRequestCookie = true; @@ -585,7 +657,7 @@ namespace NzbDrone.Common.Test.Http responseSet.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); - var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies"); var responseCookies = Subject.Get(requestCookies); @@ -597,7 +669,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_not_delete_response_cookie() { - var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies"); requestCookies.Cookies.Add("my", "cookie"); requestCookies.AllowAutoRedirect = false; requestCookies.StoreRequestCookie = true; @@ -606,14 +678,14 @@ namespace NzbDrone.Common.Test.Http responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); - var requestDelete = new HttpRequest($"http://{_httpBinHost}/cookies/delete?my"); + var requestDelete = new HttpRequest($"https://{_httpBinHost}/cookies/delete?my"); requestDelete.AllowAutoRedirect = false; requestDelete.StoreRequestCookie = false; requestDelete.StoreResponseCookie = false; var responseDelete = Subject.Get(requestDelete); - requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies"); requestCookies.StoreRequestCookie = false; requestCookies.StoreResponseCookie = false; @@ -627,7 +699,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_delete_response_cookie() { - var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies"); requestCookies.Cookies.Add("my", "cookie"); requestCookies.AllowAutoRedirect = false; requestCookies.StoreRequestCookie = true; @@ -636,14 +708,14 @@ namespace NzbDrone.Common.Test.Http responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); - var requestDelete = new HttpRequest($"http://{_httpBinHost}/cookies/delete?my"); + var requestDelete = new HttpRequest($"https://{_httpBinHost}/cookies/delete?my"); requestDelete.AllowAutoRedirect = false; requestDelete.StoreRequestCookie = false; requestDelete.StoreResponseCookie = true; var responseDelete = Subject.Get(requestDelete); - requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies"); requestCookies.StoreRequestCookie = false; requestCookies.StoreResponseCookie = false; @@ -657,7 +729,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_delete_temp_response_cookie() { - var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies"); requestCookies.Cookies.Add("my", "cookie"); requestCookies.AllowAutoRedirect = false; requestCookies.StoreRequestCookie = true; @@ -666,7 +738,7 @@ namespace NzbDrone.Common.Test.Http responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); - var requestDelete = new HttpRequest($"http://{_httpBinHost}/cookies/delete?my"); + var requestDelete = new HttpRequest($"https://{_httpBinHost}/cookies/delete?my"); requestDelete.AllowAutoRedirect = true; requestDelete.StoreRequestCookie = false; requestDelete.StoreResponseCookie = false; @@ -674,7 +746,7 @@ namespace NzbDrone.Common.Test.Http responseDelete.Resource.Cookies.Should().BeEmpty(); - requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies"); requestCookies.StoreRequestCookie = false; requestCookies.StoreResponseCookie = false; @@ -686,7 +758,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_throw_on_http429_too_many_requests() { - var request = new HttpRequest($"http://{_httpBinHost}/status/429"); + var request = new HttpRequest($"https://{_httpBinHost}/status/429"); Assert.Throws(() => Subject.Get(request)); @@ -706,7 +778,7 @@ namespace NzbDrone.Common.Test.Http .Setup(v => v.PostResponse(It.IsAny())) .Returns(r => r); - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); Subject.Get(request); @@ -728,7 +800,7 @@ namespace NzbDrone.Common.Test.Http { // the date is bad in the below - should be 13-Jul-2026 string malformedCookie = @"__cfduid=d29e686a9d65800021c66faca0a29b4261436890790; expires=Mon, 13-Jul-26 16:19:50 GMT; path=/; HttpOnly"; - var requestSet = new HttpRequestBuilder($"http://{_httpBinHost}/response-headers") + var requestSet = new HttpRequestBuilder($"https://{_httpBinHost}/response-headers") .AddQueryParam("Set-Cookie", malformedCookie) .Build(); @@ -737,7 +809,7 @@ namespace NzbDrone.Common.Test.Http var responseSet = Subject.Get(requestSet); - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); var response = Subject.Get(request); @@ -761,7 +833,7 @@ namespace NzbDrone.Common.Test.Http { try { - string url = $"http://{_httpBinHost}/response-headers?Set-Cookie={Uri.EscapeDataString(malformedCookie)}"; + string url = $"https://{_httpBinHost}/response-headers?Set-Cookie={Uri.EscapeDataString(malformedCookie)}"; var requestSet = new HttpRequest(url); requestSet.AllowAutoRedirect = false; @@ -769,7 +841,7 @@ namespace NzbDrone.Common.Test.Http var responseSet = Subject.Get(requestSet); - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); var response = Subject.Get(request); @@ -781,6 +853,17 @@ namespace NzbDrone.Common.Test.Http { } } + + [Test] + public void should_correctly_use_basic_auth() + { + var request = new HttpRequest($"https://{_httpBinHost}/basic-auth/username/password"); + request.Credentials = new BasicNetworkCredential("username", "password"); + + var response = Subject.Execute(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } public class HttpBinResource @@ -790,6 +873,8 @@ namespace NzbDrone.Common.Test.Http public string Origin { get; set; } public string Url { get; set; } public string Data { get; set; } + public bool Gzipped { get; set; } + public bool Brotli { get; set; } } public class HttpCookieResource diff --git a/src/NzbDrone.Common/Extensions/StringExtensions.cs b/src/NzbDrone.Common/Extensions/StringExtensions.cs index fa9d4e7ba..6e6344517 100644 --- a/src/NzbDrone.Common/Extensions/StringExtensions.cs +++ b/src/NzbDrone.Common/Extensions/StringExtensions.cs @@ -194,5 +194,26 @@ namespace NzbDrone.Common.Extensions return value; } + + public static string EncodeRFC3986(this string value) + { + // From Twitterizer http://www.twitterizer.net/ + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + var encoded = Uri.EscapeDataString(value); + + return Regex + .Replace(encoded, "(%[0-9a-f][0-9a-f])", c => c.Value.ToUpper()) + .Replace("(", "%28") + .Replace(")", "%29") + .Replace("$", "%24") + .Replace("!", "%21") + .Replace("*", "%2A") + .Replace("'", "%27") + .Replace("%7E", "~"); + } } } diff --git a/src/NzbDrone.Common/Http/BasicNetworkCredential.cs b/src/NzbDrone.Common/Http/BasicNetworkCredential.cs new file mode 100644 index 000000000..26710f766 --- /dev/null +++ b/src/NzbDrone.Common/Http/BasicNetworkCredential.cs @@ -0,0 +1,12 @@ +using System.Net; + +namespace NzbDrone.Common.Http +{ + public class BasicNetworkCredential : NetworkCredential + { + public BasicNetworkCredential(string user, string pass) + : base(user, pass) + { + } + } +} diff --git a/src/NzbDrone.Common/Http/Dispatchers/ICertificationValidationService.cs b/src/NzbDrone.Common/Http/Dispatchers/ICertificationValidationService.cs new file mode 100644 index 000000000..54f3e2a8d --- /dev/null +++ b/src/NzbDrone.Common/Http/Dispatchers/ICertificationValidationService.cs @@ -0,0 +1,10 @@ +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +namespace NzbDrone.Common.Http.Dispatchers +{ + public interface ICertificateValidationService + { + bool ShouldByPassValidationError(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors); + } +} diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index 538e8b79e..877ed01c4 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs @@ -1,178 +1,234 @@ using System; using System.IO; -using System.IO.Compression; using System.Net; -using System.Reflection; +using System.Net.Http; +using System.Net.Security; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; using NLog; -using NLog.Fluent; -using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http.Proxy; -using NzbDrone.Common.Instrumentation.Extensions; namespace NzbDrone.Common.Http.Dispatchers { public class ManagedHttpDispatcher : IHttpDispatcher { + private const string NO_PROXY_KEY = "no-proxy"; + + private const int connection_establish_timeout = 2000; + private static bool useIPv6 = Socket.OSSupportsIPv6; + private static bool hasResolvedIPv6Availability; + private readonly IHttpProxySettingsProvider _proxySettingsProvider; private readonly ICreateManagedWebProxy _createManagedWebProxy; + private readonly ICertificateValidationService _certificateValidationService; private readonly IUserAgentBuilder _userAgentBuilder; - private readonly IPlatformInfo _platformInfo; + private readonly ICached _httpClientCache; private readonly Logger _logger; + private readonly ICached _credentialCache; - public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider, ICreateManagedWebProxy createManagedWebProxy, IUserAgentBuilder userAgentBuilder, IPlatformInfo platformInfo, Logger logger) + public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider, + ICreateManagedWebProxy createManagedWebProxy, + ICertificateValidationService certificateValidationService, + IUserAgentBuilder userAgentBuilder, + ICacheManager cacheManager, + Logger logger) { _proxySettingsProvider = proxySettingsProvider; _createManagedWebProxy = createManagedWebProxy; + _certificateValidationService = certificateValidationService; _userAgentBuilder = userAgentBuilder; - _platformInfo = platformInfo; _logger = logger; + + _httpClientCache = cacheManager.GetCache(typeof(ManagedHttpDispatcher)); + _credentialCache = cacheManager.GetCache(typeof(ManagedHttpDispatcher), "credentialcache"); } public HttpResponse GetResponse(HttpRequest request, CookieContainer cookies) { - var webRequest = (HttpWebRequest)WebRequest.Create((Uri)request.Url); + var requestMessage = new HttpRequestMessage(request.Method, (Uri)request.Url); + requestMessage.Headers.UserAgent.ParseAdd(_userAgentBuilder.GetUserAgent(request.UseSimplifiedUserAgent)); + requestMessage.Headers.ConnectionClose = !request.ConnectionKeepAlive; - // Deflate is not a standard and could break depending on implementation. - // we should just stick with the more compatible Gzip - //http://stackoverflow.com/questions/8490718/how-to-decompress-stream-deflated-with-java-util-zip-deflater-in-net - webRequest.AutomaticDecompression = DecompressionMethods.GZip; - - webRequest.Method = request.Method.ToString(); - webRequest.UserAgent = _userAgentBuilder.GetUserAgent(request.UseSimplifiedUserAgent); - webRequest.KeepAlive = request.ConnectionKeepAlive; - webRequest.AllowAutoRedirect = false; - webRequest.CookieContainer = cookies; - - if (request.RequestTimeout != TimeSpan.Zero) + var cookieHeader = cookies.GetCookieHeader((Uri)request.Url); + if (cookieHeader.IsNotNullOrWhiteSpace()) { - webRequest.Timeout = (int)Math.Ceiling(request.RequestTimeout.TotalMilliseconds); + requestMessage.Headers.Add("Cookie", cookieHeader); } - AddProxy(webRequest, request); + using var cts = new CancellationTokenSource(); + if (request.RequestTimeout != TimeSpan.Zero) + { + cts.CancelAfter(request.RequestTimeout); + } + else + { + // The default for System.Net.Http.HttpClient + cts.CancelAfter(TimeSpan.FromSeconds(100)); + } + + if (request.Credentials != null) + { + if (request.Credentials is BasicNetworkCredential bc) + { + // Manually set header to avoid initial challenge response + var authInfo = bc.UserName + ":" + bc.Password; + authInfo = Convert.ToBase64String(Encoding.GetEncoding("ISO-8859-1").GetBytes(authInfo)); + requestMessage.Headers.Add("Authorization", "Basic " + authInfo); + } + else if (request.Credentials is NetworkCredential nc) + { + var creds = GetCredentialCache(); + foreach (var authtype in new[] { "Basic", "Digest" }) + { + creds.Remove((Uri)request.Url, authtype); + creds.Add((Uri)request.Url, authtype, nc); + } + } + } + + if (request.ContentData != null) + { + requestMessage.Content = new ByteArrayContent(request.ContentData); + } if (request.Headers != null) { - AddRequestHeaders(webRequest, request.Headers); + AddRequestHeaders(requestMessage, request.Headers); } - HttpWebResponse httpWebResponse; + var httpClient = GetClient(request.Url); + + HttpResponseMessage responseMessage; try { - if (request.ContentData != null) - { - webRequest.ContentLength = request.ContentData.Length; - using (var writeStream = webRequest.GetRequestStream()) - { - writeStream.Write(request.ContentData, 0, request.ContentData.Length); - } - } - - httpWebResponse = (HttpWebResponse)webRequest.GetResponse(); + responseMessage = httpClient.Send(requestMessage, cts.Token); } - catch (WebException e) + catch (HttpRequestException e) { - httpWebResponse = (HttpWebResponse)e.Response; - - if (httpWebResponse == null) - { - // Workaround for mono not closing connections properly in certain situations. - AbortWebRequest(webRequest); - - // The default messages for WebException on mono are pretty horrible. - if (e.Status == WebExceptionStatus.NameResolutionFailure) - { - throw new WebException($"DNS Name Resolution Failure: '{webRequest.RequestUri.Host}'", e.Status); - } - else if (e.ToString().Contains("TLS Support not")) - { - throw new TlsFailureException(webRequest, e); - } - else if (e.ToString().Contains("The authentication or decryption has failed.")) - { - throw new TlsFailureException(webRequest, e); - } - else if (OsInfo.IsNotWindows) - { - throw new WebException($"{e.Message}: '{webRequest.RequestUri}'", e, e.Status, e.Response); - } - else - { - throw; - } - } + _logger.Error(e, "HttpClient error"); + throw; } byte[] data = null; - using (var responseStream = httpWebResponse.GetResponseStream()) + using (var responseStream = responseMessage.Content.ReadAsStream()) { if (responseStream != null && responseStream != Stream.Null) { try { - data = responseStream.ToBytes(); + if (request.ResponseStream != null && responseMessage.StatusCode == HttpStatusCode.OK) + { + // A target ResponseStream was specified, write to that instead. + // But only on the OK status code, since we don't want to write failures and redirects. + responseStream.CopyTo(request.ResponseStream); + } + else + { + data = responseStream.ToBytes(); + } } catch (Exception ex) { - throw new WebException("Failed to read complete http response", ex, WebExceptionStatus.ReceiveFailure, httpWebResponse); + throw new WebException("Failed to read complete http response", ex, WebExceptionStatus.ReceiveFailure, null); } } } - return new HttpResponse(request, new HttpHeader(httpWebResponse.Headers), data, httpWebResponse.StatusCode); + var headers = responseMessage.Headers.ToNameValueCollection(); + + headers.Add(responseMessage.Content.Headers.ToNameValueCollection()); + + return new HttpResponse(request, new HttpHeader(responseMessage.Headers), data, responseMessage.StatusCode); } - protected virtual void AddProxy(HttpWebRequest webRequest, HttpRequest request) + protected virtual System.Net.Http.HttpClient GetClient(HttpUri uri) { - var proxySettings = _proxySettingsProvider.GetProxySettings(request); + var proxySettings = _proxySettingsProvider.GetProxySettings(uri); + + var key = proxySettings?.Key ?? NO_PROXY_KEY; + + return _httpClientCache.Get(key, () => CreateHttpClient(proxySettings)); + } + + protected virtual System.Net.Http.HttpClient CreateHttpClient(HttpProxySettings proxySettings) + { + var handler = new SocketsHttpHandler() + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Brotli, + UseCookies = false, // sic - we don't want to use a shared cookie container + AllowAutoRedirect = false, + Credentials = GetCredentialCache(), + PreAuthenticate = true, + MaxConnectionsPerServer = 12, + ConnectCallback = onConnect, + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = _certificateValidationService.ShouldByPassValidationError + } + }; + if (proxySettings != null) { - webRequest.Proxy = _createManagedWebProxy.GetWebProxy(proxySettings); + handler.Proxy = _createManagedWebProxy.GetWebProxy(proxySettings); } + + var client = new System.Net.Http.HttpClient(handler) + { + Timeout = Timeout.InfiniteTimeSpan + }; + + return client; } - protected virtual void AddRequestHeaders(HttpWebRequest webRequest, HttpHeader headers) + protected virtual void AddRequestHeaders(HttpRequestMessage webRequest, HttpHeader headers) { foreach (var header in headers) { switch (header.Key) { case "Accept": - webRequest.Accept = header.Value; + webRequest.Headers.Accept.ParseAdd(header.Value); break; case "Connection": - webRequest.Connection = header.Value; + webRequest.Headers.Connection.Clear(); + webRequest.Headers.Connection.Add(header.Value); break; case "Content-Length": - webRequest.ContentLength = Convert.ToInt64(header.Value); + AddContentHeader(webRequest, "Content-Length", header.Value); break; case "Content-Type": - webRequest.ContentType = header.Value; + AddContentHeader(webRequest, "Content-Type", header.Value); break; case "Date": - webRequest.Date = HttpHeader.ParseDateTime(header.Value); + webRequest.Headers.Remove("Date"); + webRequest.Headers.Date = HttpHeader.ParseDateTime(header.Value); break; case "Expect": - webRequest.Expect = header.Value; + webRequest.Headers.Expect.ParseAdd(header.Value); break; case "Host": - webRequest.Host = header.Value; + webRequest.Headers.Host = header.Value; break; case "If-Modified-Since": - webRequest.IfModifiedSince = HttpHeader.ParseDateTime(header.Value); + webRequest.Headers.IfModifiedSince = HttpHeader.ParseDateTime(header.Value); break; case "Range": throw new NotImplementedException(); case "Referer": - webRequest.Referer = header.Value; + webRequest.Headers.Add("Referer", header.Value); break; case "Transfer-Encoding": - webRequest.TransferEncoding = header.Value; + webRequest.Headers.TransferEncoding.ParseAdd(header.Value); break; case "User-Agent": - throw new NotSupportedException("User-Agent other than Sonarr not allowed."); + webRequest.Headers.UserAgent.ParseAdd(header.Value); + break; case "Proxy-Connection": throw new NotImplementedException(); default: @@ -182,35 +238,82 @@ namespace NzbDrone.Common.Http.Dispatchers } } - // Workaround for mono not closing connections properly on timeouts - private void AbortWebRequest(HttpWebRequest webRequest) + private void AddContentHeader(HttpRequestMessage request, string header, string value) { - // First affected version was mono 5.16 - if (OsInfo.IsNotWindows && _platformInfo.Version >= new Version(5, 16)) + var headers = request.Content?.Headers; + if (headers == null) + { + return; + } + + headers.Remove(header); + headers.Add(header, value); + } + + private CredentialCache GetCredentialCache() + { + return _credentialCache.Get("credentialCache", () => new CredentialCache()); + } + + private static async ValueTask onConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken) + { + // Until .NET supports an implementation of Happy Eyeballs (https://tools.ietf.org/html/rfc8305#section-2), let's make IPv4 fallback work in a simple way. + // This issue is being tracked at https://github.com/dotnet/runtime/issues/26177 and expected to be fixed in .NET 6. + if (useIPv6) { try { - var currentOperationInfo = webRequest.GetType().GetField("currentOperation", BindingFlags.NonPublic | BindingFlags.Instance); - var currentOperation = currentOperationInfo.GetValue(webRequest); + var localToken = cancellationToken; - if (currentOperation != null) + if (!hasResolvedIPv6Availability) { - var responseStreamInfo = currentOperation.GetType().GetField("responseStream", BindingFlags.NonPublic | BindingFlags.Instance); - var responseStream = responseStreamInfo.GetValue(currentOperation) as Stream; + // to make things move fast, use a very low timeout for the initial ipv6 attempt. + var quickFailCts = new CancellationTokenSource(connection_establish_timeout); + var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, quickFailCts.Token); - // Note that responseStream will likely be null once mono fixes it. - responseStream?.Dispose(); + localToken = linkedTokenSource.Token; } + + return await attemptConnection(AddressFamily.InterNetworkV6, context, localToken); } - catch (Exception ex) + catch { - // This can fail randomly on future mono versions that have been changed/fixed. Log to sentry and ignore. - _logger.Trace() - .Exception(ex) - .Message("Unable to dispose responseStream on mono {0}", _platformInfo.Version) - .WriteSentryWarn("MonoCloseWaitPatchFailed", ex.Message) - .Write(); + // very naively fallback to ipv4 permanently for this execution based on the response of the first connection attempt. + // note that this may cause users to eventually get switched to ipv4 (on a random failure when they are switching networks, for instance) + // but in the interest of keeping this implementation simple, this is acceptable. + useIPv6 = false; } + finally + { + hasResolvedIPv6Availability = true; + } + } + + // fallback to IPv4. + return await attemptConnection(AddressFamily.InterNetwork, context, cancellationToken); + } + + private static async ValueTask attemptConnection(AddressFamily addressFamily, SocketsHttpConnectionContext context, CancellationToken cancellationToken) + { + // The following socket constructor will create a dual-mode socket on systems where IPV6 is available. + var socket = new Socket(addressFamily, SocketType.Stream, ProtocolType.Tcp) + { + // Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios. + NoDelay = true + }; + + try + { + await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false); + + // The stream should take the ownership of the underlying socket, + // closing it when it's disposed. + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; } } } diff --git a/src/NzbDrone.Common/Http/GZipWebClient.cs b/src/NzbDrone.Common/Http/GZipWebClient.cs deleted file mode 100644 index 191bfb10b..000000000 --- a/src/NzbDrone.Common/Http/GZipWebClient.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Net; - -namespace NzbDrone.Common.Http -{ - public class GZipWebClient : WebClient - { - protected override WebRequest GetWebRequest(Uri address) - { - var request = (HttpWebRequest)base.GetWebRequest(address); - request.AutomaticDecompression = DecompressionMethods.GZip; - return request; - } - } -} diff --git a/src/NzbDrone.Common/Http/HttpClient.cs b/src/NzbDrone.Common/Http/HttpClient.cs index 775c12fb6..ddb81ecdc 100644 --- a/src/NzbDrone.Common/Http/HttpClient.cs +++ b/src/NzbDrone.Common/Http/HttpClient.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.EnvironmentInfo; @@ -123,8 +124,6 @@ namespace NzbDrone.Common.Http var stopWatch = Stopwatch.StartNew(); - PrepareRequestCookies(request, cookieContainer); - var response = _httpDispatcher.GetResponse(request, cookieContainer); HandleResponseCookies(response, cookieContainer); @@ -191,45 +190,44 @@ namespace NzbDrone.Common.Http } } - private void PrepareRequestCookies(HttpRequest request, CookieContainer cookieContainer) + private void HandleResponseCookies(HttpResponse response, CookieContainer container) { - // Don't collect persistnet cookies for intermediate/redirected urls. - /*lock (_cookieContainerCache) + foreach (Cookie cookie in container.GetAllCookies()) { - var presistentContainer = _cookieContainerCache.Get("container", () => new CookieContainer()); - var persistentCookies = presistentContainer.GetCookies((Uri)request.Url); - var existingCookies = cookieContainer.GetCookies((Uri)request.Url); + cookie.Expired = true; + } - cookieContainer.Add(persistentCookies); - cookieContainer.Add(existingCookies); - }*/ - } - - private void HandleResponseCookies(HttpResponse response, CookieContainer cookieContainer) - { var cookieHeaders = response.GetCookieHeaders(); + if (cookieHeaders.Empty()) { return; } + AddCookiesToContainer(response.Request.Url, cookieHeaders, container); + if (response.Request.StoreResponseCookie) { lock (_cookieContainerCache) { var persistentCookieContainer = _cookieContainerCache.Get("container", () => new CookieContainer()); - foreach (var cookieHeader in cookieHeaders) - { - try - { - persistentCookieContainer.SetCookies((Uri)response.Request.Url, cookieHeader); - } - catch (Exception ex) - { - _logger.Debug(ex, "Invalid cookie in {0}", response.Request.Url); - } - } + AddCookiesToContainer(response.Request.Url, cookieHeaders, persistentCookieContainer); + } + } + } + + private void AddCookiesToContainer(HttpUri url, string[] cookieHeaders, CookieContainer container) + { + foreach (var cookieHeader in cookieHeaders) + { + try + { + container.SetCookies((Uri)url, cookieHeader); + } + catch (Exception ex) + { + _logger.Debug(ex, "Invalid cookie in {0}", url); } } } @@ -252,6 +250,7 @@ namespace NzbDrone.Common.Http using (var fileStream = new FileStream(fileNamePart, FileMode.Create, FileAccess.ReadWrite)) { var request = new HttpRequest(url); + request.AllowAutoRedirect = true; request.ResponseStream = fileStream; var response = Get(request); @@ -281,7 +280,7 @@ namespace NzbDrone.Common.Http public HttpResponse Get(HttpRequest request) { - request.Method = HttpMethod.GET; + request.Method = HttpMethod.Get; return Execute(request); } @@ -295,13 +294,13 @@ namespace NzbDrone.Common.Http public HttpResponse Head(HttpRequest request) { - request.Method = HttpMethod.HEAD; + request.Method = HttpMethod.Head; return Execute(request); } public HttpResponse Post(HttpRequest request) { - request.Method = HttpMethod.POST; + request.Method = HttpMethod.Post; return Execute(request); } diff --git a/src/NzbDrone.Common/Http/HttpHeader.cs b/src/NzbDrone.Common/Http/HttpHeader.cs index 2794f6dc4..42c13fd44 100644 --- a/src/NzbDrone.Common/Http/HttpHeader.cs +++ b/src/NzbDrone.Common/Http/HttpHeader.cs @@ -1,14 +1,29 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.Globalization; using System.Linq; +using System.Net.Http.Headers; using System.Text; using NzbDrone.Common.Extensions; namespace NzbDrone.Common.Http { + public static class WebHeaderCollectionExtensions + { + public static NameValueCollection ToNameValueCollection(this HttpHeaders headers) + { + var result = new NameValueCollection(); + foreach (var header in headers) + { + result.Add(header.Key, header.Value.ConcatToString(";")); + } + + return result; + } + } + public class HttpHeader : NameValueCollection, IEnumerable>, IEnumerable { public HttpHeader(NameValueCollection headers) @@ -16,6 +31,11 @@ namespace NzbDrone.Common.Http { } + public HttpHeader(HttpHeaders headers) + : base(headers.ToNameValueCollection()) + { + } + public HttpHeader() { } diff --git a/src/NzbDrone.Common/Http/HttpMethod.cs b/src/NzbDrone.Common/Http/HttpMethod.cs deleted file mode 100644 index 8839c0c52..000000000 --- a/src/NzbDrone.Common/Http/HttpMethod.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace NzbDrone.Common.Http -{ - public enum HttpMethod - { - GET, - POST, - PUT, - DELETE, - HEAD, - OPTIONS, - PATCH, - MERGE - } -} diff --git a/src/NzbDrone.Common/Http/HttpRequest.cs b/src/NzbDrone.Common/Http/HttpRequest.cs index 4b9e80f67..c9ef99f62 100644 --- a/src/NzbDrone.Common/Http/HttpRequest.cs +++ b/src/NzbDrone.Common/Http/HttpRequest.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Net; +using System.Net.Http; using System.Text; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; @@ -12,6 +13,7 @@ namespace NzbDrone.Common.Http { public HttpRequest(string url, HttpAccept httpAccept = null) { + Method = HttpMethod.Get; Url = new HttpUri(url); Headers = new HttpHeader(); AllowAutoRedirect = true; @@ -35,6 +37,7 @@ namespace NzbDrone.Common.Http public HttpHeader Headers { get; set; } public byte[] ContentData { get; set; } public string ContentSummary { get; set; } + public ICredentials Credentials { get; set; } public bool SuppressHttpError { get; set; } public IEnumerable SuppressHttpErrorStatusCodes { get; set; } public bool UseSimplifiedUserAgent { get; set; } @@ -85,12 +88,5 @@ namespace NzbDrone.Common.Http var encoding = HttpHeader.GetEncodingFromContentType(Headers.ContentType); ContentData = encoding.GetBytes(data); } - - public void AddBasicAuthentication(string username, string password) - { - var authInfo = Convert.ToBase64String(Encoding.GetEncoding("ISO-8859-1").GetBytes($"{username}:{password}")); - - Headers.Set("Authorization", "Basic " + authInfo); - } } } diff --git a/src/NzbDrone.Common/Http/HttpRequestBuilder.cs b/src/NzbDrone.Common/Http/HttpRequestBuilder.cs index fe4b2a4ca..657835fb4 100644 --- a/src/NzbDrone.Common/Http/HttpRequestBuilder.cs +++ b/src/NzbDrone.Common/Http/HttpRequestBuilder.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using System.Text; using NzbDrone.Common.Extensions; @@ -25,17 +26,16 @@ namespace NzbDrone.Common.Http public bool ConnectionKeepAlive { get; set; } public TimeSpan RateLimit { get; set; } public bool LogResponseContent { get; set; } - public NetworkCredential NetworkCredential { get; set; } + public ICredentials NetworkCredential { get; set; } public Dictionary Cookies { get; private set; } public List FormData { get; private set; } - public Action PostProcess { get; set; } public HttpRequestBuilder(string baseUrl) { BaseUrl = new HttpUri(baseUrl); ResourceUrl = string.Empty; - Method = HttpMethod.GET; + Method = HttpMethod.Get; QueryParams = new List>(); SuffixQueryParams = new List>(); Segments = new Dictionary(); @@ -108,13 +108,7 @@ namespace NzbDrone.Common.Http request.ConnectionKeepAlive = ConnectionKeepAlive; request.RateLimit = RateLimit; request.LogResponseContent = LogResponseContent; - - if (NetworkCredential != null) - { - var authInfo = NetworkCredential.UserName + ":" + NetworkCredential.Password; - authInfo = Convert.ToBase64String(Encoding.GetEncoding("ISO-8859-1").GetBytes(authInfo)); - request.Headers.Set("Authorization", "Basic " + authInfo); - } + request.Credentials = NetworkCredential; foreach (var header in Headers) { @@ -272,7 +266,7 @@ namespace NzbDrone.Common.Http public virtual HttpRequestBuilder Post() { - Method = HttpMethod.POST; + Method = HttpMethod.Post; return this; } @@ -363,7 +357,7 @@ namespace NzbDrone.Common.Http public virtual HttpRequestBuilder AddFormParameter(string key, object value) { - if (Method != HttpMethod.POST) + if (Method != HttpMethod.Post) { throw new NotSupportedException("HttpRequest Method must be POST to add FormParameter."); } @@ -379,7 +373,7 @@ namespace NzbDrone.Common.Http public virtual HttpRequestBuilder AddFormUpload(string name, string fileName, byte[] data, string contentType = "application/octet-stream") { - if (Method != HttpMethod.POST) + if (Method != HttpMethod.Post) { throw new NotSupportedException("HttpRequest Method must be POST to add FormUpload."); } diff --git a/src/NzbDrone.Common/Http/JsonRpcRequestBuilder.cs b/src/NzbDrone.Common/Http/JsonRpcRequestBuilder.cs index ae987a23d..06b113e54 100644 --- a/src/NzbDrone.Common/Http/JsonRpcRequestBuilder.cs +++ b/src/NzbDrone.Common/Http/JsonRpcRequestBuilder.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; using Newtonsoft.Json; using NzbDrone.Common.Serializer; @@ -17,14 +18,14 @@ namespace NzbDrone.Common.Http public JsonRpcRequestBuilder(string baseUrl) : base(baseUrl) { - Method = HttpMethod.POST; + Method = HttpMethod.Post; JsonParameters = new List(); } public JsonRpcRequestBuilder(string baseUrl, string method, IEnumerable parameters) : base(baseUrl) { - Method = HttpMethod.POST; + Method = HttpMethod.Post; JsonMethod = method; JsonParameters = parameters.ToList(); } diff --git a/src/NzbDrone.Common/Http/NzbDroneWebClient.cs b/src/NzbDrone.Common/Http/NzbDroneWebClient.cs deleted file mode 100644 index ccd369bb7..000000000 --- a/src/NzbDrone.Common/Http/NzbDroneWebClient.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Net; - -namespace NzbDrone.Common.Http -{ - public class NzbDroneWebClient : WebClient - { - protected override WebRequest GetWebRequest(Uri address) - { - var request = base.GetWebRequest(address); - if (request is HttpWebRequest) - { - ((HttpWebRequest)request).KeepAlive = false; - ((HttpWebRequest)request).ServicePoint.Expect100Continue = false; - } - - return request; - } - } -} diff --git a/src/NzbDrone.Common/Http/Proxy/IHttpProxySettingsProvider.cs b/src/NzbDrone.Common/Http/Proxy/IHttpProxySettingsProvider.cs index bb5909395..ed223fe63 100644 --- a/src/NzbDrone.Common/Http/Proxy/IHttpProxySettingsProvider.cs +++ b/src/NzbDrone.Common/Http/Proxy/IHttpProxySettingsProvider.cs @@ -2,7 +2,7 @@ namespace NzbDrone.Common.Http.Proxy { public interface IHttpProxySettingsProvider { - HttpProxySettings GetProxySettings(HttpRequest request); + HttpProxySettings GetProxySettings(HttpUri uri); HttpProxySettings GetProxySettings(); } } diff --git a/src/NzbDrone.Common/Http/XmlRpcRequestBuilder.cs b/src/NzbDrone.Common/Http/XmlRpcRequestBuilder.cs new file mode 100644 index 000000000..e03161702 --- /dev/null +++ b/src/NzbDrone.Common/Http/XmlRpcRequestBuilder.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Xml.Linq; +using NLog; +using NzbDrone.Common.Instrumentation; + +namespace NzbDrone.Common.Http +{ + public class XmlRpcRequestBuilder : HttpRequestBuilder + { + public static string XmlRpcContentType = "text/xml"; + + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(XmlRpcRequestBuilder)); + + public string XmlMethod { get; private set; } + public List XmlParameters { get; private set; } + + public XmlRpcRequestBuilder(string baseUrl) + : base(baseUrl) + { + Method = HttpMethod.Post; + XmlParameters = new List(); + } + + public XmlRpcRequestBuilder(bool useHttps, string host, int port, string urlBase = null) + : this(BuildBaseUrl(useHttps, host, port, urlBase)) + { + } + + public override HttpRequestBuilder Clone() + { + var clone = base.Clone() as XmlRpcRequestBuilder; + clone.XmlParameters = new List(XmlParameters); + return clone; + } + + public XmlRpcRequestBuilder Call(string method, params object[] parameters) + { + var clone = Clone() as XmlRpcRequestBuilder; + clone.XmlMethod = method; + clone.XmlParameters = parameters.ToList(); + return clone; + } + + protected override void Apply(HttpRequest request) + { + base.Apply(request); + + request.Headers.ContentType = XmlRpcContentType; + + var methodCallElements = new List { new XElement("methodName", XmlMethod) }; + + if (XmlParameters.Any()) + { + var argElements = XmlParameters.Select(x => new XElement("param", ConvertParameter(x))).ToList(); + var paramsElement = new XElement("params", argElements); + methodCallElements.Add(paramsElement); + } + + var message = new XDocument( + new XDeclaration("1.0", "utf-8", "yes"), + new XElement("methodCall", methodCallElements)); + + var body = message.ToString(); + + Logger.Debug($"Executing remote method: {XmlMethod}"); + + Logger.Trace($"methodCall {XmlMethod} body:\n{body}"); + + request.SetContent(body); + } + + private static XElement ConvertParameter(object value) + { + XElement data; + + if (value is string s) + { + data = new XElement("string", s); + } + else if (value is List l) + { + data = new XElement("array", new XElement("data", l.Select(x => new XElement("value", new XElement("string", x))))); + } + else if (value is int i) + { + data = new XElement("int", i); + } + else if (value is byte[] bytes) + { + data = new XElement("base64", Convert.ToBase64String(bytes)); + } + else + { + throw new InvalidOperationException($"Unhandled argument type {value.GetType().Name}"); + } + + return new XElement("value", data); + } + } +} diff --git a/src/NzbDrone.Core.Test/Framework/CoreTest.cs b/src/NzbDrone.Core.Test/Framework/CoreTest.cs index d97c666ff..5481e1f4e 100644 --- a/src/NzbDrone.Core.Test/Framework/CoreTest.cs +++ b/src/NzbDrone.Core.Test/Framework/CoreTest.cs @@ -1,4 +1,4 @@ -using System; +using System; using NUnit.Framework; using NzbDrone.Common.Cache; using NzbDrone.Common.Cloud; @@ -9,6 +9,7 @@ using NzbDrone.Common.Http.Proxy; using NzbDrone.Common.TPL; using NzbDrone.Core.Configuration; using NzbDrone.Core.Http; +using NzbDrone.Core.Security; using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.Framework @@ -23,7 +24,8 @@ namespace NzbDrone.Core.Test.Framework Mocker.SetConstant(new HttpProxySettingsProvider(Mocker.Resolve())); Mocker.SetConstant(new ManagedWebProxyFactory(Mocker.Resolve())); - Mocker.SetConstant(new ManagedHttpDispatcher(Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); + Mocker.SetConstant(new X509CertificateValidationService(Mocker.Resolve(), TestLogger)); + Mocker.SetConstant(new ManagedHttpDispatcher(Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); Mocker.SetConstant(new HttpClient(new IHttpRequestInterceptor[0], Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); Mocker.SetConstant(new SonarrCloudRequestBuilder()); } diff --git a/src/NzbDrone.Core.Test/IndexerTests/BroadcastheNetTests/BroadcastheNetFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/BroadcastheNetTests/BroadcastheNetFixture.cs index acfd6ceae..40e9cedd5 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/BroadcastheNetTests/BroadcastheNetFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/BroadcastheNetTests/BroadcastheNetFixture.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Linq; +using System.Net.Http; using FluentAssertions; using Moq; using NUnit.Framework; @@ -31,7 +32,7 @@ namespace NzbDrone.Core.Test.IndexerTests.BroadcastheNetTests var recentFeed = ReadAllText(@"Files/Indexers/BroadcastheNet/RecentFeed.json"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.POST))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Post))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); @@ -144,7 +145,7 @@ namespace NzbDrone.Core.Test.IndexerTests.BroadcastheNetTests recentFeed = recentFeed.Replace("http:", "https:"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.POST))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Post))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); diff --git a/src/NzbDrone.Core.Test/IndexerTests/FanzubTests/FanzubFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/FanzubTests/FanzubFixture.cs index e807192ec..da80f870c 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/FanzubTests/FanzubFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/FanzubTests/FanzubFixture.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Linq; +using System.Net.Http; using FluentAssertions; using Moq; using NUnit.Framework; @@ -29,7 +30,7 @@ namespace NzbDrone.Core.Test.IndexerTests.FanzubTests var recentFeed = ReadAllText(@"Files/Indexers/Fanzub/fanzub.xml"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); diff --git a/src/NzbDrone.Core.Test/IndexerTests/FileListTests/FileListFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/FileListTests/FileListFixture.cs index cac1c00ad..732825188 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/FileListTests/FileListFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/FileListTests/FileListFixture.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Linq; +using System.Net.Http; using FluentAssertions; using Moq; using NUnit.Framework; @@ -30,7 +31,7 @@ namespace NzbDrone.Core.Test.IndexerTests.FileListTests var recentFeed = ReadAllText(@"Files/Indexers/FileList/RecentFeed.json"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); diff --git a/src/NzbDrone.Core.Test/IndexerTests/HDBitsTests/HDBitsFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/HDBitsTests/HDBitsFixture.cs index ba90d4fa9..766df7d31 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/HDBitsTests/HDBitsFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/HDBitsTests/HDBitsFixture.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Net.Http; using System.Text; using FluentAssertions; using Moq; @@ -34,7 +35,7 @@ namespace NzbDrone.Core.Test.IndexerTests.HDBitsTests var responseJson = ReadAllText(fileName); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.POST))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Post))) .Returns(r => new HttpResponse(r, new HttpHeader(), responseJson)); var torrents = Subject.FetchRecent(); diff --git a/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs index d00a2d731..e022ca389 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Net.Http; using FluentAssertions; using Moq; using NUnit.Framework; @@ -88,7 +89,7 @@ namespace NzbDrone.Core.Test.IndexerTests.IPTorrentsTests var recentFeed = ReadAllText(@"Files/Indexers/IPTorrents/IPTorrents.xml"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); diff --git a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs index 1bce91ff5..31d9f06d4 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Linq; using System.Net; +using System.Net.Http; using FluentAssertions; using Moq; using NUnit.Framework; @@ -44,7 +45,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests var recentFeed = ReadAllText(@"Files/Indexers/Newznab/newznab_nzb_su.xml"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); @@ -70,7 +71,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests var recentFeed = ReadAllText(@"Files/Indexers/Torznab/torznab_animetosho.xml"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); diff --git a/src/NzbDrone.Core.Test/IndexerTests/NyaaTests/NyaaFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/NyaaTests/NyaaFixture.cs index d2db3be6d..b814a40af 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/NyaaTests/NyaaFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/NyaaTests/NyaaFixture.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Linq; +using System.Net.Http; using FluentAssertions; using Moq; using NUnit.Framework; @@ -32,7 +33,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NyaaTests var recentFeed = ReadAllText(@"Files/Indexers/Nyaa/Nyaa.xml"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); @@ -62,7 +63,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NyaaTests var recentFeed = ReadAllText(@"Files/Indexers/Nyaa/Nyaa2021.xml"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); diff --git a/src/NzbDrone.Core.Test/IndexerTests/OmgwtfnzbsTests/OmgwtfnzbsFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/OmgwtfnzbsTests/OmgwtfnzbsFixture.cs index 3e211d4cb..bb4bcfbe5 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/OmgwtfnzbsTests/OmgwtfnzbsFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/OmgwtfnzbsTests/OmgwtfnzbsFixture.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Linq; +using System.Net.Http; using FluentAssertions; using Moq; using NUnit.Framework; @@ -33,7 +34,7 @@ namespace NzbDrone.Core.Test.IndexerTests.OmgwtfnzbsTests var recentFeed = ReadAllText(@"Files/Indexers/Omgwtfnzbs/Omgwtfnzbs.xml"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); diff --git a/src/NzbDrone.Core.Test/IndexerTests/RarbgTests/RarbgFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/RarbgTests/RarbgFixture.cs index 89a8603bc..8470f0c0a 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/RarbgTests/RarbgFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/RarbgTests/RarbgFixture.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Linq; +using System.Net.Http; using FluentAssertions; using Moq; using NUnit.Framework; @@ -35,7 +36,7 @@ namespace NzbDrone.Core.Test.IndexerTests.RarbgTests var recentFeed = ReadAllText(@"Files/Indexers/Rarbg/RecentFeed_v2.json"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); @@ -64,7 +65,7 @@ namespace NzbDrone.Core.Test.IndexerTests.RarbgTests public void should_parse_error_20_as_empty_results() { Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), "{ error_code: 20, error: \"some message\" }")); var releases = Subject.FetchRecent(); @@ -76,7 +77,7 @@ namespace NzbDrone.Core.Test.IndexerTests.RarbgTests public void should_warn_on_unknown_error() { Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), "{ error_code: 25, error: \"some message\" }")); var releases = Subject.FetchRecent(); diff --git a/src/NzbDrone.Core.Test/IndexerTests/SeasonSearchFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/SeasonSearchFixture.cs index 316d4ae08..394d01526 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/SeasonSearchFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/SeasonSearchFixture.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System.Collections.Generic; +using System.Net.Http; using FizzWare.NBuilder; using Moq; using NUnit.Framework; @@ -22,7 +23,7 @@ namespace NzbDrone.Core.Test.IndexerTests _series = Builder.CreateNew().Build(); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), "")); } @@ -40,7 +41,7 @@ namespace NzbDrone.Core.Test.IndexerTests var requests = Builder.CreateListOfSize(paging ? 100 : 1) .All() .WithFactory(() => new IndexerRequest("http://my.feed.local/", HttpAccept.Rss)) - .With(v => v.HttpRequest.Method = HttpMethod.GET) + .With(v => v.HttpRequest.Method = HttpMethod.Get) .Build(); var pageable = new IndexerPageableRequestChain(); diff --git a/src/NzbDrone.Core.Test/IndexerTests/TorrentleechTests/TorrentleechFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/TorrentleechTests/TorrentleechFixture.cs index 6b4d5fd0e..ddbcb635f 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/TorrentleechTests/TorrentleechFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/TorrentleechTests/TorrentleechFixture.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Linq; +using System.Net.Http; using FluentAssertions; using Moq; using NUnit.Framework; @@ -30,7 +31,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorrentleechTests var recentFeed = ReadAllText(@"Files/Indexers/Torrentleech/Torrentleech.xml"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); diff --git a/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs index eb8a8255b..cbe0b1d3f 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Net.Http; using FizzWare.NBuilder; @@ -49,7 +49,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests var recentFeed = ReadAllText(@"Files/Indexers/Torznab/torznab_hdaccess_net.xml"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); @@ -80,7 +80,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests var recentFeed = ReadAllText(@"Files/Indexers/Torznab/torznab_tpb.xml"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); @@ -110,7 +110,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests var recentFeed = ReadAllText(@"Files/Indexers/Torznab/torznab_animetosho.xml"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var releases = Subject.FetchRecent(); @@ -164,7 +164,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests (Subject.Definition.Settings as TorznabSettings).BaseUrl = baseUrl; Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); var result = new NzbDroneValidationResult(Subject.Test()); @@ -179,7 +179,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests var recentFeed = ReadAllText(@"Files/Indexers/Torznab/torznab_tpb.xml"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); (Subject.Definition.Settings as TorznabSettings).ApiPath = apiPath; diff --git a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs index 3b51add84..188ba8626 100644 --- a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs +++ b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; -using CookComputing.XmlRpc; using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; @@ -90,12 +89,7 @@ namespace NzbDrone.Core.Download.Clients.Aria2 var downloadSpeed = long.Parse(torrent.DownloadSpeed); var status = DownloadItemStatus.Failed; - var title = ""; - - if (torrent.Bittorrent?.ContainsKey("info") == true && ((XmlRpcStruct)torrent.Bittorrent["info"]).ContainsKey("name")) - { - title = ((XmlRpcStruct)torrent.Bittorrent["info"])["name"].ToString(); - } + var title = torrent.Bittorrent?.Name ?? ""; switch (torrent.Status) { diff --git a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Containers.cs b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Containers.cs index d4ab5a49c..24122b65c 100644 --- a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Containers.cs +++ b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Containers.cs @@ -1,111 +1,161 @@ -using CookComputing.XmlRpc; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; +using System.Xml.XPath; +using NzbDrone.Core.Download.Extensions; namespace NzbDrone.Core.Download.Clients.Aria2 { - public class Aria2Version + public class Aria2Fault { - [XmlRpcMember("version")] - public string Version; + public Aria2Fault(XElement element) + { + foreach (var e in element.XPathSelectElements("./value/struct/member")) + { + var name = e.ElementAsString("name"); + if (name == "faultCode") + { + FaultCode = e.Element("value").ElementAsInt("int"); + } + else if (name == "faultString") + { + FaultString = e.Element("value").GetStringValue(); + } + } + } - [XmlRpcMember("enabledFeatures")] - public string[] EnabledFeatures; + public int FaultCode { get; set; } + public string FaultString { get; set; } } - public class Aria2Uri + public class Aria2Version { - [XmlRpcMember("status")] - public string Status; + public Aria2Version(XElement element) + { + foreach (var e in element.XPathSelectElements("./struct/member")) + { + if (e.ElementAsString("name") == "version") + { + Version = e.Element("value").GetStringValue(); + } + } + } - [XmlRpcMember("uri")] - public string Uri; + public string Version { get; set; } } public class Aria2File { - [XmlRpcMember("index")] - public string Index; + public Aria2File(XElement element) + { + foreach (var e in element.XPathSelectElements("./struct/member")) + { + var name = e.ElementAsString("name"); - [XmlRpcMember("length")] - public string Length; + if (name == "path") + { + Path = e.Element("value").GetStringValue(); + } + } + } - [XmlRpcMember("completedLength")] - public string CompletedLength; + public string Path { get; set; } + } - [XmlRpcMember("path")] - public string Path; + public class Aria2Dict + { + public Aria2Dict(XElement element) + { + Dict = new Dictionary(); - [XmlRpcMember("selected")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string Selected; + foreach (var e in element.XPathSelectElements("./struct/member")) + { + Dict.Add(e.ElementAsString("name"), e.Element("value").GetStringValue()); + } + } - [XmlRpcMember("uris")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public Aria2Uri[] Uris; + public Dictionary Dict { get; set; } + } + + public class Aria2Bittorrent + { + public Aria2Bittorrent(XElement element) + { + foreach (var e in element.Descendants("member")) + { + if (e.ElementAsString("name") == "name") + { + Name = e.Element("value").GetStringValue(); + } + } + } + + public string Name; } public class Aria2Status { - [XmlRpcMember("bittorrent")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public XmlRpcStruct Bittorrent; + public Aria2Status(XElement element) + { + foreach (var e in element.XPathSelectElements("./struct/member")) + { + var name = e.ElementAsString("name"); - [XmlRpcMember("bitfield")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string Bitfield; + if (name == "bittorrent") + { + Bittorrent = new Aria2Bittorrent(e.Element("value")); + } + else if (name == "infoHash") + { + InfoHash = e.Element("value").GetStringValue(); + } + else if (name == "completedLength") + { + CompletedLength = e.Element("value").GetStringValue(); + } + else if (name == "downloadSpeed") + { + DownloadSpeed = e.Element("value").GetStringValue(); + } + else if (name == "files") + { + Files = e.XPathSelectElement("./value/array/data") + .Elements() + .Select(x => new Aria2File(x)) + .ToArray(); + } + else if (name == "gid") + { + Gid = e.Element("value").GetStringValue(); + } + else if (name == "status") + { + Status = e.Element("value").GetStringValue(); + } + else if (name == "totalLength") + { + TotalLength = e.Element("value").GetStringValue(); + } + else if (name == "uploadLength") + { + UploadLength = e.Element("value").GetStringValue(); + } + else if (name == "errorMessage") + { + ErrorMessage = e.Element("value").GetStringValue(); + } + } + } - [XmlRpcMember("infoHash")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string InfoHash; - - [XmlRpcMember("completedLength")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string CompletedLength; - - [XmlRpcMember("connections")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string Connections; - - [XmlRpcMember("dir")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string Dir; - - [XmlRpcMember("downloadSpeed")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string DownloadSpeed; - - [XmlRpcMember("files")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public Aria2File[] Files; - - [XmlRpcMember("gid")] - public string Gid; - - [XmlRpcMember("numPieces")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string NumPieces; - - [XmlRpcMember("pieceLength")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string PieceLength; - - [XmlRpcMember("status")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string Status; - - [XmlRpcMember("totalLength")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string TotalLength; - - [XmlRpcMember("uploadLength")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string UploadLength; - - [XmlRpcMember("uploadSpeed")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string UploadSpeed; - - [XmlRpcMember("errorMessage")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string ErrorMessage; + public Aria2Bittorrent Bittorrent { get; set; } + public string InfoHash { get; set; } + public string CompletedLength { get; set; } + public string DownloadSpeed { get; set; } + public Aria2File[] Files { get; set; } + public string Gid { get; set; } + public string Status { get; set; } + public string TotalLength { get; set; } + public string UploadLength { get; set; } + public string ErrorMessage { get; set; } } } diff --git a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Proxy.cs b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Proxy.cs index 3e7b5a6be..f141ef7ce 100644 --- a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Proxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Proxy.cs @@ -1,9 +1,9 @@ -using System; -using System.Collections; using System.Collections.Generic; -using System.Net; -using CookComputing.XmlRpc; -using NLog; +using System.Linq; +using System.Xml.Linq; +using System.Xml.XPath; +using NzbDrone.Common.Http; +using NzbDrone.Core.Download.Extensions; namespace NzbDrone.Core.Download.Clients.Aria2 { @@ -19,103 +19,61 @@ namespace NzbDrone.Core.Download.Clients.Aria2 Aria2Status GetFromGID(Aria2Settings settings, string gid); } - public interface IAria2 : IXmlRpcProxy - { - [XmlRpcMethod("aria2.getVersion")] - Aria2Version GetVersion(string token); - - [XmlRpcMethod("aria2.addUri")] - string AddUri(string token, string[] uri); - - [XmlRpcMethod("aria2.addTorrent")] - string AddTorrent(string token, byte[] torrent); - - [XmlRpcMethod("aria2.forceRemove")] - string Remove(string token, string gid); - - [XmlRpcMethod("aria2.removeDownloadResult")] - string RemoveResult(string token, string gid); - - [XmlRpcMethod("aria2.tellStatus")] - Aria2Status GetFromGid(string token, string gid); - - [XmlRpcMethod("aria2.getGlobalOption")] - XmlRpcStruct GetGlobalOption(string token); - - [XmlRpcMethod("aria2.tellActive")] - Aria2Status[] GetActive(string token); - - [XmlRpcMethod("aria2.tellWaiting")] - Aria2Status[] GetWaiting(string token, int offset, int num); - - [XmlRpcMethod("aria2.tellStopped")] - Aria2Status[] GetStopped(string token, int offset, int num); - } - public class Aria2Proxy : IAria2Proxy { - private readonly Logger _logger; + private readonly IHttpClient _httpClient; - public Aria2Proxy(Logger logger) + public Aria2Proxy(IHttpClient httpClient) { - _logger = logger; - } - - private string GetToken(Aria2Settings settings) - { - return $"token:{settings?.SecretToken}"; - } - - private string GetURL(Aria2Settings settings) - { - return $"http{(settings.UseSsl ? "s" : "")}://{settings.Host}:{settings.Port}{settings.RpcPath}"; + _httpClient = httpClient; } public string GetVersion(Aria2Settings settings) { - _logger.Trace("> aria2.getVersion"); + var response = ExecuteRequest(settings, "aria2.getVersion", GetToken(settings)); - var client = BuildClient(settings); - var version = ExecuteRequest(() => client.GetVersion(GetToken(settings))); + var element = response.XPathSelectElement("./methodResponse/params/param/value"); - _logger.Trace("< aria2.getVersion"); + var version = new Aria2Version(element); return version.Version; } public Aria2Status GetFromGID(Aria2Settings settings, string gid) { - _logger.Trace("> aria2.tellStatus"); + var response = ExecuteRequest(settings, "aria2.tellStatus", GetToken(settings), gid); - var client = BuildClient(settings); - var found = ExecuteRequest(() => client.GetFromGid(GetToken(settings), gid)); + var element = response.XPathSelectElement("./methodResponse/params/param/value"); - _logger.Trace("< aria2.tellStatus"); + return new Aria2Status(element); + } - return found; + private List GetTorrentsMethod(Aria2Settings settings, string method, params object[] args) + { + var allArgs = new List { GetToken(settings) }; + if (args.Any()) + { + allArgs.AddRange(args); + } + + var response = ExecuteRequest(settings, method, allArgs.ToArray()); + + var element = response.XPathSelectElement("./methodResponse/params/param/value/array/data"); + + var torrents = element?.Elements() + .Select(x => new Aria2Status(x)) + .ToList() + ?? new List(); + return torrents; } public List GetTorrents(Aria2Settings settings) { - _logger.Trace("> aria2.tellActive"); + var active = GetTorrentsMethod(settings, "aria2.tellActive"); - var client = BuildClient(settings); + var waiting = GetTorrentsMethod(settings, "aria2.tellWaiting", 0, 10 * 1024); - var active = ExecuteRequest(() => client.GetActive(GetToken(settings))); - - _logger.Trace("< aria2.tellActive"); - - _logger.Trace("> aria2.tellWaiting"); - - var waiting = ExecuteRequest(() => client.GetWaiting(GetToken(settings), 0, 10 * 1024)); - - _logger.Trace("< aria2.tellWaiting"); - - _logger.Trace("> aria2.tellStopped"); - - var stopped = ExecuteRequest(() => client.GetStopped(GetToken(settings), 0, 10 * 1024)); - - _logger.Trace("< aria2.tellStopped"); + var stopped = GetTorrentsMethod(settings, "aria2.tellStopped", 0, 10 * 1024); var items = new List(); @@ -128,98 +86,79 @@ namespace NzbDrone.Core.Download.Clients.Aria2 public Dictionary GetGlobals(Aria2Settings settings) { - _logger.Trace("> aria2.getGlobalOption"); + var response = ExecuteRequest(settings, "aria2.getGlobalOption", GetToken(settings)); - var client = BuildClient(settings); - var options = ExecuteRequest(() => client.GetGlobalOption(GetToken(settings))); + var element = response.XPathSelectElement("./methodResponse/params/param/value"); - _logger.Trace("< aria2.getGlobalOption"); + var result = new Aria2Dict(element); - var ret = new Dictionary(); - - foreach (DictionaryEntry option in options) - { - ret.Add(option.Key.ToString(), option.Value?.ToString()); - } - - return ret; + return result.Dict; } public string AddMagnet(Aria2Settings settings, string magnet) { - _logger.Trace("> aria2.addUri"); + var response = ExecuteRequest(settings, "aria2.addUri", GetToken(settings), new List { magnet }); - var client = BuildClient(settings); - var gid = ExecuteRequest(() => client.AddUri(GetToken(settings), new[] { magnet })); - - _logger.Trace("< aria2.addUri"); + var gid = response.GetStringResponse(); return gid; } public string AddTorrent(Aria2Settings settings, byte[] torrent) { - _logger.Trace("> aria2.addTorrent"); + var response = ExecuteRequest(settings, "aria2.addTorrent", GetToken(settings), torrent); - var client = BuildClient(settings); - var gid = ExecuteRequest(() => client.AddTorrent(GetToken(settings), torrent)); - - _logger.Trace("< aria2.addTorrent"); + var gid = response.GetStringResponse(); return gid; } public bool RemoveTorrent(Aria2Settings settings, string gid) { - _logger.Trace("> aria2.forceRemove"); + var response = ExecuteRequest(settings, "aria2.forceRemove", GetToken(settings), gid); - var client = BuildClient(settings); - var gidres = ExecuteRequest(() => client.Remove(GetToken(settings), gid)); - - _logger.Trace("< aria2.forceRemove"); + var gidres = response.GetStringResponse(); return gid == gidres; } public bool RemoveCompletedTorrent(Aria2Settings settings, string gid) { - _logger.Trace("> aria2.removeDownloadResult"); + var response = ExecuteRequest(settings, "aria2.removeDownloadResult", GetToken(settings), gid); - var client = BuildClient(settings); - var result = ExecuteRequest(() => client.RemoveResult(GetToken(settings), gid)); - - _logger.Trace("< aria2.removeDownloadResult"); + var result = response.GetStringResponse(); return result == "OK"; } - private IAria2 BuildClient(Aria2Settings settings) + private string GetToken(Aria2Settings settings) { - var client = XmlRpcProxyGen.Create(); - client.Url = GetURL(settings); - - return client; + return $"token:{settings?.SecretToken}"; } - private T ExecuteRequest(Func task) + private XDocument ExecuteRequest(Aria2Settings settings, string methodName, params object[] args) { - try + var requestBuilder = new XmlRpcRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.RpcPath) { - return task(); - } - catch (XmlRpcServerException ex) - { - throw new DownloadClientException("Unable to connect to aria2, please check your settings", ex); - } - catch (WebException ex) - { - if (ex.Status == WebExceptionStatus.TrustFailure) - { - throw new DownloadClientUnavailableException("Unable to connect to aria2, certificate validation failed.", ex); - } + LogResponseContent = true, + }; - throw new DownloadClientUnavailableException("Unable to connect to aria2, please check your settings", ex); + var request = requestBuilder.Call(methodName, args).Build(); + + var response = _httpClient.Execute(request); + + var doc = XDocument.Parse(response.Content); + + var faultElement = doc.XPathSelectElement("./methodResponse/fault"); + + if (faultElement != null) + { + var fault = new Aria2Fault(faultElement); + + throw new DownloadClientException($"Aria2 returned error code {fault.FaultCode}: {fault.FaultString}"); } + + return doc; } } } diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs index 023e06ee4..18787a69c 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; +using System.Net.Http; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Http; @@ -143,15 +144,19 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies return authResponse.Data.SId; } - protected HttpRequestBuilder BuildRequest(DownloadStationSettings settings, string methodName, int apiVersion, HttpMethod httpVerb = HttpMethod.GET) + protected HttpRequestBuilder BuildRequest(DownloadStationSettings settings, string methodName, int apiVersion, HttpMethod httpVerb = null) { + httpVerb ??= HttpMethod.Get; + var info = GetApiInfo(_apiType, settings); return BuildRequest(settings, info, methodName, apiVersion, httpVerb); } - private HttpRequestBuilder BuildRequest(DownloadStationSettings settings, DiskStationApiInfo apiInfo, string methodName, int apiVersion, HttpMethod httpVerb = HttpMethod.GET) + private HttpRequestBuilder BuildRequest(DownloadStationSettings settings, DiskStationApiInfo apiInfo, string methodName, int apiVersion, HttpMethod httpVerb = null) { + httpVerb ??= HttpMethod.Get; + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port).Resource($"webapi/{apiInfo.Path}"); requestBuilder.Method = httpVerb; requestBuilder.LogResponseContent = true; @@ -164,7 +169,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies throw new ArgumentOutOfRangeException(nameof(apiVersion)); } - if (httpVerb == HttpMethod.POST) + if (httpVerb == HttpMethod.Post) { if (apiInfo.NeedsAuthentication) { diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxyV1.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxyV1.cs index 8180a70ba..466a8c49c 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxyV1.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxyV1.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System.Collections.Generic; +using System.Net.Http; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; @@ -21,7 +22,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies public void AddTaskFromData(byte[] data, string filename, string downloadDirectory, DownloadStationSettings settings) { - var requestBuilder = BuildRequest(settings, "create", 2, HttpMethod.POST); + var requestBuilder = BuildRequest(settings, "create", 2, HttpMethod.Post); if (downloadDirectory.IsNotNullOrWhiteSpace()) { diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxyV2.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxyV2.cs index 0484db83e..0812b76d4 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxyV2.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxyV2.cs @@ -1,5 +1,6 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; +using System.Net.Http; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; @@ -22,7 +23,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies public void AddTaskFromData(byte[] data, string filename, string downloadDirectory, DownloadStationSettings settings) { - var requestBuilder = BuildRequest(settings, "create", 2, HttpMethod.POST); + var requestBuilder = BuildRequest(settings, "create", 2, HttpMethod.Post); requestBuilder.AddFormParameter("type", "\"file\""); requestBuilder.AddFormParameter("file", "[\"fileData\"]"); diff --git a/src/NzbDrone.Core/Download/Clients/Flood/FloodProxy.cs b/src/NzbDrone.Core/Download/Clients/Flood/FloodProxy.cs index 35e587c33..a73347df5 100644 --- a/src/NzbDrone.Core/Download/Clients/Flood/FloodProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Flood/FloodProxy.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using System.Net.Http; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Http; @@ -108,7 +109,7 @@ namespace NzbDrone.Core.Download.Clients.Flood { var verifyRequest = BuildRequest(settings).Resource("/auth/verify").Build(); - verifyRequest.Method = HttpMethod.GET; + verifyRequest.Method = HttpMethod.Get; HandleRequest(verifyRequest, settings); } @@ -181,7 +182,7 @@ namespace NzbDrone.Core.Download.Clients.Flood { var getTorrentsRequest = BuildRequest(settings).Resource("/torrents").Build(); - getTorrentsRequest.Method = HttpMethod.GET; + getTorrentsRequest.Method = HttpMethod.Get; return Json.Deserialize(HandleRequest(getTorrentsRequest, settings).Content).Torrents; } @@ -190,7 +191,7 @@ namespace NzbDrone.Core.Download.Clients.Flood { var contentsRequest = BuildRequest(settings).Resource($"/torrents/{hash}/contents").Build(); - contentsRequest.Method = HttpMethod.GET; + contentsRequest.Method = HttpMethod.Get; return Json.Deserialize>(HandleRequest(contentsRequest, settings).Content).ConvertAll(content => content.Path); } @@ -199,7 +200,7 @@ namespace NzbDrone.Core.Download.Clients.Flood { var tagsRequest = BuildRequest(settings).Resource("/torrents/tags").Build(); - tagsRequest.Method = HttpMethod.PATCH; + tagsRequest.Method = HttpMethod.Patch; var body = new Dictionary { @@ -215,7 +216,7 @@ namespace NzbDrone.Core.Download.Clients.Flood { var contentsRequest = BuildRequest(settings).Resource($"/client/settings").Build(); - contentsRequest.Method = HttpMethod.GET; + contentsRequest.Method = HttpMethod.Get; return Json.Deserialize(HandleRequest(contentsRequest, settings).Content); } diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs index 42fcede8f..ec0dd1ffe 100644 --- a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs @@ -74,7 +74,7 @@ namespace NzbDrone.Core.Download.Clients.Hadouken var requestBuilder = new JsonRpcRequestBuilder(baseUrl, method, parameters); requestBuilder.LogResponseContent = true; - requestBuilder.NetworkCredential = new NetworkCredential(settings.Username, settings.Password); + requestBuilder.NetworkCredential = new BasicNetworkCredential(settings.Username, settings.Password); requestBuilder.Headers.Add("Accept-Encoding", "gzip,deflate"); var httpRequest = requestBuilder.Build(); diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs index 30c874656..a24c464f4 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs @@ -229,7 +229,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget var requestBuilder = new JsonRpcRequestBuilder(baseUrl, method, parameters); requestBuilder.LogResponseContent = true; - requestBuilder.NetworkCredential = new NetworkCredential(settings.Username, settings.Password); + requestBuilder.NetworkCredential = new BasicNetworkCredential(settings.Username, settings.Password); var httpRequest = requestBuilder.Build(); diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs index 6cc87bfb6..2432a47d0 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs @@ -294,7 +294,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase) { LogResponseContent = true, - NetworkCredential = new NetworkCredential(settings.Username, settings.Password) + NetworkCredential = new BasicNetworkCredential(settings.Username, settings.Password) }; return requestBuilder; } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs index e1ea0aa93..2ba1ae640 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -339,7 +339,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase) { LogResponseContent = true, - NetworkCredential = new NetworkCredential(settings.Username, settings.Password) + NetworkCredential = new BasicNetworkCredential(settings.Username, settings.Password) }; return requestBuilder; } diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs index 63d3784c3..90e758c3c 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs @@ -201,7 +201,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission .Accept(HttpAccept.Json); requestBuilder.LogResponseContent = true; - requestBuilder.NetworkCredential = new NetworkCredential(settings.Username, settings.Password); + requestBuilder.NetworkCredential = new BasicNetworkCredential(settings.Username, settings.Password); requestBuilder.AllowAutoRedirect = false; return requestBuilder; diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs index ba717ad6a..6e70661c3 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs @@ -132,6 +132,12 @@ namespace NzbDrone.Core.Download.Clients.RTorrent continue; } + // Ignore torrents with an empty path + if (torrent.Path.IsNullOrWhiteSpace()) + { + continue; + } + if (torrent.Path.StartsWith(".")) { throw new DownloadClientException("Download paths must be absolute. Please specify variable \"directory\" in rTorrent."); diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentFault.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentFault.cs new file mode 100644 index 000000000..ba1a23962 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentFault.cs @@ -0,0 +1,28 @@ +using System.Xml.Linq; +using System.Xml.XPath; +using NzbDrone.Core.Download.Extensions; + +namespace NzbDrone.Core.Download.Clients.RTorrent +{ + public class RTorrentFault + { + public RTorrentFault(XElement element) + { + foreach (var e in element.XPathSelectElements("./value/struct/member")) + { + var name = e.ElementAsString("name"); + if (name == "faultCode") + { + FaultCode = e.Element("value").GetIntValue(); + } + else if (name == "faultString") + { + FaultString = e.Element("value").GetStringValue(); + } + } + } + + public int FaultCode { get; set; } + public string FaultString { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs index db7506471..fb32c0598 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs @@ -2,11 +2,11 @@ using System; using System.Collections.Generic; using System.Linq; using System.Net; -using System.Net.Sockets; -using System.Runtime.InteropServices.ComTypes; -using CookComputing.XmlRpc; -using NLog; +using System.Xml.Linq; +using System.Xml.XPath; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Download.Extensions; namespace NzbDrone.Core.Download.Clients.RTorrent { @@ -23,122 +23,67 @@ namespace NzbDrone.Core.Download.Clients.RTorrent void PushTorrentUniqueView(string hash, string view, RTorrentSettings settings); } - public interface IRTorrent : IXmlRpcProxy - { - [XmlRpcMethod("d.multicall2")] - object[] TorrentMulticall(params string[] parameters); - - [XmlRpcMethod("load.normal")] - int LoadNormal(string target, string data, params string[] commands); - - [XmlRpcMethod("load.start")] - int LoadStart(string target, string data, params string[] commands); - - [XmlRpcMethod("load.raw")] - int LoadRaw(string target, byte[] data, params string[] commands); - - [XmlRpcMethod("load.raw_start")] - int LoadRawStart(string target, byte[] data, params string[] commands); - - [XmlRpcMethod("d.erase")] - int Remove(string hash); - - [XmlRpcMethod("d.name")] - string GetName(string hash); - - [XmlRpcMethod("d.custom1.set")] - string SetLabel(string hash, string label); - - [XmlRpcMethod("d.views.push_back_unique")] - int PushUniqueView(string hash, string view); - - [XmlRpcMethod("system.client_version")] - string GetVersion(); - } - public class RTorrentProxy : IRTorrentProxy { - private readonly Logger _logger; + private readonly IHttpClient _httpClient; - public RTorrentProxy(Logger logger) + public RTorrentProxy(IHttpClient httpClient) { - _logger = logger; + _httpClient = httpClient; } public string GetVersion(RTorrentSettings settings) { - _logger.Debug("Executing remote method: system.client_version"); + var document = ExecuteRequest(settings, "system.client_version"); - var client = BuildClient(settings); - var version = ExecuteRequest(() => client.GetVersion()); - - return version; + return document.Descendants("string").FirstOrDefault()?.Value ?? "0.0.0"; } public List GetTorrents(RTorrentSettings settings) { - _logger.Debug("Executing remote method: d.multicall2"); + var document = ExecuteRequest(settings, + "d.multicall2", + "", + "", + "d.name=", // string + "d.hash=", // string + "d.base_path=", // string + "d.custom1=", // string (label) + "d.size_bytes=", // long + "d.left_bytes=", // long + "d.down.rate=", // long (in bytes / s) + "d.ratio=", // long + "d.is_open=", // long + "d.is_active=", // long + "d.complete=", //long + "d.timestamp.finished="); // long (unix timestamp) - var client = BuildClient(settings); - var ret = ExecuteRequest(() => client.TorrentMulticall("", - "", - "d.name=", // string - "d.hash=", // string - "d.base_path=", // string - "d.custom1=", // string (label) - "d.size_bytes=", // long - "d.left_bytes=", // long - "d.down.rate=", // long (in bytes / s) - "d.ratio=", // long - "d.is_open=", // long - "d.is_active=", // long - "d.complete=", // long - "d.timestamp.finished=")); // long (unix timestamp) + var torrents = document.XPathSelectElement("./methodResponse/params/param/value/array/data") + ?.Elements() + .Select(x => new RTorrentTorrent(x)) + .ToList() + ?? new List(); - var items = new List(); - - foreach (object[] torrent in ret) - { - var labelDecoded = System.Web.HttpUtility.UrlDecode((string)torrent[3]); - - var item = new RTorrentTorrent(); - item.Name = (string)torrent[0]; - item.Hash = (string)torrent[1]; - item.Path = (string)torrent[2]; - item.Category = labelDecoded; - item.TotalSize = (long)torrent[4]; - item.RemainingSize = (long)torrent[5]; - item.DownRate = (long)torrent[6]; - item.Ratio = (long)torrent[7]; - item.IsOpen = Convert.ToBoolean((long)torrent[8]); - item.IsActive = Convert.ToBoolean((long)torrent[9]); - item.IsFinished = Convert.ToBoolean((long)torrent[10]); - item.FinishedTime = (long)torrent[11]; - - items.Add(item); - } - - return items; + return torrents; } public void AddTorrentFromUrl(string torrentUrl, string label, RTorrentPriority priority, string directory, RTorrentSettings settings) { - var client = BuildClient(settings); - var response = ExecuteRequest(() => - { - if (settings.AddStopped) - { - _logger.Debug("Executing remote method: load.normal"); - return client.LoadNormal("", torrentUrl, GetCommands(label, priority, directory)); - } - else - { - _logger.Debug("Executing remote method: load.start"); - return client.LoadStart("", torrentUrl, GetCommands(label, priority, directory)); - } - }); + var args = new List { "", torrentUrl }; + args.AddRange(GetCommands(label, priority, directory)); - if (response != 0) + XDocument response; + + if (settings.AddStopped) + { + response = ExecuteRequest(settings, "load.normal", args.ToArray()); + } + else + { + response = ExecuteRequest(settings, "load.start", args.ToArray()); + } + + if (response.GetIntResponse() != 0) { throw new DownloadClientException("Could not add torrent: {0}.", torrentUrl); } @@ -146,22 +91,21 @@ namespace NzbDrone.Core.Download.Clients.RTorrent public void AddTorrentFromFile(string fileName, byte[] fileContent, string label, RTorrentPriority priority, string directory, RTorrentSettings settings) { - var client = BuildClient(settings); - var response = ExecuteRequest(() => - { - if (settings.AddStopped) - { - _logger.Debug("Executing remote method: load.raw"); - return client.LoadRaw("", fileContent, GetCommands(label, priority, directory)); - } - else - { - _logger.Debug("Executing remote method: load.raw_start"); - return client.LoadRawStart("", fileContent, GetCommands(label, priority, directory)); - } - }); + var args = new List { "", fileContent }; + args.AddRange(GetCommands(label, priority, directory)); - if (response != 0) + XDocument response; + + if (settings.AddStopped) + { + response = ExecuteRequest(settings, "load.raw", args.ToArray()); + } + else + { + response = ExecuteRequest(settings, "load.raw_start", args.ToArray()); + } + + if (response.GetIntResponse() != 0) { throw new DownloadClientException("Could not add torrent: {0}.", fileName); } @@ -169,12 +113,9 @@ namespace NzbDrone.Core.Download.Clients.RTorrent public void SetTorrentLabel(string hash, string label, RTorrentSettings settings) { - _logger.Debug("Executing remote method: d.custom1.set"); + var response = ExecuteRequest(settings, "d.custom1.set", hash, label); - var client = BuildClient(settings); - var response = ExecuteRequest(() => client.SetLabel(hash, label)); - - if (response != label) + if (response.GetStringResponse() != label) { throw new DownloadClientException("Could not set label to {1} for torrent: {0}.", hash, label); } @@ -182,11 +123,9 @@ namespace NzbDrone.Core.Download.Clients.RTorrent public void PushTorrentUniqueView(string hash, string view, RTorrentSettings settings) { - _logger.Debug("Executing remote method: d.views.push_back_unique"); + var response = ExecuteRequest(settings, "d.views.push_back_unique", hash, view); - var client = BuildClient(settings); - var response = ExecuteRequest(() => client.PushUniqueView(hash, view)); - if (response != 0) + if (response.GetIntResponse() != 0) { throw new DownloadClientException("Could not push unique view {0} for torrent: {1}.", view, hash); } @@ -194,12 +133,9 @@ namespace NzbDrone.Core.Download.Clients.RTorrent public void RemoveTorrent(string hash, RTorrentSettings settings) { - _logger.Debug("Executing remote method: d.erase"); + var response = ExecuteRequest(settings, "d.erase", hash); - var client = BuildClient(settings); - var response = ExecuteRequest(() => client.Remove(hash)); - - if (response != 0) + if (response.GetIntResponse() != 0) { throw new DownloadClientException("Could not remove torrent: {0}.", hash); } @@ -207,13 +143,10 @@ namespace NzbDrone.Core.Download.Clients.RTorrent public bool HasHashTorrent(string hash, RTorrentSettings settings) { - _logger.Debug("Executing remote method: d.name"); - - var client = BuildClient(settings); - try { - var name = ExecuteRequest(() => client.GetName(hash)); + var response = ExecuteRequest(settings, "d.name", hash); + var name = response.GetStringResponse(); if (name.IsNullOrWhiteSpace()) { @@ -252,45 +185,34 @@ namespace NzbDrone.Core.Download.Clients.RTorrent return result.ToArray(); } - private IRTorrent BuildClient(RTorrentSettings settings) + private XDocument ExecuteRequest(RTorrentSettings settings, string methodName, params object[] args) { - var client = XmlRpcProxyGen.Create(); - - client.Url = string.Format(@"{0}://{1}:{2}/{3}", - settings.UseSsl ? "https" : "http", - settings.Host, - settings.Port, - settings.UrlBase); - - client.EnableCompression = true; + var requestBuilder = new XmlRpcRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase) + { + LogResponseContent = true, + }; if (!settings.Username.IsNullOrWhiteSpace()) { - client.Credentials = new NetworkCredential(settings.Username, settings.Password); + requestBuilder.NetworkCredential = new NetworkCredential(settings.Username, settings.Password); } - return client; - } + var request = requestBuilder.Call(methodName, args).Build(); - private T ExecuteRequest(Func task) - { - try - { - return task(); - } - catch (XmlRpcServerException ex) - { - throw new DownloadClientException("Unable to connect to rTorrent, please check your settings", ex); - } - catch (WebException ex) - { - if (ex.Status == WebExceptionStatus.TrustFailure) - { - throw new DownloadClientUnavailableException("Unable to connect to rTorrent, certificate validation failed.", ex); - } + var response = _httpClient.Execute(request); - throw new DownloadClientUnavailableException("Unable to connect to rTorrent, please check your settings", ex); + var doc = XDocument.Parse(response.Content); + + var faultElement = doc.XPathSelectElement("./methodResponse/fault"); + + if (faultElement != null) + { + var fault = new RTorrentFault(faultElement); + + throw new DownloadClientException($"rTorrent returned error code {fault.FaultCode}: {fault.FaultString}"); } + + return doc; } } } diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs index 14cd0b346..75573b0e9 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs @@ -1,7 +1,35 @@ -namespace NzbDrone.Core.Download.Clients.RTorrent +using System; +using System.Linq; +using System.Web; +using System.Xml.Linq; +using NzbDrone.Core.Download.Extensions; + +namespace NzbDrone.Core.Download.Clients.RTorrent { public class RTorrentTorrent { + public RTorrentTorrent() + { + } + + public RTorrentTorrent(XElement element) + { + var data = element.Descendants("value").ToList(); + + Name = data[0].GetStringValue(); + Hash = data[1].GetStringValue(); + Path = data[2].GetStringValue(); + Category = HttpUtility.UrlDecode(data[3].GetStringValue()); + TotalSize = data[4].GetLongValue(); + RemainingSize = data[5].GetLongValue(); + DownRate = data[6].GetLongValue(); + Ratio = data[7].GetLongValue(); + IsOpen = Convert.ToBoolean(data[8].GetLongValue()); + IsActive = Convert.ToBoolean(data[9].GetLongValue()); + IsFinished = Convert.ToBoolean(data[10].GetLongValue()); + FinishedTime = data[11].GetLongValue(); + } + public string Name { get; set; } public string Hash { get; set; } public string Path { get; set; } diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs index 252cca7a1..9ced7181b 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs @@ -196,7 +196,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent .Accept(HttpAccept.Json); requestBuilder.LogResponseContent = true; - requestBuilder.NetworkCredential = new NetworkCredential(settings.Username, settings.Password); + requestBuilder.NetworkCredential = new BasicNetworkCredential(settings.Username, settings.Password); return requestBuilder; } diff --git a/src/NzbDrone.Core/Download/Extensions/XmlExtensions.cs b/src/NzbDrone.Core/Download/Extensions/XmlExtensions.cs new file mode 100644 index 000000000..1e9deec9f --- /dev/null +++ b/src/NzbDrone.Core/Download/Extensions/XmlExtensions.cs @@ -0,0 +1,55 @@ +using System.Linq; +using System.Xml.Linq; +using System.Xml.XPath; + +namespace NzbDrone.Core.Download.Extensions +{ + internal static class XmlExtensions + { + public static string GetStringValue(this XElement element) + { + return element.ElementAsString("string"); + } + + public static long GetLongValue(this XElement element) + { + return element.ElementAsLong("i8"); + } + + public static int GetIntValue(this XElement element) + { + return element.ElementAsInt("i4"); + } + + public static string ElementAsString(this XElement element, XName name, bool trim = false) + { + var el = element.Element(name); + + return string.IsNullOrWhiteSpace(el?.Value) + ? null + : (trim ? el.Value.Trim() : el.Value); + } + + public static long ElementAsLong(this XElement element, XName name) + { + var el = element.Element(name); + return long.TryParse(el?.Value, out long value) ? value : default; + } + + public static int ElementAsInt(this XElement element, XName name) + { + var el = element.Element(name); + return int.TryParse(el?.Value, out int value) ? value : default(int); + } + + public static int GetIntResponse(this XDocument document) + { + return document.XPathSelectElement("./methodResponse/params/param/value").GetIntValue(); + } + + public static string GetStringResponse(this XDocument document) + { + return document.XPathSelectElement("./methodResponse/params/param/value").GetStringValue(); + } + } +} diff --git a/src/NzbDrone.Core/Http/HttpProxySettingsProvider.cs b/src/NzbDrone.Core/Http/HttpProxySettingsProvider.cs index 57bd4451a..25ac48474 100644 --- a/src/NzbDrone.Core/Http/HttpProxySettingsProvider.cs +++ b/src/NzbDrone.Core/Http/HttpProxySettingsProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; using NzbDrone.Common.Http; using NzbDrone.Common.Http.Proxy; @@ -15,7 +15,7 @@ namespace NzbDrone.Core.Http _configService = configService; } - public HttpProxySettings GetProxySettings(HttpRequest request) + public HttpProxySettings GetProxySettings(HttpUri uri) { var proxySettings = GetProxySettings(); if (proxySettings == null) @@ -23,7 +23,7 @@ namespace NzbDrone.Core.Http return null; } - if (ShouldProxyBeBypassed(proxySettings, request.Url)) + if (ShouldProxyBeBypassed(proxySettings, uri)) { return null; } diff --git a/src/NzbDrone.Core/Indexers/FileList/FileListRequestGenerator.cs b/src/NzbDrone.Core/Indexers/FileList/FileListRequestGenerator.cs index 76251a386..d6a62c2f0 100644 --- a/src/NzbDrone.Core/Indexers/FileList/FileListRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/FileList/FileListRequestGenerator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using NzbDrone.Common.Extensions; @@ -131,7 +131,7 @@ namespace NzbDrone.Core.Indexers.FileList var baseUrl = string.Format("{0}/api.php?action={1}&category={2}{3}", Settings.BaseUrl.TrimEnd('/'), searchType, categoriesQuery, parameters); var request = new IndexerRequest(baseUrl, HttpAccept.Json); - request.HttpRequest.AddBasicAuthentication(Settings.Username.Trim(), Settings.Passkey.Trim()); + request.HttpRequest.Credentials = new BasicNetworkCredential(Settings.Username.Trim(), Settings.Passkey.Trim()); yield return request; } diff --git a/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs b/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs index 6df07f8e9..e051b9a7f 100644 --- a/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs @@ -1,5 +1,6 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; +using System.Net.Http; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; using NzbDrone.Core.IndexerSearch.Definitions; @@ -132,7 +133,7 @@ namespace NzbDrone.Core.Indexers.HDBits .Resource("/api/torrents") .Build(); - request.Method = HttpMethod.POST; + request.Method = HttpMethod.Post; const string appJson = "application/json"; request.Headers.Accept = appJson; request.Headers.ContentType = appJson; diff --git a/src/NzbDrone.Core/Notifications/Discord/DiscordProxy.cs b/src/NzbDrone.Core/Notifications/Discord/DiscordProxy.cs index f3a6be3d2..c066da569 100644 --- a/src/NzbDrone.Core/Notifications/Discord/DiscordProxy.cs +++ b/src/NzbDrone.Core/Notifications/Discord/DiscordProxy.cs @@ -1,3 +1,4 @@ +using System.Net.Http; using NLog; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; @@ -29,7 +30,7 @@ namespace NzbDrone.Core.Notifications.Discord .Accept(HttpAccept.Json) .Build(); - request.Method = HttpMethod.POST; + request.Method = HttpMethod.Post; request.Headers.ContentType = "application/json"; request.SetContent(payload.ToJson()); diff --git a/src/NzbDrone.Core/Notifications/Email/Email.cs b/src/NzbDrone.Core/Notifications/Email/Email.cs index 10d5cc370..59244c1c1 100644 --- a/src/NzbDrone.Core/Notifications/Email/Email.cs +++ b/src/NzbDrone.Core/Notifications/Email/Email.cs @@ -7,17 +7,21 @@ using MailKit.Security; using MimeKit; using NLog; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http.Dispatchers; +using NzbDrone.Core.Security; namespace NzbDrone.Core.Notifications.Email { public class Email : NotificationBase { + private readonly ICertificateValidationService _certificateValidationService; private readonly Logger _logger; public override string Name => "Email"; - public Email(Logger logger) + public Email(ICertificateValidationService certificateValidationService, Logger logger) { + _certificateValidationService = certificateValidationService; _logger = logger; } @@ -124,6 +128,8 @@ namespace NzbDrone.Core.Notifications.Email } } + client.ServerCertificateValidationCallback = _certificateValidationService.ShouldByPassValidationError; + _logger.Debug("Connecting to mail server"); client.Connect(settings.Server, settings.Port, serverOption); diff --git a/src/NzbDrone.Core/Notifications/Join/JoinProxy.cs b/src/NzbDrone.Core/Notifications/Join/JoinProxy.cs index b96508b75..516dda173 100644 --- a/src/NzbDrone.Core/Notifications/Join/JoinProxy.cs +++ b/src/NzbDrone.Core/Notifications/Join/JoinProxy.cs @@ -1,4 +1,5 @@ using System; +using System.Net.Http; using FluentValidation.Results; using NLog; using NzbDrone.Common.Extensions; @@ -28,7 +29,7 @@ namespace NzbDrone.Core.Notifications.Join public void SendNotification(string title, string message, JoinSettings settings) { - var method = HttpMethod.GET; + var method = HttpMethod.Get; try { diff --git a/src/NzbDrone.Core/Notifications/Mailgun/MailgunProxy.cs b/src/NzbDrone.Core/Notifications/Mailgun/MailgunProxy.cs index 837cb4ee0..ac728fd08 100644 --- a/src/NzbDrone.Core/Notifications/Mailgun/MailgunProxy.cs +++ b/src/NzbDrone.Core/Notifications/Mailgun/MailgunProxy.cs @@ -1,7 +1,7 @@ using System.Net; +using System.Net.Http; using NLog; using NzbDrone.Common.Http; -using HttpMethod = NzbDrone.Common.Http.HttpMethod; namespace NzbDrone.Core.Notifications.Mailgun { @@ -28,7 +28,7 @@ namespace NzbDrone.Core.Notifications.Mailgun { try { - var request = BuildRequest(settings, $"{settings.SenderDomain}/messages", HttpMethod.POST, title, message).Build(); + var request = BuildRequest(settings, $"{settings.SenderDomain}/messages", HttpMethod.Post, title, message).Build(); _httpClient.Execute(request); } catch (HttpException ex) diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs index 32919d69c..ba12fee74 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs @@ -1,4 +1,5 @@ using System.Linq; +using System.Net.Http; using System.Text; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Http; @@ -40,7 +41,7 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv .AddQueryParam("X-Plex-Version", BuildInfo.Version.ToString()) .AddQueryParam("strong", true); - requestBuilder.Method = HttpMethod.POST; + requestBuilder.Method = HttpMethod.Post; var request = requestBuilder.Build(); diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs index 5b802857e..784726e3c 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using System.Net.Http; using NLog; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; @@ -35,7 +36,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server public List GetTvSections(PlexServerSettings settings) { - var request = BuildRequest("library/sections", HttpMethod.GET, settings); + var request = BuildRequest("library/sections", HttpMethod.Get, settings); var response = ProcessRequest(request); CheckForError(response); @@ -65,7 +66,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server public void Update(int sectionId, PlexServerSettings settings) { var resource = $"library/sections/{sectionId}/refresh"; - var request = BuildRequest(resource, HttpMethod.GET, settings); + var request = BuildRequest(resource, HttpMethod.Get, settings); var response = ProcessRequest(request); CheckForError(response); @@ -74,7 +75,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server public void UpdateSeries(string metadataId, PlexServerSettings settings) { var resource = $"library/metadata/{metadataId}/refresh"; - var request = BuildRequest(resource, HttpMethod.PUT, settings); + var request = BuildRequest(resource, HttpMethod.Put, settings); var response = ProcessRequest(request); CheckForError(response); @@ -82,7 +83,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server public string Version(PlexServerSettings settings) { - var request = BuildRequest("identity", HttpMethod.GET, settings); + var request = BuildRequest("identity", HttpMethod.Get, settings); var response = ProcessRequest(request); CheckForError(response); @@ -100,7 +101,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server public List Preferences(PlexServerSettings settings) { - var request = BuildRequest(":/prefs", HttpMethod.GET, settings); + var request = BuildRequest(":/prefs", HttpMethod.Get, settings); var response = ProcessRequest(request); CheckForError(response); @@ -120,7 +121,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server { var guid = $"com.plexapp.agents.thetvdb://{tvdbId}?lang={language}"; var resource = $"library/sections/{sectionId}/all?guid={System.Web.HttpUtility.UrlEncode(guid)}"; - var request = BuildRequest(resource, HttpMethod.GET, settings); + var request = BuildRequest(resource, HttpMethod.Get, settings); var response = ProcessRequest(request); CheckForError(response); diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs index 563e21d46..555ffc2c1 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Net; +using System.Net.Http; using FluentValidation.Results; using NLog; using NzbDrone.Common.Extensions; @@ -100,8 +101,8 @@ namespace NzbDrone.Core.Notifications.PushBullet var request = requestBuilder.Build(); - request.Method = HttpMethod.GET; - request.AddBasicAuthentication(settings.ApiKey, string.Empty); + request.Method = HttpMethod.Get; + request.Credentials = new BasicNetworkCredential(settings.ApiKey, string.Empty); var response = _httpClient.Execute(request); @@ -197,7 +198,7 @@ namespace NzbDrone.Core.Notifications.PushBullet var request = requestBuilder.Build(); - request.AddBasicAuthentication(settings.ApiKey, string.Empty); + request.Credentials = new BasicNetworkCredential(settings.ApiKey, string.Empty); _httpClient.Execute(request); } diff --git a/src/NzbDrone.Core/Notifications/SendGrid/SendGridProxy.cs b/src/NzbDrone.Core/Notifications/SendGrid/SendGridProxy.cs index 0db56b4c9..ec57c8636 100644 --- a/src/NzbDrone.Core/Notifications/SendGrid/SendGridProxy.cs +++ b/src/NzbDrone.Core/Notifications/SendGrid/SendGridProxy.cs @@ -1,4 +1,5 @@ -using System.Net; +using System.Net; +using System.Net.Http; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; @@ -22,7 +23,7 @@ namespace NzbDrone.Core.Notifications.SendGrid { try { - var request = BuildRequest(settings, "mail/send", HttpMethod.POST); + var request = BuildRequest(settings, "mail/send", HttpMethod.Post); var payload = new SendGridPayload { diff --git a/src/NzbDrone.Core/Notifications/Slack/SlackProxy.cs b/src/NzbDrone.Core/Notifications/Slack/SlackProxy.cs index 02075dad9..c8dd12f53 100644 --- a/src/NzbDrone.Core/Notifications/Slack/SlackProxy.cs +++ b/src/NzbDrone.Core/Notifications/Slack/SlackProxy.cs @@ -1,3 +1,4 @@ +using System.Net.Http; using NLog; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; @@ -29,7 +30,7 @@ namespace NzbDrone.Core.Notifications.Slack .Accept(HttpAccept.Json) .Build(); - request.Method = HttpMethod.POST; + request.Method = HttpMethod.Post; request.Headers.ContentType = "application/json"; request.SetContent(payload.ToJson()); diff --git a/src/NzbDrone.Core/Notifications/Trakt/TraktProxy.cs b/src/NzbDrone.Core/Notifications/Trakt/TraktProxy.cs index 8307e34c1..fc6bee59b 100644 --- a/src/NzbDrone.Core/Notifications/Trakt/TraktProxy.cs +++ b/src/NzbDrone.Core/Notifications/Trakt/TraktProxy.cs @@ -1,3 +1,4 @@ +using System.Net.Http; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; @@ -35,7 +36,7 @@ namespace NzbDrone.Core.Notifications.Trakt public void AddToCollection(TraktCollectShowsResource payload, string accessToken) { - var request = BuildTraktRequest("sync/collection", HttpMethod.POST, accessToken); + var request = BuildTraktRequest("sync/collection", HttpMethod.Post, accessToken); request.Headers.ContentType = "application/json"; request.SetContent(payload.ToJson()); @@ -53,7 +54,7 @@ namespace NzbDrone.Core.Notifications.Trakt public void RemoveFromCollection(TraktCollectShowsResource payload, string accessToken) { - var request = BuildTraktRequest("sync/collection/remove", HttpMethod.POST, accessToken); + var request = BuildTraktRequest("sync/collection/remove", HttpMethod.Post, accessToken); request.Headers.ContentType = "application/json"; var temp = payload.ToJson(); @@ -72,7 +73,7 @@ namespace NzbDrone.Core.Notifications.Trakt public string GetUserName(string accessToken) { - var request = BuildTraktRequest("users/settings", HttpMethod.GET, accessToken); + var request = BuildTraktRequest("users/settings", HttpMethod.Get, accessToken); try { diff --git a/src/NzbDrone.Core/Notifications/Twitter/TwitterProxy.cs b/src/NzbDrone.Core/Notifications/Twitter/TwitterProxy.cs new file mode 100644 index 000000000..3330e4a07 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Twitter/TwitterProxy.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Web; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.OAuth; + +namespace NzbDrone.Core.Notifications.Twitter +{ + public interface ITwitterProxy + { + NameValueCollection GetOAuthToken(string consumerKey, string consumerSecret, string oauthToken, string oauthVerifier); + string GetOAuthRedirect(string consumerKey, string consumerSecret, string callbackUrl); + void UpdateStatus(string message, TwitterSettings settings); + void DirectMessage(string message, TwitterSettings settings); + } + + public class TwitterProxy : ITwitterProxy + { + private readonly IHttpClient _httpClient; + + public TwitterProxy(IHttpClient httpClient) + { + _httpClient = httpClient; + } + + public string GetOAuthRedirect(string consumerKey, string consumerSecret, string callbackUrl) + { + // Creating a new instance with a helper method + var oAuthRequest = OAuthRequest.ForRequestToken(consumerKey, consumerSecret, callbackUrl); + oAuthRequest.RequestUrl = "https://api.twitter.com/oauth/request_token"; + var qscoll = HttpUtility.ParseQueryString(ExecuteRequest(GetRequest(oAuthRequest, new Dictionary())).Content); + + return string.Format("https://api.twitter.com/oauth/authorize?oauth_token={0}", qscoll["oauth_token"]); + } + + public NameValueCollection GetOAuthToken(string consumerKey, string consumerSecret, string oauthToken, string oauthVerifier) + { + // Creating a new instance with a helper method + var oAuthRequest = OAuthRequest.ForAccessToken(consumerKey, consumerSecret, oauthToken, "", oauthVerifier); + oAuthRequest.RequestUrl = "https://api.twitter.com/oauth/access_token"; + + return HttpUtility.ParseQueryString(ExecuteRequest(GetRequest(oAuthRequest, new Dictionary())).Content); + } + + public void UpdateStatus(string message, TwitterSettings settings) + { + var oAuthRequest = OAuthRequest.ForProtectedResource("POST", settings.ConsumerKey, settings.ConsumerSecret, settings.AccessToken, settings.AccessTokenSecret); + + oAuthRequest.RequestUrl = "https://api.twitter.com/1.1/statuses/update.json"; + + var customParams = new Dictionary + { + { "status", message.EncodeRFC3986() } + }; + + var request = GetRequest(oAuthRequest, customParams); + + request.Headers.ContentType = "application/x-www-form-urlencoded"; + request.SetContent(Encoding.ASCII.GetBytes(GetCustomParametersString(customParams))); + + ExecuteRequest(request); + } + + public void DirectMessage(string message, TwitterSettings settings) + { + var oAuthRequest = OAuthRequest.ForProtectedResource("POST", settings.ConsumerKey, settings.ConsumerSecret, settings.AccessToken, settings.AccessTokenSecret); + + oAuthRequest.RequestUrl = "https://api.twitter.com/1.1/direct_messages/new.json"; + + var customParams = new Dictionary + { + { "text", message.EncodeRFC3986() }, + { "screenname", settings.Mention.EncodeRFC3986() } + }; + + var request = GetRequest(oAuthRequest, customParams); + + request.Headers.ContentType = "application/x-www-form-urlencoded"; + request.SetContent(Encoding.ASCII.GetBytes(GetCustomParametersString(customParams))); + + ExecuteRequest(request); + } + + private string GetCustomParametersString(Dictionary customParams) + { + return customParams.Select(x => string.Format("{0}={1}", x.Key, x.Value)).Join("&"); + } + + private HttpRequest GetRequest(OAuthRequest oAuthRequest, Dictionary customParams) + { + var auth = oAuthRequest.GetAuthorizationHeader(customParams); + var request = new HttpRequest(oAuthRequest.RequestUrl); + + request.Headers.Add("Authorization", auth); + + request.Method = oAuthRequest.Method == "POST" ? HttpMethod.Post : HttpMethod.Get; + + return request; + } + + private HttpResponse ExecuteRequest(HttpRequest request) + { + return _httpClient.Execute(request); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Twitter/TwitterService.cs b/src/NzbDrone.Core/Notifications/Twitter/TwitterService.cs index 9e5db17e4..8d448b384 100644 --- a/src/NzbDrone.Core/Notifications/Twitter/TwitterService.cs +++ b/src/NzbDrone.Core/Notifications/Twitter/TwitterService.cs @@ -1,13 +1,9 @@ -using System; -using System.Collections.Specialized; +using System; using System.IO; using System.Net; -using System.Web; using FluentValidation.Results; using NLog; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; -using NzbDrone.Common.OAuth; namespace NzbDrone.Core.Notifications.Twitter { @@ -21,31 +17,18 @@ namespace NzbDrone.Core.Notifications.Twitter public class TwitterService : ITwitterService { - private readonly IHttpClient _httpClient; + private readonly ITwitterProxy _twitterProxy; private readonly Logger _logger; - public TwitterService(IHttpClient httpClient, Logger logger) + public TwitterService(ITwitterProxy twitterProxy, Logger logger) { - _httpClient = httpClient; + _twitterProxy = twitterProxy; _logger = logger; } - private NameValueCollection OAuthQuery(OAuthRequest oAuthRequest) - { - var auth = oAuthRequest.GetAuthorizationHeader(); - var request = new Common.Http.HttpRequest(oAuthRequest.RequestUrl); - request.Headers.Add("Authorization", auth); - var response = _httpClient.Get(request); - - return HttpUtility.ParseQueryString(response.Content); - } - public OAuthToken GetOAuthToken(string consumerKey, string consumerSecret, string oauthToken, string oauthVerifier) { - // Creating a new instance with a helper method - var oAuthRequest = OAuthRequest.ForAccessToken(consumerKey, consumerSecret, oauthToken, "", oauthVerifier); - oAuthRequest.RequestUrl = "https://api.twitter.com/oauth/access_token"; - var qscoll = OAuthQuery(oAuthRequest); + var qscoll = _twitterProxy.GetOAuthToken(consumerKey, consumerSecret, oauthToken, oauthVerifier); return new OAuthToken { @@ -56,31 +39,16 @@ namespace NzbDrone.Core.Notifications.Twitter public string GetOAuthRedirect(string consumerKey, string consumerSecret, string callbackUrl) { - // Creating a new instance with a helper method - var oAuthRequest = OAuthRequest.ForRequestToken(consumerKey, consumerSecret, callbackUrl); - oAuthRequest.RequestUrl = "https://api.twitter.com/oauth/request_token"; - var qscoll = OAuthQuery(oAuthRequest); - - return string.Format("https://api.twitter.com/oauth/authorize?oauth_token={0}", qscoll["oauth_token"]); + return _twitterProxy.GetOAuthRedirect(consumerKey, consumerSecret, callbackUrl); } public void SendNotification(string message, TwitterSettings settings) { try { - var oAuth = new TinyTwitter.OAuthInfo - { - ConsumerKey = settings.ConsumerKey, - ConsumerSecret = settings.ConsumerSecret, - AccessToken = settings.AccessToken, - AccessSecret = settings.AccessTokenSecret - }; - - var twitter = new TinyTwitter.TinyTwitter(oAuth); - if (settings.DirectMessage) { - twitter.DirectMessage(message, settings.Mention); + _twitterProxy.DirectMessage(message, settings); } else { @@ -89,7 +57,7 @@ namespace NzbDrone.Core.Notifications.Twitter message += string.Format(" @{0}", settings.Mention); } - twitter.UpdateStatus(message); + _twitterProxy.UpdateStatus(message, settings); } } catch (WebException ex) diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookMethod.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookMethod.cs index 920c8b080..e3705d69b 100644 --- a/src/NzbDrone.Core/Notifications/Webhook/WebhookMethod.cs +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookMethod.cs @@ -1,10 +1,10 @@ -using NzbDrone.Common.Http; +using NzbDrone.Common.Http; namespace NzbDrone.Core.Notifications.Webhook { public enum WebhookMethod { - POST = HttpMethod.POST, - PUT = HttpMethod.PUT + POST = 1, + PUT = 2 } } diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookProxy.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookProxy.cs index 489eeb41f..23a7fbdc8 100644 --- a/src/NzbDrone.Core/Notifications/Webhook/WebhookProxy.cs +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookProxy.cs @@ -1,3 +1,5 @@ +using System; +using System.Net.Http; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; @@ -26,13 +28,19 @@ namespace NzbDrone.Core.Notifications.Webhook .Accept(HttpAccept.Json) .Build(); - request.Method = (HttpMethod)settings.Method; + request.Method = settings.Method switch + { + (int)WebhookMethod.POST => HttpMethod.Post, + (int)WebhookMethod.PUT => HttpMethod.Put, + _ => throw new ArgumentOutOfRangeException($"Invalid Webhook method {settings.Method}") + }; + request.Headers.ContentType = "application/json"; request.SetContent(body.ToJson()); if (settings.Username.IsNotNullOrWhiteSpace() || settings.Password.IsNotNullOrWhiteSpace()) { - request.AddBasicAuthentication(settings.Username, settings.Password); + request.Credentials = new BasicNetworkCredential(settings.Username, settings.Password); } _httpClient.Execute(request); diff --git a/src/NzbDrone.Core/Notifications/Xbmc/XbmcJsonApiProxy.cs b/src/NzbDrone.Core/Notifications/Xbmc/XbmcJsonApiProxy.cs index ba22e5609..1d5d5d044 100644 --- a/src/NzbDrone.Core/Notifications/Xbmc/XbmcJsonApiProxy.cs +++ b/src/NzbDrone.Core/Notifications/Xbmc/XbmcJsonApiProxy.cs @@ -84,7 +84,7 @@ namespace NzbDrone.Core.Notifications.Xbmc if (!settings.Username.IsNullOrWhiteSpace()) { - request.AddBasicAuthentication(settings.Username, settings.Password); + request.Credentials = new BasicNetworkCredential(settings.Username, settings.Password); } var response = _httpClient.Execute(request); diff --git a/src/NzbDrone.Core/Security/X509CertificateValidationService.cs b/src/NzbDrone.Core/Security/X509CertificateValidationService.cs index 3493cd20a..7efc877ad 100644 --- a/src/NzbDrone.Core/Security/X509CertificateValidationService.cs +++ b/src/NzbDrone.Core/Security/X509CertificateValidationService.cs @@ -1,16 +1,15 @@ -using System.Linq; +using System.Linq; using System.Net; using System.Net.Security; using System.Security.Cryptography.X509Certificates; using NLog; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http.Dispatchers; using NzbDrone.Core.Configuration; -using NzbDrone.Core.Lifecycle; -using NzbDrone.Core.Messaging.Events; namespace NzbDrone.Core.Security { - public class X509CertificateValidationService : IHandle + public class X509CertificateValidationService : ICertificateValidationService { private readonly IConfigService _configService; private readonly Logger _logger; @@ -21,19 +20,29 @@ namespace NzbDrone.Core.Security _logger = logger; } - private bool ShouldByPassValidationError(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) + public bool ShouldByPassValidationError(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) { - var request = sender as HttpWebRequest; + var targetHostName = string.Empty; - if (request == null) + if (sender is not SslStream && sender is not string) { return true; } - var cert2 = certificate as X509Certificate2; - if (cert2 != null && request != null && cert2.SignatureAlgorithm.FriendlyName == "md5RSA") + if (sender is SslStream request) { - _logger.Error("https://{0} uses the obsolete md5 hash in it's https certificate, if that is your certificate, please (re)create certificate with better algorithm as soon as possible.", request.RequestUri.Authority); + targetHostName = request.TargetHostName; + } + + // Mailkit passes host in sender as string + if (sender is string stringHost) + { + targetHostName = stringHost; + } + + if (certificate is X509Certificate2 cert2 && cert2.SignatureAlgorithm.FriendlyName == "md5RSA") + { + _logger.Error("https://{0} uses the obsolete md5 hash in it's https certificate, if that is your certificate, please (re)create certificate with better algorithm as soon as possible.", targetHostName); } if (sslPolicyErrors == SslPolicyErrors.None) @@ -41,12 +50,12 @@ namespace NzbDrone.Core.Security return true; } - if (request.RequestUri.Host == "localhost" || request.RequestUri.Host == "127.0.0.1") + if (targetHostName == "localhost" || targetHostName == "127.0.0.1") { return true; } - var ipAddresses = GetIPAddresses(request.RequestUri.Host); + var ipAddresses = GetIPAddresses(targetHostName); var certificateValidation = _configService.CertificateValidation; if (certificateValidation == CertificateValidationType.Disabled) @@ -60,7 +69,7 @@ namespace NzbDrone.Core.Security return true; } - _logger.Error("Certificate validation for {0} failed. {1}", request.Address, sslPolicyErrors); + _logger.Error("Certificate validation for {0} failed. {1}", targetHostName, sslPolicyErrors); return false; } @@ -74,10 +83,5 @@ namespace NzbDrone.Core.Security return Dns.GetHostEntry(host).AddressList; } - - public void Handle(ApplicationStartedEvent message) - { - ServicePointManager.ServerCertificateValidationCallback = ShouldByPassValidationError; - } } } diff --git a/src/NzbDrone.Core/Sonarr.Core.csproj b/src/NzbDrone.Core/Sonarr.Core.csproj index f18892e92..239a3f42b 100644 --- a/src/NzbDrone.Core/Sonarr.Core.csproj +++ b/src/NzbDrone.Core/Sonarr.Core.csproj @@ -15,7 +15,6 @@ - diff --git a/src/NzbDrone.Core/TinyTwitter.cs b/src/NzbDrone.Core/TinyTwitter.cs deleted file mode 100644 index 9179f47f0..000000000 --- a/src/NzbDrone.Core/TinyTwitter.cs +++ /dev/null @@ -1,238 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Security.Cryptography; -using System.Text; -using System.Text.RegularExpressions; -using Newtonsoft.Json; - -namespace TinyTwitter -{ - public class OAuthInfo - { - public string ConsumerKey { get; set; } - public string ConsumerSecret { get; set; } - public string AccessToken { get; set; } - public string AccessSecret { get; set; } - } - - public class Tweet - { - public long Id { get; set; } - public DateTime CreatedAt { get; set; } - public string UserName { get; set; } - public string ScreenName { get; set; } - public string Text { get; set; } - } - - public class TinyTwitter - { - private readonly OAuthInfo _oauth; - - public TinyTwitter(OAuthInfo oauth) - { - _oauth = oauth; - } - - public void UpdateStatus(string message) - { - new RequestBuilder(_oauth, "POST", "https://api.twitter.com/1.1/statuses/update.json") - .AddParameter("status", message) - .Execute(); - } - - /** - * - * As of June 26th 2015 Direct Messaging is not part of TinyTwitter. - * I have added it to Sonarr's copy to make our implementation easier - * and added this banner so it's not blindly updated. - * - **/ - public void DirectMessage(string message, string screenName) - { - new RequestBuilder(_oauth, "POST", "https://api.twitter.com/1.1/direct_messages/new.json") - .AddParameter("text", message) - .AddParameter("screen_name", screenName) - .Execute(); - } - - public class RequestBuilder - { - private const string VERSION = "1.0"; - private const string SIGNATURE_METHOD = "HMAC-SHA1"; - - private readonly OAuthInfo _oauth; - private readonly string _method; - private readonly IDictionary _customParameters; - private readonly string _url; - - public RequestBuilder(OAuthInfo oauth, string method, string url) - { - _oauth = oauth; - _method = method; - _url = url; - _customParameters = new Dictionary(); - } - - public RequestBuilder AddParameter(string name, string value) - { - _customParameters.Add(name, value.EncodeRFC3986()); - return this; - } - - public string Execute() - { - var timespan = GetTimestamp(); - var nonce = CreateNonce(); - - var parameters = new Dictionary(_customParameters); - AddOAuthParameters(parameters, timespan, nonce); - - var signature = GenerateSignature(parameters); - var headerValue = GenerateAuthorizationHeaderValue(parameters, signature); - - var request = (HttpWebRequest)WebRequest.Create(GetRequestUrl()); - request.Method = _method; - request.ContentType = "application/x-www-form-urlencoded"; - - request.Headers.Add("Authorization", headerValue); - - WriteRequestBody(request); - - // It looks like a bug in HttpWebRequest. It throws random TimeoutExceptions - // after some requests. Abort the request seems to work. More info: - // http://stackoverflow.com/questions/2252762/getrequeststream-throws-timeout-exception-randomly - - var response = request.GetResponse(); - - string content; - - using (var stream = response.GetResponseStream()) - { - using (var reader = new StreamReader(stream)) - { - content = reader.ReadToEnd(); - } - } - - request.Abort(); - - return content; - } - - private void WriteRequestBody(HttpWebRequest request) - { - if (_method == "GET") - { - return; - } - - var requestBody = Encoding.ASCII.GetBytes(GetCustomParametersString()); - using (var stream = request.GetRequestStream()) - { - stream.Write(requestBody, 0, requestBody.Length); - } - } - - private string GetRequestUrl() - { - if (_method != "GET" || _customParameters.Count == 0) - { - return _url; - } - - return string.Format("{0}?{1}", _url, GetCustomParametersString()); - } - - private string GetCustomParametersString() - { - return _customParameters.Select(x => string.Format("{0}={1}", x.Key, x.Value)).Join("&"); - } - - private string GenerateAuthorizationHeaderValue(IEnumerable> parameters, string signature) - { - return new StringBuilder("OAuth ") - .Append(parameters.Concat(new KeyValuePair("oauth_signature", signature)) - .Where(x => x.Key.StartsWith("oauth_")) - .Select(x => string.Format("{0}=\"{1}\"", x.Key, x.Value.EncodeRFC3986())) - .Join(",")) - .ToString(); - } - - private string GenerateSignature(IEnumerable> parameters) - { - var dataToSign = new StringBuilder() - .Append(_method).Append('&') - .Append(_url.EncodeRFC3986()).Append('&') - .Append(parameters - .OrderBy(x => x.Key) - .Select(x => string.Format("{0}={1}", x.Key, x.Value)) - .Join("&") - .EncodeRFC3986()); - - var signatureKey = string.Format("{0}&{1}", _oauth.ConsumerSecret.EncodeRFC3986(), _oauth.AccessSecret.EncodeRFC3986()); - var sha1 = new HMACSHA1(Encoding.ASCII.GetBytes(signatureKey)); - - var signatureBytes = sha1.ComputeHash(Encoding.ASCII.GetBytes(dataToSign.ToString())); - return Convert.ToBase64String(signatureBytes); - } - - private void AddOAuthParameters(IDictionary parameters, string timestamp, string nonce) - { - parameters.Add("oauth_version", VERSION); - parameters.Add("oauth_consumer_key", _oauth.ConsumerKey); - parameters.Add("oauth_nonce", nonce); - parameters.Add("oauth_signature_method", SIGNATURE_METHOD); - parameters.Add("oauth_timestamp", timestamp); - parameters.Add("oauth_token", _oauth.AccessToken); - } - - private static string GetTimestamp() - { - return ((int)(DateTime.UtcNow - new DateTime(1970, 1, 1)).TotalSeconds).ToString(); - } - - private static string CreateNonce() - { - return new Random().Next(0x0000000, 0x7fffffff).ToString("X8"); - } - } - } - - public static class TinyTwitterHelperExtensions - { - public static string Join(this IEnumerable items, string separator) - { - return string.Join(separator, items.ToArray()); - } - - public static IEnumerable Concat(this IEnumerable items, T value) - { - return items.Concat(new[] { value }); - } - - public static string EncodeRFC3986(this string value) - { - // From Twitterizer http://www.twitterizer.net/ - - if (string.IsNullOrEmpty(value)) - { - return string.Empty; - } - - var encoded = Uri.EscapeDataString(value); - - return Regex - .Replace(encoded, "(%[0-9a-f][0-9a-f])", c => c.Value.ToUpper()) - .Replace("(", "%28") - .Replace(")", "%29") - .Replace("$", "%24") - .Replace("!", "%21") - .Replace("*", "%2A") - .Replace("'", "%27") - .Replace("%7E", "~"); - } - } -} diff --git a/src/NzbDrone.Integration.Test/IndexHtmlFixture.cs b/src/NzbDrone.Integration.Test/IndexHtmlFixture.cs index f78c0627e..7534fcf1b 100644 --- a/src/NzbDrone.Integration.Test/IndexHtmlFixture.cs +++ b/src/NzbDrone.Integration.Test/IndexHtmlFixture.cs @@ -1,5 +1,8 @@ -using System.Linq; +using System; +using System.Linq; using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; using FluentAssertions; using NUnit.Framework; @@ -8,25 +11,30 @@ namespace NzbDrone.Integration.Test [TestFixture] public class IndexHtmlFixture : IntegrationTest { + private HttpClient _httpClient = new HttpClient(); + [Test] public void should_get_index_html() { - var text = new WebClient().DownloadString(RootUrl); + var request = new HttpRequestMessage(HttpMethod.Get, RootUrl); + var response = _httpClient.Send(request); + var text = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); text.Should().NotBeNullOrWhiteSpace(); } [Test] public void index_should_not_be_cached() { - var client = new WebClient(); - _ = client.DownloadString(RootUrl); + var request = new HttpRequestMessage(HttpMethod.Get, RootUrl); + var response = _httpClient.Send(request); - var headers = client.ResponseHeaders; + var headers = response.Headers; - headers.Get("Cache-Control").Split(',').Select(x => x.Trim()) - .Should().BeEquivalentTo("no-store, no-cache".Split(',').Select(x => x.Trim())); - headers.Get("Pragma").Should().Be("no-cache"); - headers.Get("Expires").Should().Be("-1"); + headers.CacheControl.NoStore.Should().BeTrue(); + headers.CacheControl.NoCache.Should().BeTrue(); + headers.Pragma.Should().Contain(new NameValueHeaderValue("no-cache")); + + response.Content.Headers.Expires.Should().BeBefore(DateTime.UtcNow); } } }