From 73fb216e6f6365e07fd5770b105a33e0ab03d18c Mon Sep 17 00:00:00 2001 From: Taloth Saldono Date: Wed, 10 Aug 2016 20:45:48 +0200 Subject: [PATCH] New: Added CAPTCHA support to Rarbg. --- .../Pipelines/RequestLoggingPipeline.cs | 2 +- src/NzbDrone.Api/ProviderModuleBase.cs | 8 +- .../Http/Dispatchers/CurlHttpDispatcher.cs | 2 +- .../Http/Dispatchers/ManagedHttpDispatcher.cs | 2 +- src/NzbDrone.Common/Http/HttpRequest.cs | 1 + .../Http/HttpRequestBuilder.cs | 2 + src/NzbDrone.Common/Http/UserAgentBuilder.cs | 6 +- .../Annotations/FieldDefinitionAttribute.cs | 3 +- .../Download/DownloadClientBase.cs | 2 +- .../CloudFlare/CloudFlareCaptchaException.cs | 19 ++++ .../CloudFlare/CloudFlareCaptchaRequest.cs | 15 +++ .../CloudFlare/CloudFlareHttpInterceptor.cs | 56 ++++++++++ src/NzbDrone.Core/Indexers/HttpIndexerBase.cs | 7 +- src/NzbDrone.Core/Indexers/IndexerBase.cs | 3 +- src/NzbDrone.Core/Indexers/Rarbg/Rarbg.cs | 78 +++++++++++++ .../Indexers/Rarbg/RarbgRequestGenerator.cs | 6 + .../Indexers/Rarbg/RarbgSettings.cs | 3 + .../Indexers/Rarbg/RarbgTokenProvider.cs | 13 ++- src/NzbDrone.Core/Metadata/MetadataBase.cs | 2 +- .../Notifications/NotificationBase.cs | 2 +- .../Notifications/Twitter/OAuthToken.cs | 9 ++ .../Notifications/Twitter/Twitter.cs | 47 +++++--- .../Notifications/Twitter/TwitterService.cs | 6 +- .../Notifications/Twitter/TwitterSettings.cs | 5 + src/NzbDrone.Core/NzbDrone.Core.csproj | 5 + src/NzbDrone.Core/ThingiProvider/IProvider.cs | 2 +- .../ThingiProvider/IProviderFactory.cs | 2 +- .../ThingiProvider/ProviderFactory.cs | 4 +- .../NzbDroneValidationExtensions.cs | 24 ++++ src/UI/Form/ActionTemplate.hbs | 2 +- src/UI/Form/CaptchaTemplate.hbs | 14 +++ src/UI/Form/FormBuilder.js | 4 + .../Settings/Indexers/Edit/IndexerEditView.js | 76 ++++++++++++- .../Edit/NotificationEditView.js | 51 ++++++--- src/UI/Settings/ProviderSettingsModelBase.js | 103 ++++-------------- src/UI/jQuery/jquery.validation.js | 2 + 36 files changed, 458 insertions(+), 130 deletions(-) create mode 100644 src/NzbDrone.Core/Http/CloudFlare/CloudFlareCaptchaException.cs create mode 100644 src/NzbDrone.Core/Http/CloudFlare/CloudFlareCaptchaRequest.cs create mode 100644 src/NzbDrone.Core/Http/CloudFlare/CloudFlareHttpInterceptor.cs create mode 100644 src/NzbDrone.Core/Notifications/Twitter/OAuthToken.cs create mode 100644 src/NzbDrone.Core/Validation/NzbDroneValidationExtensions.cs create mode 100644 src/UI/Form/CaptchaTemplate.hbs diff --git a/src/NzbDrone.Api/Extensions/Pipelines/RequestLoggingPipeline.cs b/src/NzbDrone.Api/Extensions/Pipelines/RequestLoggingPipeline.cs index 470e7d18c..766fa886d 100644 --- a/src/NzbDrone.Api/Extensions/Pipelines/RequestLoggingPipeline.cs +++ b/src/NzbDrone.Api/Extensions/Pipelines/RequestLoggingPipeline.cs @@ -81,7 +81,7 @@ namespace NzbDrone.Api.Extensions.Pipelines { if (request.Url.Query.IsNotNullOrWhiteSpace()) { - return string.Concat(request.Url.Path, request.Url.Query); + return string.Concat(request.Url.Path, "?", request.Url.Query); } else { diff --git a/src/NzbDrone.Api/ProviderModuleBase.cs b/src/NzbDrone.Api/ProviderModuleBase.cs index a018a0744..75560ef42 100644 --- a/src/NzbDrone.Api/ProviderModuleBase.cs +++ b/src/NzbDrone.Api/ProviderModuleBase.cs @@ -27,7 +27,7 @@ namespace NzbDrone.Api Get["schema"] = x => GetTemplates(); Post["test"] = x => Test(ReadResourceFromRequest(true)); - Post["connectData/{stage}"] = x => ConnectData(x.stage, ReadResourceFromRequest(true)); + Post["action/{action}"] = x => RequestAction(x.action, ReadResourceFromRequest(true)); GetResourceAll = GetAll; GetResourceById = GetProviderById; @@ -183,13 +183,13 @@ namespace NzbDrone.Api } - private Response ConnectData(string stage, TProviderResource providerResource) + private Response RequestAction(string action, TProviderResource providerResource) { var providerDefinition = GetDefinition(providerResource, true, false); - if (!providerDefinition.Enable) return "{}"; + var query = ((IDictionary)Request.Query.ToDictionary()).ToDictionary(k => k.Key, k => k.Value.ToString()); - object data = _providerFactory.ConnectData(providerDefinition, stage, (IDictionary) Request.Query.ToDictionary()); + var data = _providerFactory.RequestAction(providerDefinition, action, query); Response resp = JsonConvert.SerializeObject(data); resp.ContentType = "application/json"; return resp; diff --git a/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs index 72f0cc30f..0399dc914 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs @@ -104,7 +104,7 @@ namespace NzbDrone.Common.Http.Dispatchers default: throw new NotSupportedException(string.Format("HttpCurl method {0} not supported", request.Method)); } - curlEasy.UserAgent = UserAgentBuilder.UserAgent; + curlEasy.UserAgent = request.UseSimplifiedUserAgent ? UserAgentBuilder.UserAgentSimplified : UserAgentBuilder.UserAgent; ; curlEasy.FollowLocation = request.AllowAutoRedirect; if (request.RequestTimeout != TimeSpan.Zero) diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index b1802696a..4cd2a73bc 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs @@ -26,7 +26,7 @@ namespace NzbDrone.Common.Http.Dispatchers webRequest.AutomaticDecompression = DecompressionMethods.GZip; webRequest.Method = request.Method.ToString(); - webRequest.UserAgent = UserAgentBuilder.UserAgent; + webRequest.UserAgent = request.UseSimplifiedUserAgent ? UserAgentBuilder.UserAgentSimplified : UserAgentBuilder.UserAgent; webRequest.KeepAlive = request.ConnectionKeepAlive; webRequest.AllowAutoRedirect = request.AllowAutoRedirect; webRequest.CookieContainer = cookies; diff --git a/src/NzbDrone.Common/Http/HttpRequest.cs b/src/NzbDrone.Common/Http/HttpRequest.cs index 471cf19e8..729d082c1 100644 --- a/src/NzbDrone.Common/Http/HttpRequest.cs +++ b/src/NzbDrone.Common/Http/HttpRequest.cs @@ -34,6 +34,7 @@ namespace NzbDrone.Common.Http public byte[] ContentData { get; set; } public string ContentSummary { get; set; } public bool SuppressHttpError { get; set; } + public bool UseSimplifiedUserAgent { get; set; } public bool AllowAutoRedirect { get; set; } public bool ConnectionKeepAlive { get; set; } public bool LogResponseContent { get; set; } diff --git a/src/NzbDrone.Common/Http/HttpRequestBuilder.cs b/src/NzbDrone.Common/Http/HttpRequestBuilder.cs index c3ed3d296..95d7b5c10 100644 --- a/src/NzbDrone.Common/Http/HttpRequestBuilder.cs +++ b/src/NzbDrone.Common/Http/HttpRequestBuilder.cs @@ -19,6 +19,7 @@ namespace NzbDrone.Common.Http public Dictionary Segments { get; private set; } public HttpHeader Headers { get; private set; } public bool SuppressHttpError { get; set; } + public bool UseSimplifiedUserAgent { get; set; } public bool AllowAutoRedirect { get; set; } public bool ConnectionKeepAlive { get; set; } public bool LogResponseContent { get; set; } @@ -99,6 +100,7 @@ namespace NzbDrone.Common.Http { request.Method = Method; request.SuppressHttpError = SuppressHttpError; + request.UseSimplifiedUserAgent = UseSimplifiedUserAgent; request.AllowAutoRedirect = AllowAutoRedirect; request.ConnectionKeepAlive = ConnectionKeepAlive; request.LogResponseContent = LogResponseContent; diff --git a/src/NzbDrone.Common/Http/UserAgentBuilder.cs b/src/NzbDrone.Common/Http/UserAgentBuilder.cs index c4ae7380d..a9b76bcd0 100644 --- a/src/NzbDrone.Common/Http/UserAgentBuilder.cs +++ b/src/NzbDrone.Common/Http/UserAgentBuilder.cs @@ -6,12 +6,16 @@ namespace NzbDrone.Common.Http public static class UserAgentBuilder { public static string UserAgent { get; private set; } + public static string UserAgentSimplified { get; private set; } static UserAgentBuilder() { - UserAgent = string.Format("Sonarr/{0} ({1} {2}) ", + UserAgent = string.Format("Sonarr/{0} ({1} {2})", BuildInfo.Version, OsInfo.Os, OsInfo.Version.ToString(2)); + + UserAgentSimplified = string.Format("Sonarr/{0}", + BuildInfo.Version.ToString(2)); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index 5181ee68d..85b9b044c 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -30,6 +30,7 @@ namespace NzbDrone.Core.Annotations Hidden, Tag, Action, - Url + Url, + Captcha } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/DownloadClientBase.cs b/src/NzbDrone.Core/Download/DownloadClientBase.cs index d8d80479a..a7d85f2f6 100644 --- a/src/NzbDrone.Core/Download/DownloadClientBase.cs +++ b/src/NzbDrone.Core/Download/DownloadClientBase.cs @@ -50,7 +50,7 @@ namespace NzbDrone.Core.Download public ProviderDefinition Definition { get; set; } - public object ConnectData(string stage, IDictionary query) { return null; } + public virtual object RequestAction(string action, IDictionary query) { return null; } protected TSettings Settings { diff --git a/src/NzbDrone.Core/Http/CloudFlare/CloudFlareCaptchaException.cs b/src/NzbDrone.Core/Http/CloudFlare/CloudFlareCaptchaException.cs new file mode 100644 index 000000000..1654535a8 --- /dev/null +++ b/src/NzbDrone.Core/Http/CloudFlare/CloudFlareCaptchaException.cs @@ -0,0 +1,19 @@ +using NzbDrone.Common.Exceptions; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.Http.CloudFlare +{ + public class CloudFlareCaptchaException : NzbDroneException + { + public HttpResponse Response { get; set; } + + public CloudFlareCaptchaRequest CaptchaRequest { get; set; } + + public CloudFlareCaptchaException(HttpResponse response, CloudFlareCaptchaRequest captchaRequest) + : base("Unable to access {0}, blocked by CloudFlare CAPTCHA. Likely due to shared-IP VPN.", response.Request.Url.Host) + { + Response = response; + CaptchaRequest = captchaRequest; + } + } +} diff --git a/src/NzbDrone.Core/Http/CloudFlare/CloudFlareCaptchaRequest.cs b/src/NzbDrone.Core/Http/CloudFlare/CloudFlareCaptchaRequest.cs new file mode 100644 index 000000000..332a01059 --- /dev/null +++ b/src/NzbDrone.Core/Http/CloudFlare/CloudFlareCaptchaRequest.cs @@ -0,0 +1,15 @@ +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.Http.CloudFlare +{ + public class CloudFlareCaptchaRequest + { + public string Host { get; set; } + public string SiteKey { get; set; } + + public string Ray { get; set; } + public string SecretToken { get; set; } + + public HttpUri ResponseUrl { get; set; } + } +} diff --git a/src/NzbDrone.Core/Http/CloudFlare/CloudFlareHttpInterceptor.cs b/src/NzbDrone.Core/Http/CloudFlare/CloudFlareHttpInterceptor.cs new file mode 100644 index 000000000..d4377d399 --- /dev/null +++ b/src/NzbDrone.Core/Http/CloudFlare/CloudFlareHttpInterceptor.cs @@ -0,0 +1,56 @@ +using System.Net; +using System.Text.RegularExpressions; +using NLog; +using NzbDrone.Common.Exceptions; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.Http.CloudFlare +{ + public class CloudFlareHttpInterceptor : IHttpRequestInterceptor + { + private readonly Logger _logger; + + private const string _cloudFlareChallengeScript = "cdn-cgi/scripts/cf.challenge.js"; + private static readonly Regex _cloudFlareRegex = new Regex(@"data-ray=""(?[\w-_]+)"".*?data-sitekey=""(?[\w-_]+)"".*?data-stoken=""(?[\w-_]+)""", RegexOptions.Compiled); + + public CloudFlareHttpInterceptor(Logger logger) + { + _logger = logger; + } + + public HttpRequest PreRequest(HttpRequest request) + { + return request; + } + + public HttpResponse PostResponse(HttpResponse response) + { + if (response.StatusCode == HttpStatusCode.Forbidden && response.Content.Contains(_cloudFlareChallengeScript)) + { + _logger.Error("CloudFlare CAPTCHA block on {0}", response.Request.Url); + throw new CloudFlareCaptchaException(response, CreateCaptchaRequest(response)); + } + + return response; + } + + private CloudFlareCaptchaRequest CreateCaptchaRequest(HttpResponse response) + { + var match = _cloudFlareRegex.Match(response.Content); + + if (!match.Success) + { + return null; + } + + return new CloudFlareCaptchaRequest + { + Host = response.Request.Url.Host, + SiteKey = match.Groups["SiteKey"].Value, + Ray = match.Groups["Ray"].Value, + SecretToken = match.Groups["SecretToken"].Value, + ResponseUrl = response.Request.Url + new HttpUri("/cdn-cgi/l/chk_captcha") + }; + } + } +} diff --git a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs index 6eb8c3b92..94b0a1e00 100644 --- a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs @@ -8,6 +8,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Common.TPL; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Http.CloudFlare; using NzbDrone.Core.Indexers.Exceptions; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser; @@ -21,7 +22,7 @@ namespace NzbDrone.Core.Indexers { protected const int MaxNumResultsPerQuery = 1000; - private readonly IHttpClient _httpClient; + protected readonly IHttpClient _httpClient; public override bool SupportsRss { get { return true; } } public override bool SupportsSearch { get { return true; } } @@ -313,6 +314,10 @@ namespace NzbDrone.Core.Indexers { _logger.Warn("Request limit reached"); } + catch (CloudFlareCaptchaException) + { + return new ValidationFailure("CaptchaToken", "Site protected by CloudFlare CAPTCHA. Valid CAPTCHA token required."); + } catch (UnsupportedFeedException ex) { _logger.Warn(ex, "Indexer feed is not supported"); diff --git a/src/NzbDrone.Core/Indexers/IndexerBase.cs b/src/NzbDrone.Core/Indexers/IndexerBase.cs index 16bcc6870..17a73c398 100644 --- a/src/NzbDrone.Core/Indexers/IndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/IndexerBase.cs @@ -65,7 +65,8 @@ namespace NzbDrone.Core.Indexers } public virtual ProviderDefinition Definition { get; set; } - public object ConnectData(string stage, IDictionary query) { return null; } + + public virtual object RequestAction(string action, IDictionary query) { return null; } protected TSettings Settings { diff --git a/src/NzbDrone.Core/Indexers/Rarbg/Rarbg.cs b/src/NzbDrone.Core/Indexers/Rarbg/Rarbg.cs index a24246b5e..f300caf96 100644 --- a/src/NzbDrone.Core/Indexers/Rarbg/Rarbg.cs +++ b/src/NzbDrone.Core/Indexers/Rarbg/Rarbg.cs @@ -1,8 +1,13 @@ using System; +using System.Collections.Generic; using NLog; +using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Http.CloudFlare; using NzbDrone.Core.Parser; +using NzbDrone.Core.Validation; namespace NzbDrone.Core.Indexers.Rarbg { @@ -30,5 +35,78 @@ namespace NzbDrone.Core.Indexers.Rarbg { return new RarbgParser(); } + + public override object RequestAction(string action, IDictionary query) + { + if (action == "checkCaptcha") + { + Settings.Validate().Filter("BaseUrl").ThrowOnError(); + + try + { + var request = new HttpRequestBuilder(Settings.BaseUrl.Trim('/')) + .Resource("/pubapi_v2.php?get_token=get_token") + .Accept(HttpAccept.Json) + .Build(); + + _httpClient.Get(request); + } + catch (CloudFlareCaptchaException ex) + { + return new + { + captchaRequest = new + { + host = ex.CaptchaRequest.Host, + ray = ex.CaptchaRequest.Ray, + siteKey = ex.CaptchaRequest.SiteKey, + secretToken = ex.CaptchaRequest.SecretToken, + responseUrl = ex.CaptchaRequest.ResponseUrl.FullUri, + } + }; + } + + return new + { + captchaToken = "" + }; + } + else if (action == "getCaptchaCookie") + { + if (query["responseUrl"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam responseUrl invalid."); + } + + if (query["ray"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam ray invalid."); + } + + if (query["captchaResponse"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam captchaResponse invalid."); + } + + var request = new HttpRequestBuilder(query["responseUrl"]) + .AddQueryParam("id", query["ray"]) + .AddQueryParam("g-recaptcha-response", query["captchaResponse"]) + .Build(); + + request.UseSimplifiedUserAgent = true; + request.AllowAutoRedirect = false; + + var response = _httpClient.Get(request); + + var cfClearanceCookie = response.GetCookies()["cf_clearance"]; + + return new + { + captchaToken = cfClearanceCookie + }; + } + + return new { }; + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/Rarbg/RarbgRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Rarbg/RarbgRequestGenerator.cs index 60bd2ed2e..503dba0be 100644 --- a/src/NzbDrone.Core/Indexers/Rarbg/RarbgRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Rarbg/RarbgRequestGenerator.cs @@ -79,6 +79,12 @@ namespace NzbDrone.Core.Indexers.Rarbg .Resource("/pubapi_v2.php") .Accept(HttpAccept.Json); + if (Settings.CaptchaToken.IsNotNullOrWhiteSpace()) + { + requestBuilder.UseSimplifiedUserAgent = true; + requestBuilder.SetCookie("cf_clearance", Settings.CaptchaToken); + } + requestBuilder.AddQueryParam("mode", mode); if (tvdbId.HasValue) diff --git a/src/NzbDrone.Core/Indexers/Rarbg/RarbgSettings.cs b/src/NzbDrone.Core/Indexers/Rarbg/RarbgSettings.cs index 3d3f33e35..2bb99a53a 100644 --- a/src/NzbDrone.Core/Indexers/Rarbg/RarbgSettings.cs +++ b/src/NzbDrone.Core/Indexers/Rarbg/RarbgSettings.cs @@ -29,6 +29,9 @@ namespace NzbDrone.Core.Indexers.Rarbg [FieldDefinition(1, Type = FieldType.Checkbox, Label = "Ranked Only", HelpText = "Only include ranked results.")] public bool RankedOnly { get; set; } + + [FieldDefinition(2, Type = FieldType.Captcha, Label = "CAPTCHA Token", HelpText = "CAPTCHA Clearance token used to handle CloudFlare Anti-DDOS measures on shared-ip VPNs.")] + public string CaptchaToken { get; set; } public NzbDroneValidationResult Validate() { diff --git a/src/NzbDrone.Core/Indexers/Rarbg/RarbgTokenProvider.cs b/src/NzbDrone.Core/Indexers/Rarbg/RarbgTokenProvider.cs index dd7634429..f7ed23bcb 100644 --- a/src/NzbDrone.Core/Indexers/Rarbg/RarbgTokenProvider.cs +++ b/src/NzbDrone.Core/Indexers/Rarbg/RarbgTokenProvider.cs @@ -2,6 +2,7 @@ using System; using Newtonsoft.Json.Linq; using NLog; using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; namespace NzbDrone.Core.Indexers.Rarbg @@ -28,9 +29,17 @@ namespace NzbDrone.Core.Indexers.Rarbg { return _tokenCache.Get(settings.BaseUrl, () => { - var url = settings.BaseUrl.Trim('/') + "/pubapi_v2.php?get_token=get_token"; + var requestBuilder = new HttpRequestBuilder(settings.BaseUrl.Trim('/')) + .Resource("/pubapi_v2.php?get_token=get_token") + .Accept(HttpAccept.Json); - var response = _httpClient.Get(new HttpRequest(url, HttpAccept.Json)); + if (settings.CaptchaToken.IsNotNullOrWhiteSpace()) + { + requestBuilder.UseSimplifiedUserAgent = true; + requestBuilder.SetCookie("cf_clearance", settings.CaptchaToken); + } + + var response = _httpClient.Get(requestBuilder.Build()); return response.Resource["token"].ToString(); }, TimeSpan.FromMinutes(14.0)); diff --git a/src/NzbDrone.Core/Metadata/MetadataBase.cs b/src/NzbDrone.Core/Metadata/MetadataBase.cs index 37f5666cf..ce6b7ad87 100644 --- a/src/NzbDrone.Core/Metadata/MetadataBase.cs +++ b/src/NzbDrone.Core/Metadata/MetadataBase.cs @@ -52,7 +52,7 @@ namespace NzbDrone.Core.Metadata public abstract List SeasonImages(Series series, Season season); public abstract List EpisodeImages(Series series, EpisodeFile episodeFile); - public object ConnectData(string stage, IDictionary query) { return null; } + public virtual object RequestAction(string action, IDictionary query) { return null; } protected TSettings Settings { diff --git a/src/NzbDrone.Core/Notifications/NotificationBase.cs b/src/NzbDrone.Core/Notifications/NotificationBase.cs index 255f5f1e0..49d28f169 100644 --- a/src/NzbDrone.Core/Notifications/NotificationBase.cs +++ b/src/NzbDrone.Core/Notifications/NotificationBase.cs @@ -61,7 +61,7 @@ namespace NzbDrone.Core.Notifications return GetType().Name; } - public virtual object ConnectData(string stage, IDictionary query) { return null; } + public virtual object RequestAction(string action, IDictionary query) { return null; } } } diff --git a/src/NzbDrone.Core/Notifications/Twitter/OAuthToken.cs b/src/NzbDrone.Core/Notifications/Twitter/OAuthToken.cs new file mode 100644 index 000000000..dde4bc1aa --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Twitter/OAuthToken.cs @@ -0,0 +1,9 @@ + +namespace NzbDrone.Core.Notifications.Twitter +{ + public class OAuthToken + { + public string AccessToken { get; set; } + public string AccessTokenSecret { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Twitter/Twitter.cs b/src/NzbDrone.Core/Notifications/Twitter/Twitter.cs index c925c2bc6..40a0c74c0 100644 --- a/src/NzbDrone.Core/Notifications/Twitter/Twitter.cs +++ b/src/NzbDrone.Core/Notifications/Twitter/Twitter.cs @@ -1,7 +1,9 @@ using System.Collections.Generic; using FluentValidation.Results; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Exceptions; using NzbDrone.Core.Tv; +using NzbDrone.Core.Validation; namespace NzbDrone.Core.Notifications.Twitter { @@ -34,26 +36,45 @@ namespace NzbDrone.Core.Notifications.Twitter { } - public override object ConnectData(string stage, IDictionary query) + public override object RequestAction(string action, IDictionary query) { - if (stage == "step1") + if (action == "startOAuth") { - return new + Settings.Validate().Filter("ConsumerKey", "ConsumerSecret").ThrowOnError(); + + if (query["callbackUrl"].IsNullOrWhiteSpace()) { - nextStep = "step2", - action = "openWindow", - url = _twitterService.GetOAuthRedirect(query["consumerKey"].ToString(), query["consumerSecret"].ToString(), query["callbackUrl"].ToString()) - }; - } - else if (stage == "step2") - { + throw new BadRequestException("QueryParam callbackUrl invalid."); + } + + var oauthRedirectUrl = _twitterService.GetOAuthRedirect(Settings.ConsumerKey, Settings.ConsumerSecret, query["callbackUrl"]); return new { - action = "updateFields", - fields = _twitterService.GetOAuthToken(query["consumerKey"].ToString(), query["consumerSecret"].ToString(), query["oauth_token"].ToString(), query["oauth_verifier"].ToString()) + oauthUrl = oauthRedirectUrl }; } - return new {}; + else if (action == "getOAuthToken") + { + Settings.Validate().Filter("ConsumerKey", "ConsumerSecret").ThrowOnError(); + + if (query["oauth_token"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam oauth_token invalid."); + } + + if (query["oauth_verifier"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam oauth_verifier invalid."); + } + + var oauthToken = _twitterService.GetOAuthToken(Settings.ConsumerKey, Settings.ConsumerSecret, query["oauth_token"], query["oauth_verifier"]); + return new + { + accessToken = oauthToken.AccessToken, + accessTokenSecret = oauthToken.AccessTokenSecret + }; + } + return new { }; } public override string Name diff --git a/src/NzbDrone.Core/Notifications/Twitter/TwitterService.cs b/src/NzbDrone.Core/Notifications/Twitter/TwitterService.cs index d60f6c435..6c894b228 100644 --- a/src/NzbDrone.Core/Notifications/Twitter/TwitterService.cs +++ b/src/NzbDrone.Core/Notifications/Twitter/TwitterService.cs @@ -16,7 +16,7 @@ namespace NzbDrone.Core.Notifications.Twitter void SendNotification(string message, TwitterSettings settings); ValidationFailure Test(TwitterSettings settings); string GetOAuthRedirect(string consumerKey, string consumerSecret, string callbackUrl); - object GetOAuthToken(string consumerKey, string consumerSecret, string oauthToken, string oauthVerifier); + OAuthToken GetOAuthToken(string consumerKey, string consumerSecret, string oauthToken, string oauthVerifier); } public class TwitterService : ITwitterService @@ -43,14 +43,14 @@ namespace NzbDrone.Core.Notifications.Twitter return HttpUtility.ParseQueryString(response.Content); } - public object GetOAuthToken(string consumerKey, string consumerSecret, string oauthToken, string oauthVerifier) + 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); - return new + return new OAuthToken { AccessToken = qscoll["oauth_token"], AccessTokenSecret = qscoll["oauth_token_secret"] diff --git a/src/NzbDrone.Core/Notifications/Twitter/TwitterSettings.cs b/src/NzbDrone.Core/Notifications/Twitter/TwitterSettings.cs index 08f83b007..36b18285a 100644 --- a/src/NzbDrone.Core/Notifications/Twitter/TwitterSettings.cs +++ b/src/NzbDrone.Core/Notifications/Twitter/TwitterSettings.cs @@ -1,4 +1,5 @@ using FluentValidation; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -19,6 +20,10 @@ namespace NzbDrone.Core.Notifications.Twitter RuleFor(c => c.DirectMessage).Equal(true) .WithMessage("Using Direct Messaging is recommended, or use a private account.") .AsWarning(); + + RuleFor(c => c.AuthorizeNotification).Empty() + .When(c => c.AccessToken.IsNullOrWhiteSpace() || c.AccessTokenSecret.IsNullOrWhiteSpace()) + .WithMessage("Authenticate app."); } } diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index e4e8b1b48..8612ab78c 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -527,6 +527,9 @@ + + + @@ -810,6 +813,7 @@ + @@ -1054,6 +1058,7 @@ + diff --git a/src/NzbDrone.Core/ThingiProvider/IProvider.cs b/src/NzbDrone.Core/ThingiProvider/IProvider.cs index 88ee2881c..386d2bfaf 100644 --- a/src/NzbDrone.Core/ThingiProvider/IProvider.cs +++ b/src/NzbDrone.Core/ThingiProvider/IProvider.cs @@ -12,6 +12,6 @@ namespace NzbDrone.Core.ThingiProvider IEnumerable DefaultDefinitions { get; } ProviderDefinition Definition { get; set; } ValidationResult Test(); - object ConnectData(string stage, IDictionary query); + object RequestAction(string stage, IDictionary query); } } \ No newline at end of file diff --git a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs index 22d7771c7..e82e4fc2f 100644 --- a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs @@ -20,6 +20,6 @@ namespace NzbDrone.Core.ThingiProvider void SetProviderCharacteristics(TProvider provider, TProviderDefinition definition); TProvider GetInstance(TProviderDefinition definition); ValidationResult Test(TProviderDefinition definition); - object ConnectData(TProviderDefinition definition, string stage, IDictionary query ); + object RequestAction(TProviderDefinition definition, string action, IDictionary query); } } \ No newline at end of file diff --git a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs index 6aa20c4d1..0c64aa994 100644 --- a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs @@ -81,9 +81,9 @@ namespace NzbDrone.Core.ThingiProvider return GetInstance(definition).Test(); } - public object ConnectData(TProviderDefinition definition, string stage, IDictionary query) + public object RequestAction(TProviderDefinition definition, string action, IDictionary query) { - return GetInstance(definition).ConnectData(stage, query); + return GetInstance(definition).RequestAction(action, query); } public List GetAvailableProviders() diff --git a/src/NzbDrone.Core/Validation/NzbDroneValidationExtensions.cs b/src/NzbDrone.Core/Validation/NzbDroneValidationExtensions.cs new file mode 100644 index 000000000..6f7eb4fea --- /dev/null +++ b/src/NzbDrone.Core/Validation/NzbDroneValidationExtensions.cs @@ -0,0 +1,24 @@ +using System; +using System.Linq; +using FluentValidation; + +namespace NzbDrone.Core.Validation +{ + public static class NzbDroneValidationExtensions + { + public static NzbDroneValidationResult Filter(this NzbDroneValidationResult result, params string[] fields) + { + var failures = result.Failures.Where(v => fields.Contains(v.PropertyName)).ToArray(); + + return new NzbDroneValidationResult(failures); + } + + public static void ThrowOnError(this NzbDroneValidationResult result) + { + if (!result.IsValid) + { + throw new ValidationException(result.Errors); + } + } + } +} diff --git a/src/UI/Form/ActionTemplate.hbs b/src/UI/Form/ActionTemplate.hbs index 27ffcb538..ecb861f99 100644 --- a/src/UI/Form/ActionTemplate.hbs +++ b/src/UI/Form/ActionTemplate.hbs @@ -2,6 +2,6 @@
- +
diff --git a/src/UI/Form/CaptchaTemplate.hbs b/src/UI/Form/CaptchaTemplate.hbs new file mode 100644 index 000000000..29ef3edcf --- /dev/null +++ b/src/UI/Form/CaptchaTemplate.hbs @@ -0,0 +1,14 @@ +
+ + +
+
+ + +
+
+ + + + +
diff --git a/src/UI/Form/FormBuilder.js b/src/UI/Form/FormBuilder.js index 0a47a26b6..eef48eb91 100644 --- a/src/UI/Form/FormBuilder.js +++ b/src/UI/Form/FormBuilder.js @@ -49,6 +49,10 @@ var _fieldBuilder = function(field) { return _templateRenderer.call(field, 'Form/ActionTemplate'); } + if (field.type === 'captcha') { + return _templateRenderer.call(field, 'Form/CaptchaTemplate'); + } + return _templateRenderer.call(field, 'Form/TextboxTemplate'); }; diff --git a/src/UI/Settings/Indexers/Edit/IndexerEditView.js b/src/UI/Settings/Indexers/Edit/IndexerEditView.js index 3334c658e..616c863a7 100644 --- a/src/UI/Settings/Indexers/Edit/IndexerEditView.js +++ b/src/UI/Settings/Indexers/Edit/IndexerEditView.js @@ -1,3 +1,5 @@ +var _ = require('underscore'); +var $ = require('jquery'); var vent = require('vent'); var Marionette = require('marionette'); var DeleteView = require('../Delete/IndexerDeleteView'); @@ -12,7 +14,8 @@ var view = Marionette.ItemView.extend({ template : 'Settings/Indexers/Edit/IndexerEditViewTemplate', events : { - 'click .x-back' : '_back' + 'click .x-back' : '_back', + 'click .x-captcha-refresh' : '_onRefreshCaptcha' }, _deleteView : DeleteView, @@ -38,6 +41,77 @@ var view = Marionette.ItemView.extend({ } require('../Add/IndexerSchemaModal').open(this.targetCollection); + }, + + _onRefreshCaptcha : function(event) { + var self = this; + + var target = $(event.target).parents('.input-group'); + + this.ui.indicator.show(); + + this.model.requestAction("checkCaptcha") + .then(function(result) { + if (!result.captchaRequest) { + self.model.setFieldValue('CaptchaToken', ''); + + return result; + } + + return self._showCaptcha(target, result.captchaRequest); + }) + .always(function() { + self.ui.indicator.hide(); + }); + }, + + _showCaptcha : function(target, captchaRequest) { + var self = this; + + var widget = $('
').insertAfter(target); + + return this._loadRecaptchaWidget(widget[0], captchaRequest.siteKey, captchaRequest.secretToken) + .then(function(captchaResponse) { + target.parents('.form-group').removeAllErrors(); + widget.remove(); + + var queryParams = { + responseUrl : captchaRequest.responseUrl, + ray : captchaRequest.ray, + captchaResponse: captchaResponse + }; + + return self.model.requestAction("getCaptchaCookie", queryParams); + }) + .then(function(response) { + self.model.setFieldValue('CaptchaToken', response.captchaToken); + }); + }, + + _loadRecaptchaWidget : function(widget, sitekey, stoken) { + var promise = $.Deferred(); + + var renderWidget = function() { + window.grecaptcha.render(widget, { + 'sitekey' : sitekey, + 'stoken' : stoken, + 'callback' : promise.resolve + }); + }; + + if (window.grecaptcha) { + renderWidget(); + } else { + window.grecaptchaLoadCallback = function() { + delete window.grecaptchaLoadCallback; + renderWidget(); + }; + + $.getScript('https://www.google.com/recaptcha/api.js?onload=grecaptchaLoadCallback&render=explicit') + .fail(function() { promise.reject(); }); + } + + return promise; } }); diff --git a/src/UI/Settings/Notifications/Edit/NotificationEditView.js b/src/UI/Settings/Notifications/Edit/NotificationEditView.js index 5f722e1b8..5e626ce90 100644 --- a/src/UI/Settings/Notifications/Edit/NotificationEditView.js +++ b/src/UI/Settings/Notifications/Edit/NotificationEditView.js @@ -1,4 +1,5 @@ var _ = require('underscore'); +var $ = require('jquery'); var vent = require('vent'); var Marionette = require('marionette'); var DeleteView = require('../Delete/NotificationDeleteView'); @@ -90,21 +91,45 @@ var view = Marionette.ItemView.extend({ this.ui.indicator.show(); var self = this; - var callbackUrl = window.location.origin + '/oauth.html'; - var fields = this.model.get('fields'); - var consumerKeyObj = _.findWhere(fields, { name: 'ConsumerKey' }); - var consumerSecretObj = _.findWhere(fields, { name: 'ConsumerSecret' }); - var queryParams = { - callbackUrl: callbackUrl, - consumerKey: (consumerKeyObj ? consumerKeyObj.value : ''), - consumerSecret: (consumerSecretObj ? consumerSecretObj.value : '') - }; - - var promise = this.model.connectData(this.ui.authorizedNotificationButton.data('value'), queryParams); + var promise = this.model.requestAction('startOAuth', { callbackUrl: window.location.origin + '/oauth.html' }) + .then(function(response) { + return self._showOAuthWindow(response.oauthUrl); + }) + .then(function(responseQueryParams) { + return self.model.requestAction('getOAuthToken', responseQueryParams); + }) + .then(function(response) { + self.model.setFieldValue('AccessToken', response.accessToken); + self.model.setFieldValue('AccessTokenSecret', response.accessTokenSecret); + }); + promise.always(function() { - self.ui.indicator.hide(); - }); + self.ui.indicator.hide(); + }); + }, + + _showOAuthWindow : function(oauthUrl) { + var promise = $.Deferred(); + + window.open(oauthUrl); + var selfWindow = window; + selfWindow.onCompleteOauth = function(query, callback) { + delete selfWindow.onCompleteOauth; + + var queryParams = {}; + var splitQuery = query.substring(1).split('&'); + _.each(splitQuery, function (param) { + var paramSplit = param.split('='); + queryParams[paramSplit[0]] = paramSplit[1]; + }); + + callback(); + + promise.resolve(queryParams); + }; + + return promise; } }); diff --git a/src/UI/Settings/ProviderSettingsModelBase.js b/src/UI/Settings/ProviderSettingsModelBase.js index 65c7cdac4..674aba4e5 100644 --- a/src/UI/Settings/ProviderSettingsModelBase.js +++ b/src/UI/Settings/ProviderSettingsModelBase.js @@ -4,93 +4,38 @@ var DeepModel = require('backbone.deepmodel'); var Messenger = require('../Shared/Messenger'); module.exports = DeepModel.extend({ - connectData : function(action, initialQueryParams) { + + getFieldValue : function(name) { + var index = _.indexOf(_.pluck(this.get('fields'), 'name'), name); + return this.get('fields.' + index + '.value'); + }, + + setFieldValue : function(name, value) { + var index = _.indexOf(_.pluck(this.get('fields'), 'name'), name); + return this.set('fields.' + index + '.value', value); + }, + + requestAction : function(action, queryParams) { var self = this; - this.trigger('connect:sync'); + this.trigger('validation:sync'); - var promise = $.Deferred(); - - var callAction = function(action, queryParams) { - - if (queryParams) { - action = action + '?' + $.param(queryParams, true); - } - - var params = { - url : self.collection.url + '/connectData/' + action, - contentType : 'application/json', - data : JSON.stringify(self.toJSON()), - type : 'POST', - isValidatedCall : true - }; - - var ajaxPromise = $.ajax(params); - ajaxPromise.fail(promise.reject); - - ajaxPromise.success(function(response) { - if (response.action) - { - if (response.action === 'openWindow') - { - window.open(response.url); - var selfWindow = window; - - selfWindow.onCompleteOauth = function(query, callback) { - delete selfWindow.onCompleteOauth; - - if (response.nextStep) { - var queryParams = {}; - var splitQuery = query.substring(1).split('&'); - - _.each(splitQuery, function (param) { - var paramSplit = param.split('='); - queryParams[paramSplit[0]] = paramSplit[1]; - }); - - callAction(response.nextStep, _.extend(initialQueryParams, queryParams)); - } - else { - promise.resolve(response); - } - - callback(); - }; - - return; - } - else if (response.action === 'updateFields') - { - _.each(self.get('fields'), function (value, index) { - var fieldValue = _.find(response.fields, function (field, key) { - return key === value.name; - }); - - if (fieldValue) { - self.set('fields.' + index + '.value', fieldValue); - } - }); - } - } - if (response.nextStep) { - callAction(response.nextStep, initialQueryParams); - } - else { - promise.resolve(response); - } - }); + var params = { + url : this.collection.url + '/action/' + action, + contentType : 'application/json', + data : JSON.stringify(this.toJSON()), + type : 'POST', + isValidatedCall : true }; - callAction(action, initialQueryParams); + if (queryParams) { + params.url += '?' + $.param(queryParams, true); + } - Messenger.monitor({ - promise : promise, - successMessage : 'Connecting for \'{0}\' succeeded'.format(this.get('name')), - errorMessage : 'Connecting for \'{0}\' failed'.format(this.get('name')) - }); + var promise = $.ajax(params); promise.fail(function(response) { - self.trigger('connect:failed', response); + self.trigger('validation:failed', response); }); return promise; diff --git a/src/UI/jQuery/jquery.validation.js b/src/UI/jQuery/jquery.validation.js index af8efe3a4..18cdd2f51 100644 --- a/src/UI/jQuery/jquery.validation.js +++ b/src/UI/jQuery/jquery.validation.js @@ -75,6 +75,8 @@ module.exports = function() { }; $.fn.removeAllErrors = function() { + this.removeClass('has-error'); + this.removeClass('has-warning'); this.find('.has-error').removeClass('has-error'); this.find('.has-warning').removeClass('has-warning'); this.find('.error').removeClass('error');