summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <github@jplamondonw.com>2017-10-27 19:39:13 -0400
committerJesse Plamondon-Willard <github@jplamondonw.com>2017-10-27 19:39:13 -0400
commitad5bb5b49af49c4668fd30fb2a0e606dcefe4ec0 (patch)
treee0bdd32fbfe91f7ab5e6cd3446a75b32d6e10e5c
parentacbea9bfa33655048673a2292350aedb1d05a09a (diff)
downloadSMAPI-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.cs54
-rw-r--r--src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs52
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs18
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs2
-rw-r--r--src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs15
-rw-r--r--src/SMAPI.Web/Framework/LogParser/PastebinClient.cs110
-rw-r--r--src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs15
-rw-r--r--src/SMAPI.Web/Startup.cs1
-rw-r--r--src/SMAPI.Web/appsettings.json5
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/log-parser.js65
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 />&nbsp;<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 />&nbsp;<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 />&nbsp;<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 />&nbsp;<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();
});