diff options
author | Jesse Plamondon-Willard <github@jplamondonw.com> | 2017-10-27 19:39:13 -0400 |
---|---|---|
committer | Jesse Plamondon-Willard <github@jplamondonw.com> | 2017-10-27 19:39:13 -0400 |
commit | ad5bb5b49af49c4668fd30fb2a0e606dcefe4ec0 (patch) | |
tree | e0bdd32fbfe91f7ab5e6cd3446a75b32d6e10e5c | |
parent | acbea9bfa33655048673a2292350aedb1d05a09a (diff) | |
download | SMAPI-ad5bb5b49af49c4668fd30fb2a0e606dcefe4ec0.tar.gz SMAPI-ad5bb5b49af49c4668fd30fb2a0e606dcefe4ec0.tar.bz2 SMAPI-ad5bb5b49af49c4668fd30fb2a0e606dcefe4ec0.zip |
proxy Pastebin requests through our API instead of third parties, improve error-handling (#358)
-rw-r--r-- | src/SMAPI.Web/Controllers/LogParserController.cs | 54 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs | 52 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs | 18 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs | 2 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs | 15 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/LogParser/PastebinClient.cs | 110 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs | 15 | ||||
-rw-r--r-- | src/SMAPI.Web/Startup.cs | 1 | ||||
-rw-r--r-- | src/SMAPI.Web/appsettings.json | 5 | ||||
-rw-r--r-- | src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 65 |
10 files changed, 296 insertions, 41 deletions
diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs index 4ed8898a..893d9a52 100644 --- a/src/SMAPI.Web/Controllers/LogParserController.cs +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -1,19 +1,69 @@ +using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using StardewModdingAPI.Web.Framework; +using StardewModdingAPI.Web.Framework.ConfigModels; +using StardewModdingAPI.Web.Framework.LogParser; namespace StardewModdingAPI.Web.Controllers { /// <summary>Provides a web UI and API for parsing SMAPI log files.</summary> - [Route("log")] internal class LogParserController : Controller { /********* + ** Properties + *********/ + /// <summary>The underlying Pastebin client.</summary> + private readonly PastebinClient PastebinClient; + + + /********* ** Public methods *********/ - /// <summary>Render the web UI to upload a log file.</summary> + /*** + ** Constructor + ***/ + /// <summary>Construct an instance.</summary> + /// <param name="configProvider">The log parser config settings.</param> + public LogParserController(IOptions<LogParserConfig> configProvider) + { + // init Pastebin client + LogParserConfig config = configProvider.Value; + string version = this.GetType().Assembly.GetName().Version.ToString(3); + string userAgent = string.Format(config.PastebinUserAgent, version); + this.PastebinClient = new PastebinClient(config.PastebinBaseUrl, userAgent, config.PastebinDevKey); + } + + /*** + ** Web UI + ***/ + /// <summary>Render the log parser UI.</summary> [HttpGet] + [Route("log")] public ViewResult Index() { return this.View("Index"); } + + /*** + ** JSON + ***/ + /// <summary>Fetch raw text from Pastebin.</summary> + /// <param name="id">The Pastebin paste ID.</param> + [HttpGet, Produces("application/json")] + [Route("log/fetch/{id}")] + public async Task<GetPasteResponse> GetAsync(string id) + { + return await this.PastebinClient.GetAsync(id); + } + + /// <summary>Save raw log data.</summary> + /// <param name="content">The log content to save.</param> + [HttpPost, Produces("application/json"), AllowLargePosts] + [Route("log/save")] + public async Task<SavePasteResponse> PostAsync([FromBody] string content) + { + return await this.PastebinClient.PostAsync(content); + } } } diff --git a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs new file mode 100644 index 00000000..68ead3c2 --- /dev/null +++ b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace StardewModdingAPI.Web.Framework +{ + /// <summary>A filter which increases the maximum request size for an endpoint.</summary> + /// <remarks>Derived from <a href="https://stackoverflow.com/a/38360093/262123"/>.</remarks> + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class AllowLargePostsAttribute : Attribute, IAuthorizationFilter, IOrderedFilter + { + /********* + ** Properties + *********/ + /// <summary>The underlying form options.</summary> + private readonly FormOptions FormOptions; + + + /********* + ** Accessors + *********/ + /// <summary>The attribute order.</summary> + public int Order { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public AllowLargePostsAttribute() + { + this.FormOptions = new FormOptions + { + ValueLengthLimit = 200 * 1024 * 1024 // 200MB + }; + } + + /// <summary>Called early in the filter pipeline to confirm request is authorized.</summary> + /// <param name="context">The authorisation filter context.</param> + public void OnAuthorization(AuthorizationFilterContext context) + { + IFeatureCollection features = context.HttpContext.Features; + IFormFeature formFeature = features.Get<IFormFeature>(); + + if (formFeature?.Form == null) + { + // Request form has not been read yet, so set the limits + features.Set<IFormFeature>(new FormFeature(context.HttpContext.Request, this.FormOptions)); + } + } + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs new file mode 100644 index 00000000..5cb0cf95 --- /dev/null +++ b/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Web.Framework.ConfigModels +{ + /// <summary>The config settings for the log parser.</summary> + internal class LogParserConfig + { + /********* + ** Accessors + *********/ + /// <summary>The base URL for the Pastebin API.</summary> + public string PastebinBaseUrl { get; set; } + + /// <summary>The user agent for the Pastebin API client, where {0} is the SMAPI version.</summary> + public string PastebinUserAgent { get; set; } + + /// <summary>The developer key used to authenticate with the Pastebin API.</summary> + public string PastebinDevKey { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index 03de639e..2fb5b97e 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -1,7 +1,7 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels { /// <summary>The config settings for mod update checks.</summary> - public class ModUpdateCheckConfig + internal class ModUpdateCheckConfig { /********* ** Accessors diff --git a/src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs b/src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs new file mode 100644 index 00000000..4f8794db --- /dev/null +++ b/src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.Framework.LogParser +{ + /// <summary>The response for a get-paste request.</summary> + internal class GetPasteResponse + { + /// <summary>Whether the log was successfully fetched.</summary> + public bool Success { get; set; } + + /// <summary>The fetched paste content (if <see cref="Success"/> is <c>true</c>).</summary> + public string Content { get; set; } + + /// <summary>The error message (if saving failed).</summary> + public string Error { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/LogParser/PastebinClient.cs b/src/SMAPI.Web/Framework/LogParser/PastebinClient.cs new file mode 100644 index 00000000..8536f249 --- /dev/null +++ b/src/SMAPI.Web/Framework/LogParser/PastebinClient.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Pathoschild.Http.Client; + +namespace StardewModdingAPI.Web.Framework.LogParser +{ + /// <summary>An API client for Pastebin.</summary> + internal class PastebinClient : IDisposable + { + /********* + ** Properties + *********/ + /// <summary>The underlying HTTP client.</summary> + private readonly IClient Client; + + /// <summary>The developer key used to authenticate with the Pastebin API.</summary> + private readonly string DevKey; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="baseUrl">The base URL for the Pastebin API.</param> + /// <param name="userAgent">The user agent for the API client.</param> + /// <param name="devKey">The developer key used to authenticate with the Pastebin API.</param> + public PastebinClient(string baseUrl, string userAgent, string devKey) + { + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + this.DevKey = devKey; + } + + /// <summary>Fetch a saved paste.</summary> + /// <param name="id">The paste ID.</param> + public async Task<GetPasteResponse> GetAsync(string id) + { + try + { + // get from API + string content = await this.Client + .GetAsync($"raw/{id}") + .AsString(); + + // handle Pastebin errors + if (string.IsNullOrWhiteSpace(content)) + return new GetPasteResponse { Error = "Received an empty response from Pastebin." }; + if (content.StartsWith("<!DOCTYPE")) + return new GetPasteResponse { Error = $"Received a captcha challenge from Pastebin. Please visit https://pastebin.com/{id} in a new window to solve it." }; + return new GetPasteResponse { Success = true, Content = content }; + } + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return new GetPasteResponse { Error = "There's no log with that ID." }; + } + catch (Exception ex) + { + return new GetPasteResponse { Error = ex.ToString() }; + } + } + + public async Task<SavePasteResponse> PostAsync(string content) + { + try + { + // validate + if (string.IsNullOrWhiteSpace(content)) + return new SavePasteResponse { Error = "The log content can't be empty." }; + + // post to API + string response = await this.Client + .PostAsync("api/api_post.php") + .WithBodyContent(new FormUrlEncodedContent(new Dictionary<string, string> + { + ["api_dev_key"] = "b8219d942109d1e60ebb14fbb45f06f9", + ["api_option"] = "paste", + ["api_paste_private"] = "1", + ["api_paste_code"] = content, + ["api_paste_expire_date"] = "1W" + })) + .AsString(); + + // handle Pastebin errors + if (string.IsNullOrWhiteSpace(response)) + return new SavePasteResponse { Error = "Received an empty response from Pastebin." }; + if (response.StartsWith("Bad API request")) + return new SavePasteResponse { Error = response }; + if (!response.Contains("/")) + return new SavePasteResponse { Error = $"Received an unknown response: {response}" }; + + // return paste ID + string pastebinID = response.Split("/").Last(); + return new SavePasteResponse { Success = true, ID = pastebinID }; + } + catch (Exception ex) + { + return new SavePasteResponse { Success = false, Error = ex.ToString() }; + } + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + this.Client.Dispose(); + } + } +} diff --git a/src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs b/src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs new file mode 100644 index 00000000..1c0960a4 --- /dev/null +++ b/src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.Framework.LogParser +{ + /// <summary>The response for a save-log request.</summary> + internal class SavePasteResponse + { + /// <summary>Whether the log was successfully saved.</summary> + public bool Success { get; set; } + + /// <summary>The saved paste ID (if <see cref="Success"/> is <c>true</c>).</summary> + public string ID { get; set; } + + /// <summary>The error message (if saving failed).</summary> + public string Error { get; set; } + } +} diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index abce8f28..c0ea90da 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -43,6 +43,7 @@ namespace StardewModdingAPI.Web { services .Configure<ModUpdateCheckConfig>(this.Configuration.GetSection("ModUpdateCheck")) + .Configure<LogParserConfig>(this.Configuration.GetSection("LogParser")) .Configure<RouteOptions>(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint))) .AddMemoryCache() .AddMvc() diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index 852f6f71..ca1299ce 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -26,5 +26,10 @@ "NexusUserAgent": "Nexus Client v0.63.15", "NexusBaseUrl": "http://www.nexusmods.com/stardewvalley", "NexusModUrlFormat": "mods/{0}" + }, + "LogParser": { + "PastebinBaseUrl": "https://pastebin.com/", + "PastebinUserAgent": "SMAPI/{0} (+https://github.com/Pathoschild/SMAPI)", + "PastebinDevKey": "b8219d942109d1e60ebb14fbb45f06f9" } } diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index 4597392c..b1f8f5c6 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -36,10 +36,6 @@ $(function() { $("#input").val(""); $("#popup-upload").fadeIn(); }); - var proxies = [ - "https://cors-anywhere.herokuapp.com/", - "https://galvanize-cors-proxy.herokuapp.com/" - ]; $("#popup-upload").on({ 'dragover dragenter': function(e) { e.preventDefault(); @@ -66,38 +62,35 @@ $(function() { $("#submit").on("click", function() { $("#popup-upload").fadeOut(); - if ($("#input").val()) { + var raw = $("#input").val(); + if (raw) { memory = ""; - var raw = $("#input").val(); var paste = LZString.compressToUTF16(raw); - logSize("Raw", raw); - logSize("Compressed", paste); if (paste.length * 2 > 524288) { $("#output").html('<div id="log" class="color-red"><h1>Unable to save!</h1>This log cannot be saved due to its size.<hr />' + $("#input").val() + "</div>"); return; } - console.log("paste:", paste); - var packet = { - api_dev_key: "b8219d942109d1e60ebb14fbb45f06f9", - api_option: "paste", - api_paste_private: 1, - api_paste_code: paste, - api_paste_expire_date: "1W" - }; $("#uploader").attr("data-text", "Saving..."); $("#uploader").fadeIn(); - var uri = proxies[Math.floor(Math.random() * proxies.length)] + "pastebin.com/api/api_post.php"; - console.log(packet, uri); - $.post(uri, packet, function(data) { - $("#uploader").fadeOut(); - console.log("Result: ", data); - if (data.substring(0, 15) === "Bad API request") - $("#output").html('<div id="log" class="color-red"><h1>Parsing failed!</h1>Parsing of the log failed, details follow.<br /> <p>Stage: Upload</p>Error: ' + data + "<hr />" + $("#input").val() + "</div>"); - else if (data) - location.href = "?" + data.split("/").pop(); - else - $("#output").html('<div id="log" class="color-red"><h1>Parsing failed!</h1>Parsing of the log failed, details follow.<br /> <p>Stage: Upload</p>Error: Received null response<hr />' + $("#input").val() + "</div>"); - }); + $ + .ajax({ + type: "POST", + url: "/log/save", + data: JSON.stringify(paste), + contentType: "application/json" // sent to API + }) + .fail(function(xhr, textStatus) { + $("#uploader").fadeOut(); + $("#output").html('<div id="log" class="color-red"><h1>Parsing failed!</h1>Parsing of the log failed, details follow.<br /> <p>Stage: Upload</p>Error: ' + textStatus + ': ' + xhr.responseText + "<hr />" + $("#input").val() + "</div>"); + }) + .then(function(data) { + $("#uploader").fadeOut(); + console.log("Result: ", data); + if (!data.success) + $("#output").html('<div id="log" class="color-red"><h1>Parsing failed!</h1>Parsing of the log failed, details follow.<br /> <p>Stage: Upload</p>Error: ' + data.error + "<hr />" + $("#input").val() + "</div>"); + else + location.href = "?" + data.id; + }); } else { alert("Unable to parse log, the input is empty!"); $("#uploader").fadeOut(); @@ -122,10 +115,6 @@ $(function() { /********* ** Helpers *********/ - function logSize(id, str) { - console.log(id + ":", str.length * 2, "bytes", Math.round(str.length / 5.12) / 100, "kb"); - } - function modClicked(evt) { var id = $(evt.currentTarget).attr("id").split("-")[1], cls = "mod-" + id; @@ -284,13 +273,13 @@ $(function() { function getData() { $("#uploader").attr("data-text", "Loading..."); $("#uploader").fadeIn(); - $.get("https://cors-anywhere.herokuapp.com/pastebin.com/raw/" + location.search.substring(1) + "/?nocache=" + Math.random(), function(data) { - if (data.substring(0, 9) === "<!DOCTYPE") { - $("#output").html('<div id="log" class="color-red"><h1>Captcha required!</h1>The pastebin server is asking for a captcha, but their API doesnt let us show it to you directly.<br />Instead, to finish saving the log, you need to <a href="https://pastebin.com/' + location.search.substring(1) + '" target="_blank">solve the captcha in a new tab</a>, once you have done so, reload this page.</div>'); - } - else { - $("#input").val(LZString.decompressFromUTF16(data) || data); + $.get("/log/fetch/" + location.search.substring(1), function(data) { + if (data.success) { + $("#input").val(LZString.decompressFromUTF16(data.content) || data.content); loadData(); + } else { + $("#output").html('<div id="log" class="color-red"><h1>Fetching the log failed!</h1><p>' + data.error + '</p><div id="rawlog"></div></div>'); + $("#rawlog").text($("#input").val()); } $("#uploader").fadeOut(); }); |