From 35651df049a0fc6b94c5de4068f90db5edd38fce 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 (cherry picked from commit 4c0fe62dda7ba87eec08d628f79e4fae8fdb1a0f) --- .editorconfig | 4 +- .../Http/HttpClientFixture.cs | 140 ++++++-- .../Extensions/StringExtensions.cs | 21 ++ .../Http/BasicNetworkCredential.cs | 12 + .../ICertificationValidationService.cs | 10 + .../Http/Dispatchers/IHttpDispatcher.cs | 1 - .../Http/Dispatchers/ManagedHttpDispatcher.cs | 318 ++++++++++++------ src/NzbDrone.Common/Http/GZipWebClient.cs | 15 - src/NzbDrone.Common/Http/HttpAccept.cs | 1 + src/NzbDrone.Common/Http/HttpClient.cs | 104 ++++-- src/NzbDrone.Common/Http/HttpException.cs | 2 +- src/NzbDrone.Common/Http/HttpHeader.cs | 20 ++ src/NzbDrone.Common/Http/HttpMethod.cs | 14 - src/NzbDrone.Common/Http/HttpRequest.cs | 11 +- .../Http/HttpRequestBuilder.cs | 20 +- .../Http/JsonRpcRequestBuilder.cs | 7 +- .../Http/XmlRpcRequestBuilder.cs | 103 ++++++ src/NzbDrone.Core.Test/Framework/CoreTest.cs | 6 +- .../FileListTests/FileListFixture.cs | 3 +- .../GazelleTests/GazelleFixture.cs | 7 +- .../HeadphonesTests/HeadphonesFixture.cs | 3 +- .../IPTorrentsTests/IPTorrentsFixture.cs | 3 +- .../NewznabTests/NewznabFixture.cs | 3 +- .../IndexerTests/NyaaTests/NyaaFixture.cs | 5 +- .../IndexerTests/RarbgTests/RarbgFixture.cs | 9 +- .../RedactedTests/RedactedFixture.cs | 5 +- .../TorrentleechTests/TorrentleechFixture.cs | 5 +- .../TorznabTests/TorznabFixture.cs | 9 +- .../Download/Clients/Aria2/Aria2.cs | 8 +- .../Download/Clients/Aria2/Aria2Containers.cs | 230 ++++++++----- .../Download/Clients/Aria2/Aria2Proxy.cs | 191 ++++------- .../Proxies/DiskStationProxyBase.cs | 11 +- .../Proxies/DownloadStationTaskProxyV1.cs | 5 +- .../Proxies/DownloadStationTaskProxyV2.cs | 3 +- .../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 | 251 +++++--------- .../Clients/rTorrent/RTorrentTorrent.cs | 30 +- .../Clients/uTorrent/UTorrentProxy.cs | 2 +- .../Download/Extensions/XmlExtensions.cs | 55 +++ .../FileList/FileListRequestGenerator.cs | 2 +- .../Gazelle/GazelleRequestGenerator.cs | 5 +- .../HeadphonesCapabilitiesProvider.cs | 3 +- .../Headphones/HeadphonesRequestGenerator.cs | 4 +- .../Notifications/Discord/DiscordProxy.cs | 3 +- .../Notifications/Email/Email.cs | 42 ++- .../Notifications/Join/JoinProxy.cs | 3 +- .../Notifications/Mailgun/MailgunProxy.cs | 4 +- .../MediaBrowser/MediaBrowserProxy.cs | 7 +- .../Notifications/Ntfy/NtfyProxy.cs | 3 +- .../Plex/PlexTv/PlexTvService.cs | 4 +- .../Plex/Server/PlexServerProxy.cs | 13 +- .../PushBullet/PushBulletProxy.cs | 7 +- .../Notifications/SendGrid/SendGridProxy.cs | 5 +- .../Notifications/Slack/SlackProxy.cs | 3 +- .../Subsonic/SubsonicServerProxy.cs | 7 +- .../Notifications/Twitter/TwitterProxy.cs | 110 ++++++ .../Notifications/Twitter/TwitterService.cs | 48 +-- .../Notifications/Webhook/WebhookMethod.cs | 4 +- .../Notifications/Webhook/WebhookProxy.cs | 12 +- .../Notifications/Xbmc/XbmcJsonApiProxy.cs | 2 +- .../X509CertificateValidationService.cs | 40 ++- src/NzbDrone.Core/TinyTwitter.cs | 235 ------------- .../IndexHtmlFixture.cs | 24 +- 70 files changed, 1290 insertions(+), 1002 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 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 5a4666a97..1cdc7b36e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,7 +5,7 @@ root = true # NOTE: Requires **VS2019 16.3** or later # Stylecop.ruleset -# Description: Rules for Radarr +# Description: Rules for Lidarr # Code files [*.cs] @@ -264,7 +264,7 @@ dotnet_diagnostic.CA5392.severity = suggestion dotnet_diagnostic.CA5394.severity = suggestion dotnet_diagnostic.CA5397.severity = suggestion -dotnet_diagnostic.SYSLIB0014.severity = none +dotnet_diagnostic.SYSLIB0006.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 a79f40faa..5828c9fb2 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,8 +16,11 @@ 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 { @@ -31,6 +35,8 @@ namespace NzbDrone.Common.Test.Http private string _httpBinHost; private string _httpBinHost2; + private System.Net.Http.HttpClient _httpClient = new (); + [OneTimeSetUp] public void FixtureSetUp() { @@ -38,7 +44,7 @@ namespace NzbDrone.Common.Test.Http var mainHost = "httpbin.servarr.com"; // Use mirrors for tests that use two hosts - var candidates = new[] { "eu.httpbin.org", /* "httpbin.org", */ "www.httpbin.org" }; + var candidates = new[] { "httpbin1.servarr.com" }; // httpbin.org is broken right now, occassionally redirecting to https if it's unavailable. _httpBinHost = mainHost; @@ -46,29 +52,21 @@ namespace NzbDrone.Common.Test.Http TestLogger.Info($"{candidates.Length} TestSites available."); - _httpBinSleep = _httpBinHosts.Length < 2 ? 100 : 10; + _httpBinSleep = 10; } private bool IsTestSiteAvailable(string site) { try { - var req = WebRequest.Create($"https://{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($"https://{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) { @@ -95,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>(Array.Empty()); Mocker.SetConstant(Mocker.Resolve()); @@ -138,6 +140,28 @@ 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() { @@ -162,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($"https://{_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.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)] @@ -201,6 +252,16 @@ namespace NzbDrone.Common.Test.Http ExceptionVerification.IgnoreWarns(); } + [Test] + public void should_log_unsuccessful_status_codes() + { + var request = new HttpRequest($"https://{_httpBinHost}/status/{HttpStatusCode.NotFound}"); + + var exception = Assert.Throws(() => Subject.Get(request)); + + ExceptionVerification.ExpectedWarns(1); + } + [Test] public void should_not_log_unsuccessful_status_codes() { @@ -309,8 +370,11 @@ namespace NzbDrone.Common.Test.Http Subject.DownloadFile(url, file); + File.Exists(file).Should().BeTrue(); + File.Exists(file + ".part").Should().BeFalse(); + var fileInfo = new FileInfo(file); - fileInfo.Exists.Should().BeTrue(); + fileInfo.Length.Should().Be(146122); } @@ -337,13 +401,39 @@ namespace NzbDrone.Common.Test.Http { var file = GetTempFilePath(); - Assert.Throws(() => Subject.DownloadFile("https://download.lidarr.audio/wrongpath", file)); + Assert.Throws(() => Subject.DownloadFile("https://download.sonarr.tv/wrongpath", file)); File.Exists(file).Should().BeFalse(); + File.Exists(file + ".part").Should().BeFalse(); ExceptionVerification.ExpectedWarns(1); } + [Test] + public void should_not_write_redirect_content_to_stream() + { + var file = GetTempFilePath(); + + using (var fileStream = new FileStream(file, FileMode.Create)) + { + var request = new HttpRequest($"https://{_httpBinHost}/redirect/1"); + request.AllowAutoRedirect = false; + request.ResponseStream = fileStream; + + var response = Subject.Get(request); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + ExceptionVerification.ExpectedErrors(1); + + File.Exists(file).Should().BeTrue(); + + var fileInfo = new FileInfo(file); + + fileInfo.Length.Should().Be(0); + } + [Test] public void should_send_cookie() { @@ -763,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 @@ -773,6 +874,7 @@ namespace NzbDrone.Common.Test.Http 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 4a07aa7b8..686167d64 100644 --- a/src/NzbDrone.Common/Extensions/StringExtensions.cs +++ b/src/NzbDrone.Common/Extensions/StringExtensions.cs @@ -210,5 +210,26 @@ namespace NzbDrone.Common.Extensions { return 1.0 - ((double)a.LevenshteinDistance(b) / Math.Max(a.Length, b.Length)); } + + 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/IHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/IHttpDispatcher.cs index b4cae7ff8..8e665ceed 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/IHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/IHttpDispatcher.cs @@ -5,6 +5,5 @@ namespace NzbDrone.Common.Http.Dispatchers public interface IHttpDispatcher { HttpResponse GetResponse(HttpRequest request, CookieContainer cookies); - void DownloadFile(string url, string fileName); } } diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index c1179d4a2..c3d630a86 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs @@ -1,214 +1,231 @@ using System; using System.Diagnostics; 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); - - // 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; + var requestMessage = new HttpRequestMessage(request.Method, (Uri)request.Url); + requestMessage.Headers.UserAgent.ParseAdd(_userAgentBuilder.GetUserAgent(request.UseSimplifiedUserAgent)); + requestMessage.Headers.ConnectionClose = !request.ConnectionKeepAlive; - webRequest.Method = request.Method.ToString(); - webRequest.UserAgent = _userAgentBuilder.GetUserAgent(request.UseSimplifiedUserAgent); - webRequest.KeepAlive = request.ConnectionKeepAlive; - webRequest.AllowAutoRedirect = false; - webRequest.CookieContainer = cookies; + var cookieHeader = cookies.GetCookieHeader((Uri)request.Url); + if (cookieHeader.IsNotNullOrWhiteSpace()) + { + requestMessage.Headers.Add("Cookie", cookieHeader); + } + using var cts = new CancellationTokenSource(); if (request.RequestTimeout != TimeSpan.Zero) { - webRequest.Timeout = (int)Math.Ceiling(request.RequestTimeout.TotalMilliseconds); + cts.CancelAfter(request.RequestTimeout); } - - webRequest.Proxy = GetProxy(request.Url); - - if (request.Headers != null) + else { - AddRequestHeaders(webRequest, request.Headers); + // The default for System.Net.Http.HttpClient + cts.CancelAfter(TimeSpan.FromSeconds(100)); } - HttpWebResponse httpWebResponse; - - try + if (request.Credentials != null) { - if (request.ContentData != 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) { - webRequest.ContentLength = request.ContentData.Length; - using (var writeStream = webRequest.GetRequestStream()) + var creds = GetCredentialCache(); + foreach (var authtype in new[] { "Basic", "Digest" }) { - writeStream.Write(request.ContentData, 0, request.ContentData.Length); + creds.Remove((Uri)request.Url, authtype); + creds.Add((Uri)request.Url, authtype, nc); } } + } - httpWebResponse = (HttpWebResponse)webRequest.GetResponse(); + if (request.ContentData != null) + { + requestMessage.Content = new ByteArrayContent(request.ContentData); } - catch (WebException e) + + if (request.Headers != null) { - httpWebResponse = (HttpWebResponse)e.Response; + AddRequestHeaders(requestMessage, request.Headers); + } - if (httpWebResponse == null) - { - // 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; - } - } + var httpClient = GetClient(request.Url); + + HttpResponseMessage responseMessage; + + try + { + responseMessage = httpClient.Send(requestMessage, cts.Token); + } + catch (HttpRequestException e) + { + _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); } - public void DownloadFile(string url, string fileName) + protected virtual System.Net.Http.HttpClient GetClient(HttpUri uri) { - try - { - var fileInfo = new FileInfo(fileName); - if (fileInfo.Directory != null && !fileInfo.Directory.Exists) - { - fileInfo.Directory.Create(); - } - - _logger.Debug("Downloading [{0}] to [{1}]", url, fileName); + var proxySettings = _proxySettingsProvider.GetProxySettings(uri); - var stopWatch = Stopwatch.StartNew(); - var uri = new HttpUri(url); + var key = proxySettings?.Key ?? NO_PROXY_KEY; - using (var webClient = new GZipWebClient()) - { - webClient.Headers.Add(HttpRequestHeader.UserAgent, _userAgentBuilder.GetUserAgent()); - webClient.Proxy = GetProxy(uri); - webClient.DownloadFile(uri.FullUri, fileName); - stopWatch.Stop(); - _logger.Debug("Downloading Completed. took {0:0}s", stopWatch.Elapsed.Seconds); - } - } - catch (WebException e) - { - _logger.Warn("Failed to get response from: {0} {1}", url, e.Message); - throw; - } - catch (Exception e) - { - _logger.Warn(e, "Failed to get response from: " + url); - throw; - } + return _httpClientCache.Get(key, () => CreateHttpClient(proxySettings)); } - protected virtual IWebProxy GetProxy(HttpUri uri) + protected virtual System.Net.Http.HttpClient CreateHttpClient(HttpProxySettings proxySettings) { - IWebProxy proxy = null; - - var proxySettings = _proxySettingsProvider.GetProxySettings(uri); + 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) { - proxy = _createManagedWebProxy.GetWebProxy(proxySettings); + handler.Proxy = _createManagedWebProxy.GetWebProxy(proxySettings); } - return proxy; + 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 Lidarr not allowed."); @@ -220,5 +237,84 @@ namespace NzbDrone.Common.Http.Dispatchers } } } + + private void AddContentHeader(HttpRequestMessage request, string header, string value) + { + 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 localToken = cancellationToken; + + if (!hasResolvedIPv6Availability) + { + // 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); + + localToken = linkedTokenSource.Token; + } + + return await attemptConnection(AddressFamily.InterNetworkV6, context, localToken); + } + catch + { + // 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/HttpAccept.cs b/src/NzbDrone.Common/Http/HttpAccept.cs index 8e76f87d3..21367b3a6 100644 --- a/src/NzbDrone.Common/Http/HttpAccept.cs +++ b/src/NzbDrone.Common/Http/HttpAccept.cs @@ -4,6 +4,7 @@ { public static readonly HttpAccept Rss = new HttpAccept("application/rss+xml, text/rss+xml, application/xml, text/xml"); public static readonly HttpAccept Json = new HttpAccept("application/json"); + public static readonly HttpAccept JsonCharset = new HttpAccept("application/json; charset=utf-8"); public static readonly HttpAccept Html = new HttpAccept("text/html"); public string Value { get; private set; } diff --git a/src/NzbDrone.Common/Http/HttpClient.cs b/src/NzbDrone.Common/Http/HttpClient.cs index 5c23af7c8..9ae684958 100644 --- a/src/NzbDrone.Common/Http/HttpClient.cs +++ b/src/NzbDrone.Common/Http/HttpClient.cs @@ -1,8 +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; @@ -119,8 +121,6 @@ namespace NzbDrone.Common.Http var stopWatch = Stopwatch.StartNew(); - PrepareRequestCookies(request, cookieContainer); - var response = _httpDispatcher.GetResponse(request, cookieContainer); HandleResponseCookies(response, cookieContainer); @@ -134,7 +134,7 @@ namespace NzbDrone.Common.Http response = interceptor.PostResponse(response); } - if (request.LogResponseContent) + if (request.LogResponseContent && response.ResponseData != null) { _logger.Trace("Response content ({0} bytes): {1}", response.ResponseData.Length, response.Content); } @@ -187,57 +187,97 @@ 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); - - cookieContainer.Add(persistentCookies); - cookieContainer.Add(existingCookies); - }*/ - } + cookie.Expired = true; + } - 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); } } } public void DownloadFile(string url, string fileName) { - _httpDispatcher.DownloadFile(url, fileName); + var fileNamePart = fileName + ".part"; + + try + { + var fileInfo = new FileInfo(fileName); + if (fileInfo.Directory != null && !fileInfo.Directory.Exists) + { + fileInfo.Directory.Create(); + } + + _logger.Debug("Downloading [{0}] to [{1}]", url, fileName); + + var stopWatch = Stopwatch.StartNew(); + 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); + + if (response.Headers.ContentType != null && response.Headers.ContentType.Contains("text/html")) + { + throw new HttpException(request, response, "Site responded with html content."); + } + } + + stopWatch.Stop(); + if (File.Exists(fileName)) + { + File.Delete(fileName); + } + + File.Move(fileNamePart, fileName); + _logger.Debug("Downloading Completed. took {0:0}s", stopWatch.Elapsed.Seconds); + } + finally + { + if (File.Exists(fileNamePart)) + { + File.Delete(fileNamePart); + } + } } public HttpResponse Get(HttpRequest request) { - request.Method = HttpMethod.GET; + request.Method = HttpMethod.Get; return Execute(request); } @@ -251,13 +291,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/HttpException.cs b/src/NzbDrone.Common/Http/HttpException.cs index d78dd5b3e..c3ad7a1fa 100644 --- a/src/NzbDrone.Common/Http/HttpException.cs +++ b/src/NzbDrone.Common/Http/HttpException.cs @@ -26,7 +26,7 @@ namespace NzbDrone.Common.Http public override string ToString() { - if (Response != null) + if (Response != null && Response.ResponseData != null) { return base.ToString() + Environment.NewLine + Response.Content; } diff --git a/src/NzbDrone.Common/Http/HttpHeader.cs b/src/NzbDrone.Common/Http/HttpHeader.cs index 4aee4092f..f4f1e8a84 100644 --- a/src/NzbDrone.Common/Http/HttpHeader.cs +++ b/src/NzbDrone.Common/Http/HttpHeader.cs @@ -4,11 +4,26 @@ 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 8964bbef6..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 99bec5f8f..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; } @@ -48,6 +51,7 @@ namespace NzbDrone.Common.Http public TimeSpan RequestTimeout { get; set; } public TimeSpan RateLimit { get; set; } public string RateLimitKey { get; set; } + public Stream ResponseStream { get; set; } public override string ToString() { @@ -84,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 a8405a15c..32fafd56f 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) { @@ -271,7 +265,7 @@ namespace NzbDrone.Common.Http public virtual HttpRequestBuilder Post() { - Method = HttpMethod.POST; + Method = HttpMethod.Post; return this; } @@ -362,7 +356,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."); } @@ -378,7 +372,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/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 86807e0db..550309a34 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; @@ -10,6 +10,7 @@ using NzbDrone.Common.TPL; using NzbDrone.Core.Configuration; using NzbDrone.Core.Http; using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Security; using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.Framework @@ -24,7 +25,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(), TestLogger)); Mocker.SetConstant(new LidarrCloudRequestBuilder()); Mocker.SetConstant(Mocker.Resolve()); diff --git a/src/NzbDrone.Core.Test/IndexerTests/FileListTests/FileListFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/FileListTests/FileListFixture.cs index cd4da25ee..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.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/GazelleTests/GazelleFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/GazelleTests/GazelleFixture.cs index c1d254b96..2bedec4a2 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/GazelleTests/GazelleFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/GazelleTests/GazelleFixture.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Net.Http; using FluentAssertions; using Moq; using NUnit.Framework; @@ -35,15 +36,15 @@ namespace NzbDrone.Core.Test.IndexerTests.GazelleTests var indexFeed = ReadAllText(@"Files/Indexers/Gazelle/GazelleIndex.json"); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET && v.Url.FullUri.Contains("ajax.php?action=browse")))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get && v.Url.FullUri.Contains("ajax.php?action=browse")))) .Returns(r => new HttpResponse(r, new HttpHeader { ContentType = "application/json" }, recentFeed)); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.POST && v.Url.FullUri.Contains("ajax.php?action=index")))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Post && v.Url.FullUri.Contains("ajax.php?action=index")))) .Returns(r => new HttpResponse(r, new HttpHeader(), indexFeed)); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.POST && v.Url.FullUri.Contains("login.php")))) + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Post && v.Url.FullUri.Contains("login.php")))) .Returns(r => new HttpResponse(r, new HttpHeader(), indexFeed)); var releases = Subject.FetchRecent(); diff --git a/src/NzbDrone.Core.Test/IndexerTests/HeadphonesTests/HeadphonesFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/HeadphonesTests/HeadphonesFixture.cs index 88678f923..418ad45fc 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/HeadphonesTests/HeadphonesFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/HeadphonesTests/HeadphonesFixture.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Net.Http; using FluentAssertions; using Moq; using NUnit.Framework; @@ -42,7 +43,7 @@ namespace NzbDrone.Core.Test.IndexerTests.HeadphonesTests var recentFeed = ReadAllText(@"Files/Indexers/Headphones/Headphones.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/IPTorrentsTests/IPTorrentsFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs index cabc82040..14238d93f 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 3d244f5fe..b0b8ecd8b 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.Linq; using System.Net; +using System.Net.Http; using FluentAssertions; using Moq; using NUnit.Framework; @@ -43,7 +44,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(); diff --git a/src/NzbDrone.Core.Test/IndexerTests/NyaaTests/NyaaFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/NyaaTests/NyaaFixture.cs index f04f8a571..2d2d957d3 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; @@ -30,7 +31,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(); diff --git a/src/NzbDrone.Core.Test/IndexerTests/RarbgTests/RarbgFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/RarbgTests/RarbgFixture.cs index c640c07e3..97ae0fcdd 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(); @@ -62,7 +63,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(); @@ -74,7 +75,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/RedactedTests/RedactedFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/RedactedTests/RedactedFixture.cs index 68da49f7c..b13a80974 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/RedactedTests/RedactedFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/RedactedTests/RedactedFixture.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Net.Http; using FluentAssertions; using Moq; using NUnit.Framework; @@ -33,13 +34,13 @@ namespace NzbDrone.Core.Test.IndexerTests.GazelleTests var indexFeed = ReadAllText(@"Files/Indexers/Gazelle/GazelleIndex.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 && v.Url.FullUri.Contains("ajax.php?action=browse") && v.Headers.Get("Authorization") == ((RedactedSettings)Subject.Definition.Settings).ApiKey))) .Returns(r => new HttpResponse(r, new HttpHeader { ContentType = "application/json" }, recentFeed)); Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET && + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get && v.Url.FullUri.Contains("ajax.php?action=index") && v.Headers.Get("Authorization") == ((RedactedSettings)Subject.Definition.Settings).ApiKey))) .Returns(r => new HttpResponse(r, new HttpHeader(), indexFeed)); diff --git a/src/NzbDrone.Core.Test/IndexerTests/TorrentleechTests/TorrentleechFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/TorrentleechTests/TorrentleechFixture.cs index 691abd0a4..59a284276 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 6b17cb077..48715ef21 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Net.Http; using FizzWare.NBuilder; using FluentAssertions; using Moq; @@ -48,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(); @@ -77,7 +78,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(); @@ -130,7 +131,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()); @@ -145,7 +146,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 e895f34de..0af3f4a95 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; - - [XmlRpcMember("enabledFeatures")] - public string[] EnabledFeatures; + 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(); + } + } + } + + public int FaultCode { get; set; } + public string FaultString { get; set; } } - public class Aria2Uri + public class Aria2Version { - [XmlRpcMember("status")] - public string Status; - - [XmlRpcMember("uri")] - public string Uri; + public Aria2Version(XElement element) + { + foreach (var e in element.XPathSelectElements("./struct/member")) + { + if (e.ElementAsString("name") == "version") + { + Version = e.Element("value").GetStringValue(); + } + } + } + + public string Version { get; set; } } public class Aria2File { - [XmlRpcMember("index")] - public string Index; - - [XmlRpcMember("length")] - public string Length; + public Aria2File(XElement element) + { + foreach (var e in element.XPathSelectElements("./struct/member")) + { + var name = e.ElementAsString("name"); + + if (name == "path") + { + Path = e.Element("value").GetStringValue(); + } + } + } + + public string Path { get; set; } + } - [XmlRpcMember("completedLength")] - public string CompletedLength; + public class Aria2Dict + { + public Aria2Dict(XElement element) + { + Dict = new Dictionary(); - [XmlRpcMember("path")] - public string Path; + foreach (var e in element.XPathSelectElements("./struct/member")) + { + Dict.Add(e.ElementAsString("name"), e.Element("value").GetStringValue()); + } + } - [XmlRpcMember("selected")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string Selected; + public Dictionary Dict { get; set; } + } - [XmlRpcMember("uris")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public Aria2Uri[] Uris; + 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; - - [XmlRpcMember("bitfield")] - [XmlRpcMissingMapping(MappingAction.Ignore)] - public string Bitfield; - - [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 Aria2Status(XElement element) + { + foreach (var e in element.XPathSelectElements("./struct/member")) + { + var name = e.ElementAsString("name"); + + 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(); + } + } + } + + 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 client = BuildClient(settings); - var found = ExecuteRequest(() => client.GetFromGid(GetToken(settings), gid)); + var response = ExecuteRequest(settings, "aria2.tellStatus", GetToken(settings), gid); - _logger.Trace("< aria2.tellStatus"); + var element = response.XPathSelectElement("./methodResponse/params/param/value"); - return found; + return new Aria2Status(element); } - public List GetTorrents(Aria2Settings settings) + private List GetTorrentsMethod(Aria2Settings settings, string method, params object[] args) { - _logger.Trace("> aria2.tellActive"); - - var client = BuildClient(settings); - - var active = ExecuteRequest(() => client.GetActive(GetToken(settings))); - - _logger.Trace("< aria2.tellActive"); + var allArgs = new List { GetToken(settings) }; + if (args.Any()) + { + allArgs.AddRange(args); + } - _logger.Trace("> aria2.tellWaiting"); + var response = ExecuteRequest(settings, method, allArgs.ToArray()); - var waiting = ExecuteRequest(() => client.GetWaiting(GetToken(settings), 0, 10 * 1024)); + var element = response.XPathSelectElement("./methodResponse/params/param/value/array/data"); - _logger.Trace("< aria2.tellWaiting"); + var torrents = element?.Elements() + .Select(x => new Aria2Status(x)) + .ToList() + ?? new List(); + return torrents; + } - _logger.Trace("> aria2.tellStopped"); + public List GetTorrents(Aria2Settings settings) + { + var active = GetTorrentsMethod(settings, "aria2.tellActive"); - var stopped = ExecuteRequest(() => client.GetStopped(GetToken(settings), 0, 10 * 1024)); + var waiting = GetTorrentsMethod(settings, "aria2.tellWaiting", 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 client = BuildClient(settings); - var options = ExecuteRequest(() => client.GetGlobalOption(GetToken(settings))); + var response = ExecuteRequest(settings, "aria2.getGlobalOption", GetToken(settings)); - _logger.Trace("< aria2.getGlobalOption"); + var element = response.XPathSelectElement("./methodResponse/params/param/value"); - var ret = new Dictionary(); + var result = new Aria2Dict(element); - 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 client = BuildClient(settings); - var gid = ExecuteRequest(() => client.AddUri(GetToken(settings), new[] { magnet })); + var response = ExecuteRequest(settings, "aria2.addUri", GetToken(settings), new List { 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 client = BuildClient(settings); - var gidres = ExecuteRequest(() => client.Remove(GetToken(settings), gid)); + var response = ExecuteRequest(settings, "aria2.forceRemove", 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 client = BuildClient(settings); - var result = ExecuteRequest(() => client.RemoveResult(GetToken(settings), gid)); + var response = ExecuteRequest(settings, "aria2.removeDownloadResult", 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) + LogResponseContent = true, + }; + + 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) { - if (ex.Status == WebExceptionStatus.TrustFailure) - { - throw new DownloadClientUnavailableException("Unable to connect to aria2, certificate validation failed.", ex); - } + var fault = new Aria2Fault(faultElement); - throw new DownloadClientUnavailableException("Unable to connect to aria2, please check your settings", ex); + 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 5dfbea3ac..c1507aa2e 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Net; +using System.Net.Http; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Http; @@ -142,15 +143,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; @@ -163,7 +168,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 261f76e19..fa76c1d0d 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.Linq; +using System.Net.Http; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; @@ -25,7 +26,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 4f27759f9..20a53da78 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 6c7b5a53a..edd01faa3 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 d8549479d..b26e4929f 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs @@ -296,7 +296,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 104a03d83..dd1a09e2f 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -338,7 +338,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 6f5c7296b..b9ae4605a 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs @@ -200,7 +200,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 6d440e57a..72c387396 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs @@ -129,6 +129,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 1e5191e54..fb32c0598 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; -using CookComputing.XmlRpc; -using NLog; +using System.Xml.Linq; +using System.Xml.XPath; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Serializer; +using NzbDrone.Common.Http; +using NzbDrone.Core.Download.Extensions; namespace NzbDrone.Core.Download.Clients.RTorrent { @@ -21,125 +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 client = BuildClient(settings); - var version = ExecuteRequest(() => client.GetVersion()); + var document = ExecuteRequest(settings, "system.client_version"); - return version; + return document.Descendants("string").FirstOrDefault()?.Value ?? "0.0.0"; } public List GetTorrents(RTorrentSettings settings) { - _logger.Debug("Executing remote method: d.multicall2"); - - 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) - - _logger.Trace(ret.ToJson()); - - 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; + 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 torrents = document.XPathSelectElement("./methodResponse/params/param/value/array/data") + ?.Elements() + .Select(x => new RTorrentTorrent(x)) + .ToList() + ?? new List(); + + return torrents; } public void AddTorrentFromUrl(string torrentUrl, string label, RTorrentPriority priority, string directory, RTorrentSettings settings) { - var client = BuildClient(settings); - var response = ExecuteRequest(() => + var args = new List { "", torrentUrl }; + args.AddRange(GetCommands(label, priority, directory)); + + XDocument response; + + if (settings.AddStopped) { - 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)); - } - }); + response = ExecuteRequest(settings, "load.normal", args.ToArray()); + } + else + { + response = ExecuteRequest(settings, "load.start", args.ToArray()); + } - if (response != 0) + if (response.GetIntResponse() != 0) { throw new DownloadClientException("Could not add torrent: {0}.", torrentUrl); } @@ -147,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(() => + var args = new List { "", fileContent }; + args.AddRange(GetCommands(label, priority, directory)); + + XDocument response; + + if (settings.AddStopped) { - 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)); - } - }); + response = ExecuteRequest(settings, "load.raw", args.ToArray()); + } + else + { + response = ExecuteRequest(settings, "load.raw_start", args.ToArray()); + } - if (response != 0) + if (response.GetIntResponse() != 0) { throw new DownloadClientException("Could not add torrent: {0}.", fileName); } @@ -170,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); } @@ -183,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); } @@ -195,12 +133,9 @@ namespace NzbDrone.Core.Download.Clients.RTorrent public void RemoveTorrent(string hash, RTorrentSettings settings) { - _logger.Debug("Executing remote method: d.erase"); - - var client = BuildClient(settings); - var response = ExecuteRequest(() => client.Remove(hash)); + var response = ExecuteRequest(settings, "d.erase", hash); - if (response != 0) + if (response.GetIntResponse() != 0) { throw new DownloadClientException("Could not remove torrent: {0}.", hash); } @@ -208,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()) { @@ -253,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) + var response = _httpClient.Execute(request); + + var doc = XDocument.Parse(response.Content); + + var faultElement = doc.XPathSelectElement("./methodResponse/fault"); + + if (faultElement != null) { - if (ex.Status == WebExceptionStatus.TrustFailure) - { - throw new DownloadClientUnavailableException("Unable to connect to rTorrent, certificate validation failed.", ex); - } + var fault = new RTorrentFault(faultElement); - throw new DownloadClientUnavailableException("Unable to connect to rTorrent, please check your settings", ex); + 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 67aae7fd9..184608986 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/Indexers/FileList/FileListRequestGenerator.cs b/src/NzbDrone.Core/Indexers/FileList/FileListRequestGenerator.cs index 5f58aa3d6..42748ff38 100644 --- a/src/NzbDrone.Core/Indexers/FileList/FileListRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/FileList/FileListRequestGenerator.cs @@ -44,7 +44,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/Gazelle/GazelleRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Gazelle/GazelleRequestGenerator.cs index 0f15e1484..1353fcc5c 100644 --- a/src/NzbDrone.Core/Indexers/Gazelle/GazelleRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Gazelle/GazelleRequestGenerator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Net.Http; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; @@ -71,7 +72,7 @@ namespace NzbDrone.Core.Indexers.Gazelle }; indexRequestBuilder.SetCookies(cookies); - indexRequestBuilder.Method = HttpMethod.POST; + indexRequestBuilder.Method = HttpMethod.Post; indexRequestBuilder.Resource("ajax.php?action=index"); var authIndexRequest = indexRequestBuilder @@ -92,7 +93,7 @@ namespace NzbDrone.Core.Indexers.Gazelle LogResponseContent = true }; - requestBuilder.Method = HttpMethod.POST; + requestBuilder.Method = HttpMethod.Post; requestBuilder.Resource("login.php"); requestBuilder.PostProcess += r => r.RequestTimeout = TimeSpan.FromSeconds(15); diff --git a/src/NzbDrone.Core/Indexers/Headphones/HeadphonesCapabilitiesProvider.cs b/src/NzbDrone.Core/Indexers/Headphones/HeadphonesCapabilitiesProvider.cs index 95155a710..6de2154a9 100644 --- a/src/NzbDrone.Core/Indexers/Headphones/HeadphonesCapabilitiesProvider.cs +++ b/src/NzbDrone.Core/Indexers/Headphones/HeadphonesCapabilitiesProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Net; using System.Xml; using System.Xml.Linq; using NLog; @@ -50,7 +51,7 @@ namespace NzbDrone.Core.Indexers.Headphones var request = new HttpRequest(url, HttpAccept.Rss); - request.AddBasicAuthentication(indexerSettings.Username, indexerSettings.Password); + request.Credentials = new BasicNetworkCredential(indexerSettings.Username, indexerSettings.Password); HttpResponse response; diff --git a/src/NzbDrone.Core/Indexers/Headphones/HeadphonesRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Headphones/HeadphonesRequestGenerator.cs index 348c29243..882f33e5e 100644 --- a/src/NzbDrone.Core/Indexers/Headphones/HeadphonesRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Headphones/HeadphonesRequestGenerator.cs @@ -78,7 +78,7 @@ namespace NzbDrone.Core.Indexers.Headphones if (PageSize == 0) { var request = new IndexerRequest($"{baseUrl}{parameters}", HttpAccept.Rss); - request.HttpRequest.AddBasicAuthentication(Settings.Username, Settings.Password); + request.HttpRequest.Credentials = new BasicNetworkCredential(Settings.Username, Settings.Password); yield return request; } @@ -87,7 +87,7 @@ namespace NzbDrone.Core.Indexers.Headphones for (var page = 0; page < maxPages; page++) { var request = new IndexerRequest($"{baseUrl}&offset={page * PageSize}&limit={PageSize}{parameters}", HttpAccept.Rss); - request.HttpRequest.AddBasicAuthentication(Settings.Username, Settings.Password); + request.HttpRequest.Credentials = new BasicNetworkCredential(Settings.Username, Settings.Password); yield return request; } 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 9eb5f0a08..80a2e5e55 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; } @@ -68,23 +72,6 @@ namespace NzbDrone.Core.Notifications.Email return new ValidationResult(failures); } - public ValidationFailure Test(EmailSettings settings) - { - const string body = "Success! You have properly configured your email notification settings"; - - try - { - SendEmail(settings, "Lidarr - Test Notification", body); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to send test email"); - return new ValidationFailure("Server", "Unable to send test email"); - } - - return null; - } - private void SendEmail(EmailSettings settings, string subject, string body, bool htmlBody = false) { var email = new MimeMessage(); @@ -137,6 +124,8 @@ namespace NzbDrone.Core.Notifications.Email } } + client.ServerCertificateValidationCallback = _certificateValidationService.ShouldByPassValidationError; + _logger.Debug("Connecting to mail server"); client.Connect(settings.Server, settings.Port, serverOption); @@ -160,6 +149,23 @@ namespace NzbDrone.Core.Notifications.Email } } + public ValidationFailure Test(EmailSettings settings) + { + const string body = "Success! You have properly configured your email notification settings"; + + try + { + SendEmail(settings, "Sonarr - Test Notification", body); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to send test email"); + return new ValidationFailure("Server", "Unable to send test email"); + } + + return null; + } + private MailboxAddress ParseAddress(string type, string address) { try diff --git a/src/NzbDrone.Core/Notifications/Join/JoinProxy.cs b/src/NzbDrone.Core/Notifications/Join/JoinProxy.cs index 7a98dfda4..167d64d67 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; @@ -27,7 +28,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 dbbbf2af7..ba4bbe18b 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 { @@ -27,7 +27,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/MediaBrowser/MediaBrowserProxy.cs b/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserProxy.cs index 9bfdf948f..f774140f9 100644 --- a/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserProxy.cs +++ b/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserProxy.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Net.Http; using NLog; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; @@ -23,7 +24,7 @@ namespace NzbDrone.Core.Notifications.Emby var path = "/Notifications/Admin"; var request = BuildRequest(path, settings); request.Headers.ContentType = "application/json"; - request.Method = HttpMethod.POST; + request.Method = HttpMethod.Post; request.SetContent(new { @@ -68,7 +69,7 @@ namespace NzbDrone.Core.Notifications.Emby request = BuildRequest(path, settings); } - request.Method = HttpMethod.POST; + request.Method = HttpMethod.Post; ProcessRequest(request, settings); } @@ -105,7 +106,7 @@ namespace NzbDrone.Core.Notifications.Emby { var path = "/Library/MediaFolders"; var request = BuildRequest(path, settings); - request.Method = HttpMethod.GET; + request.Method = HttpMethod.Get; var response = ProcessRequest(request, settings); diff --git a/src/NzbDrone.Core/Notifications/Ntfy/NtfyProxy.cs b/src/NzbDrone.Core/Notifications/Ntfy/NtfyProxy.cs index d670cb724..bb594eeb6 100644 --- a/src/NzbDrone.Core/Notifications/Ntfy/NtfyProxy.cs +++ b/src/NzbDrone.Core/Notifications/Ntfy/NtfyProxy.cs @@ -1,7 +1,6 @@ using System; using System.Linq; using System.Net; - using FluentValidation.Results; using NLog; using NzbDrone.Common.Extensions; @@ -118,7 +117,7 @@ namespace NzbDrone.Core.Notifications.Ntfy if (!settings.UserName.IsNullOrWhiteSpace() && !settings.Password.IsNullOrWhiteSpace()) { - request.AddBasicAuthentication(settings.UserName, settings.Password); + request.Credentials = new BasicNetworkCredential(settings.UserName, settings.Password); } _httpClient.Execute(request); diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs index 533f5d866..ea65e7175 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs @@ -1,4 +1,6 @@ using System.Linq; +using System.Net.Http; +using System.Text; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; @@ -37,7 +39,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 9ae241580..effa10244 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 GetArtistSections(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 UpdateArtist(int 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 = string.Format("com.plexapp.agents.lastfm://{0}?lang={1}", mbId, language); // TODO Plex Route for MB? LastFM? 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 8ed8df0b7..6f5302e6c 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/Subsonic/SubsonicServerProxy.cs b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicServerProxy.cs index af2d58716..aa3c93a9b 100644 --- a/src/NzbDrone.Core/Notifications/Subsonic/SubsonicServerProxy.cs +++ b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicServerProxy.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Net.Http; using System.Xml.Linq; using NLog; using NzbDrone.Common.Extensions; @@ -36,7 +37,7 @@ namespace NzbDrone.Core.Notifications.Subsonic public void Notify(SubsonicSettings settings, string message) { var resource = "addChatMessage"; - var request = GetSubsonicServerRequest(resource, HttpMethod.GET, settings); + var request = GetSubsonicServerRequest(resource, HttpMethod.Get, settings); request.AddQueryParam("message", message); var response = _httpClient.Execute(request.Build()); @@ -48,7 +49,7 @@ namespace NzbDrone.Core.Notifications.Subsonic public void Update(SubsonicSettings settings) { var resource = "startScan"; - var request = GetSubsonicServerRequest(resource, HttpMethod.GET, settings); + var request = GetSubsonicServerRequest(resource, HttpMethod.Get, settings); var response = _httpClient.Execute(request.Build()); _logger.Trace("Update response: {0}", response.Content); @@ -57,7 +58,7 @@ namespace NzbDrone.Core.Notifications.Subsonic public string Version(SubsonicSettings settings) { - var request = GetSubsonicServerRequest("ping", HttpMethod.GET, settings); + var request = GetSubsonicServerRequest("ping", HttpMethod.Get, settings); var response = _httpClient.Execute(request.Build()); _logger.Trace("Version response: {0}", response.Content); 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 261c194d1..e784b64a7 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 5d6e859a6..e3705d69b 100644 --- a/src/NzbDrone.Core/Notifications/Webhook/WebhookMethod.cs +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookMethod.cs @@ -4,7 +4,7 @@ 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 9139350b5..f130199ba 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/TinyTwitter.cs b/src/NzbDrone.Core/TinyTwitter.cs deleted file mode 100644 index f7f77bb58..000000000 --- a/src/NzbDrone.Core/TinyTwitter.cs +++ /dev/null @@ -1,235 +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; - -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 Lidarr'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 ed732aee8..7534fcf1b 100644 --- a/src/NzbDrone.Integration.Test/IndexHtmlFixture.cs +++ b/src/NzbDrone.Integration.Test/IndexHtmlFixture.cs @@ -1,5 +1,8 @@ +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 = response.Headers; - var headers = client.ResponseHeaders; + headers.CacheControl.NoStore.Should().BeTrue(); + headers.CacheControl.NoCache.Should().BeTrue(); + headers.Pragma.Should().Contain(new NameValueHeaderValue("no-cache")); - 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"); + response.Content.Headers.Expires.Should().BeBefore(DateTime.UtcNow); } } }