summaryrefslogtreecommitdiff
path: root/src/SMAPI.Web
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI.Web')
-rw-r--r--src/SMAPI.Web/Controllers/LogParserController.cs158
-rw-r--r--src/SMAPI.Web/Controllers/ModsApiController.cs (renamed from src/SMAPI.Web/Controllers/ModsController.cs)6
-rw-r--r--src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs52
-rw-r--r--src/SMAPI.Web/Framework/BeanstalkEnvPropsConfigProvider.cs54
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs24
-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.cs117
-rw-r--r--src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs15
-rw-r--r--src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs62
-rw-r--r--src/SMAPI.Web/Framework/RewriteRules/ConditionalRewriteSubdomainRule.cs48
-rw-r--r--src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs61
-rw-r--r--src/SMAPI.Web/Framework/RewriteSubdomainRule.cs30
-rw-r--r--src/SMAPI.Web/Properties/launchSettings.json11
-rw-r--r--src/SMAPI.Web/Startup.cs33
-rw-r--r--src/SMAPI.Web/ViewModels/LogParserModel.cs31
-rw-r--r--src/SMAPI.Web/Views/LogParser/Index.cshtml119
-rw-r--r--src/SMAPI.Web/Views/Shared/_Layout.cshtml30
-rw-r--r--src/SMAPI.Web/Views/_ViewStart.cshtml3
-rw-r--r--src/SMAPI.Web/appsettings.Development.json20
-rw-r--r--src/SMAPI.Web/appsettings.json19
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/log-parser.css348
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/main.css107
-rw-r--r--src/SMAPI.Web/wwwroot/Content/images/sidebar-bg.gifbin0 -> 1104 bytes
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/log-parser.js278
-rw-r--r--src/SMAPI.Web/wwwroot/favicon.icobin0 -> 15086 bytes
26 files changed, 1593 insertions, 50 deletions
diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs
new file mode 100644
index 00000000..454440bb
--- /dev/null
+++ b/src/SMAPI.Web/Controllers/LogParserController.cs
@@ -0,0 +1,158 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Text;
+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;
+using StardewModdingAPI.Web.ViewModels;
+
+namespace StardewModdingAPI.Web.Controllers
+{
+ /// <summary>Provides a web UI and API for parsing SMAPI log files.</summary>
+ internal class LogParserController : Controller
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The log parser config settings.</summary>
+ private readonly LogParserConfig Config;
+
+ /// <summary>The underlying Pastebin client.</summary>
+ private readonly PastebinClient PastebinClient;
+
+ /// <summary>The first bytes in a valid zip file.</summary>
+ /// <remarks>See <a href="https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers"/>.</remarks>
+ private const uint GzipLeadBytes = 0x8b1f;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /***
+ ** Constructor
+ ***/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="configProvider">The log parser config settings.</param>
+ public LogParserController(IOptions<LogParserConfig> configProvider)
+ {
+ // init Pastebin client
+ this.Config = configProvider.Value;
+ string version = this.GetType().Assembly.GetName().Version.ToString(3);
+ string userAgent = string.Format(this.Config.PastebinUserAgent, version);
+ this.PastebinClient = new PastebinClient(this.Config.PastebinBaseUrl, userAgent, this.Config.PastebinUserKey, this.Config.PastebinDevKey);
+ }
+
+ /***
+ ** Web UI
+ ***/
+ /// <summary>Render the log parser UI.</summary>
+ /// <param name="id">The paste ID.</param>
+ [HttpGet]
+ [Route("")]
+ [Route("log")]
+ [Route("log/{id}")]
+ public ViewResult Index(string id = null)
+ {
+ return this.View("Index", new LogParserModel(this.Config.SectionUrl, id));
+ }
+
+ /***
+ ** 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)
+ {
+ GetPasteResponse response = await this.PastebinClient.GetAsync(id);
+ response.Content = this.DecompressString(response.Content);
+ return response;
+ }
+
+ /// <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)
+ {
+ content = this.CompressString(content);
+ return await this.PastebinClient.PostAsync(content);
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Compress a string.</summary>
+ /// <param name="text">The text to compress.</param>
+ /// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks>
+ private string CompressString(string text)
+ {
+ // get raw bytes
+ byte[] buffer = Encoding.UTF8.GetBytes(text);
+
+ // compressed
+ byte[] compressedData;
+ using (MemoryStream stream = new MemoryStream())
+ {
+ using (GZipStream zipStream = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true))
+ zipStream.Write(buffer, 0, buffer.Length);
+
+ stream.Position = 0;
+ compressedData = new byte[stream.Length];
+ stream.Read(compressedData, 0, compressedData.Length);
+ }
+
+ // prefix length
+ var zipBuffer = new byte[compressedData.Length + 4];
+ Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length);
+ Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4);
+
+ // return string representation
+ return Convert.ToBase64String(zipBuffer);
+ }
+
+ /// <summary>Decompress a string.</summary>
+ /// <param name="rawText">The compressed text.</param>
+ /// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks>
+ private string DecompressString(string rawText)
+ {
+ // get raw bytes
+ byte[] zipBuffer;
+ try
+ {
+ zipBuffer = Convert.FromBase64String(rawText);
+ }
+ catch
+ {
+ return rawText; // not valid base64, wasn't compressed by the log parser
+ }
+
+ // skip if not gzip
+ if (BitConverter.ToUInt16(zipBuffer, 4) != LogParserController.GzipLeadBytes)
+ return rawText;
+
+ // decompress
+ using (MemoryStream memoryStream = new MemoryStream())
+ {
+ // read length prefix
+ int dataLength = BitConverter.ToInt32(zipBuffer, 0);
+ memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4);
+
+ // read data
+ var buffer = new byte[dataLength];
+ memoryStream.Position = 0;
+ using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
+ gZipStream.Read(buffer, 0, buffer.Length);
+
+ // return original string
+ return Encoding.UTF8.GetString(buffer);
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Controllers/ModsController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs
index a671ddca..a600662c 100644
--- a/src/SMAPI.Web/Controllers/ModsController.cs
+++ b/src/SMAPI.Web/Controllers/ModsApiController.cs
@@ -14,8 +14,8 @@ namespace StardewModdingAPI.Web.Controllers
{
/// <summary>Provides an API to perform mod update checks.</summary>
[Produces("application/json")]
- [Route("api/{version:semanticVersion}/[controller]")]
- internal class ModsController : Controller
+ [Route("api/v{version:semanticVersion}/mods")]
+ internal class ModsApiController : Controller
{
/*********
** Properties
@@ -39,7 +39,7 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>Construct an instance.</summary>
/// <param name="cache">The cache in which to store mod metadata.</param>
/// <param name="configProvider">The config settings for mod update checks.</param>
- public ModsController(IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider)
+ public ModsApiController(IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider)
{
ModUpdateCheckConfig config = configProvider.Value;
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/BeanstalkEnvPropsConfigProvider.cs b/src/SMAPI.Web/Framework/BeanstalkEnvPropsConfigProvider.cs
new file mode 100644
index 00000000..b39a3b61
--- /dev/null
+++ b/src/SMAPI.Web/Framework/BeanstalkEnvPropsConfigProvider.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Microsoft.Extensions.Configuration;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace StardewModdingAPI.Web.Framework
+{
+ /// <summary>Reads configuration values from the AWS Beanstalk environment properties file (if present).</summary>
+ /// <remarks>This is a workaround for AWS Beanstalk injection not working with .NET Core apps.</remarks>
+ internal class BeanstalkEnvPropsConfigProvider : ConfigurationProvider, IConfigurationSource
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The absolute path to the container configuration file on an Amazon EC2 instance.</summary>
+ private const string ContainerConfigPath = @"C:\Program Files\Amazon\ElasticBeanstalk\config\containerconfiguration";
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Build the configuration provider for this source.</summary>
+ /// <param name="builder">The configuration builder.</param>
+ public IConfigurationProvider Build(IConfigurationBuilder builder)
+ {
+ return new BeanstalkEnvPropsConfigProvider();
+ }
+
+ /// <summary>Load the environment properties.</summary>
+ public override void Load()
+ {
+ this.Data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+ // get Beanstalk config file
+ FileInfo file = new FileInfo(BeanstalkEnvPropsConfigProvider.ContainerConfigPath);
+ if (!file.Exists)
+ return;
+
+ // parse JSON
+ JObject jsonRoot = (JObject)JsonConvert.DeserializeObject(File.ReadAllText(file.FullName));
+ if (jsonRoot["iis"]?["env"] is JArray jsonProps)
+ {
+ foreach (string prop in jsonProps.Values<string>())
+ {
+ string[] parts = prop.Split('=', 2); // key=value
+ if (parts.Length == 2)
+ this.Data[parts[0]] = parts[1];
+ }
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs
new file mode 100644
index 00000000..df5d605d
--- /dev/null
+++ b/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs
@@ -0,0 +1,24 @@
+namespace StardewModdingAPI.Web.Framework.ConfigModels
+{
+ /// <summary>The config settings for the log parser.</summary>
+ internal class LogParserConfig
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The root URL for the log parser controller.</summary>
+ public string SectionUrl { get; set; }
+
+ /// <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 user key used to authenticate with the Pastebin API.</summary>
+ public string PastebinUserKey { 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..738330d3
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParser/PastebinClient.cs
@@ -0,0 +1,117 @@
+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 user key used to authenticate with the Pastebin API.</summary>
+ private readonly string UserKey;
+
+ /// <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="userKey">The user key used to authenticate with the Pastebin API.</param>
+ /// <param name="devKey">The developer key used to authenticate with the Pastebin API.</param>
+ public PastebinClient(string baseUrl, string userAgent, string userKey, string devKey)
+ {
+ this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
+ this.UserKey = userKey;
+ 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_option"] = "paste",
+ ["api_user_key"] = this.UserKey,
+ ["api_dev_key"] = this.DevKey,
+ ["api_paste_private"] = "1", // unlisted
+ ["api_paste_name"] = $"SMAPI log {DateTime.UtcNow:s}",
+ ["api_paste_expire_date"] = "1W", // one week
+ ["api_paste_code"] = content
+ }))
+ .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/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs
new file mode 100644
index 00000000..d6a56bb7
--- /dev/null
+++ b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Net;
+using System.Text;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Rewrite;
+
+namespace StardewModdingAPI.Web.Framework.RewriteRules
+{
+ /// <summary>Redirect requests to HTTPS.</summary>
+ /// <remarks>Derived from <a href="https://stackoverflow.com/a/44526747/262123" /> and <see cref="Microsoft.AspNetCore.Rewrite.Internal.RedirectToHttpsRule"/>.</remarks>
+ internal class ConditionalRedirectToHttpsRule : IRule
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>A predicate which indicates when the rule should be applied.</summary>
+ private readonly Func<HttpRequest, bool> ShouldRewrite;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="shouldRewrite">A predicate which indicates when the rule should be applied.</param>
+ public ConditionalRedirectToHttpsRule(Func<HttpRequest, bool> shouldRewrite = null)
+ {
+ this.ShouldRewrite = shouldRewrite ?? (req => true);
+ }
+
+ /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
+ /// <param name="context">The rewrite context.</param>
+ public void ApplyRule(RewriteContext context)
+ {
+ HttpRequest request = context.HttpContext.Request;
+
+ // check condition
+ if (this.IsSecure(request) || !this.ShouldRewrite(request))
+ return;
+
+ // redirect request
+ HttpResponse response = context.HttpContext.Response;
+ response.StatusCode = (int)HttpStatusCode.RedirectKeepVerb;
+ response.Headers["Location"] = new StringBuilder()
+ .Append("https://")
+ .Append(request.Host.Host)
+ .Append(request.PathBase)
+ .Append(request.Path)
+ .Append(request.QueryString)
+ .ToString();
+ context.Result = RuleResult.EndResponse;
+ }
+
+ /// <summary>Get whether the request was received over HTTPS.</summary>
+ /// <param name="request">The request to check.</param>
+ public bool IsSecure(HttpRequest request)
+ {
+ return
+ request.IsHttps // HTTPS to server
+ || string.Equals(request.Headers["x-forwarded-proto"], "HTTPS", StringComparison.OrdinalIgnoreCase); // HTTPS to AWS load balancer
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/RewriteRules/ConditionalRewriteSubdomainRule.cs b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRewriteSubdomainRule.cs
new file mode 100644
index 00000000..920632ab
--- /dev/null
+++ b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRewriteSubdomainRule.cs
@@ -0,0 +1,48 @@
+using System;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Rewrite;
+
+namespace StardewModdingAPI.Web.Framework.RewriteRules
+{
+ /// <summary>Rewrite requests to prepend the subdomain portion (if any) to the path.</summary>
+ /// <remarks>Derived from <a href="https://stackoverflow.com/a/44526747/262123" />.</remarks>
+ internal class ConditionalRewriteSubdomainRule : IRule
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>A predicate which indicates when the rule should be applied.</summary>
+ private readonly Func<HttpRequest, bool> ShouldRewrite;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="shouldRewrite">A predicate which indicates when the rule should be applied.</param>
+ public ConditionalRewriteSubdomainRule(Func<HttpRequest, bool> shouldRewrite = null)
+ {
+ this.ShouldRewrite = shouldRewrite ?? (req => true);
+ }
+
+ /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
+ /// <param name="context">The rewrite context.</param>
+ public void ApplyRule(RewriteContext context)
+ {
+ HttpRequest request = context.HttpContext.Request;
+
+ // check condition
+ if (!this.ShouldRewrite(request))
+ return;
+
+ // get host parts
+ string host = request.Host.Host;
+ string[] parts = host.Split('.');
+ if (parts.Length < 2)
+ return;
+
+ // prepend to path
+ request.Path = $"/{parts[0]}{request.Path}";
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs b/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs
new file mode 100644
index 00000000..0719e311
--- /dev/null
+++ b/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Net;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Rewrite;
+
+namespace StardewModdingAPI.Web.Framework.RewriteRules
+{
+ /// <summary>Redirect requests to an external URL if they match a condition.</summary>
+ internal class RedirectToUrlRule : IRule
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>A predicate which indicates when the rule should be applied.</summary>
+ private readonly Func<HttpRequest, bool> ShouldRewrite;
+
+ /// <summary>The new URL to which to redirect.</summary>
+ private readonly string NewUrl;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="shouldRewrite">A predicate which indicates when the rule should be applied.</param>
+ /// <param name="url">The new URL to which to redirect.</param>
+ public RedirectToUrlRule(Func<HttpRequest, bool> shouldRewrite, string url)
+ {
+ this.ShouldRewrite = shouldRewrite ?? (req => true);
+ this.NewUrl = url;
+ }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="pathRegex">A case-insensitive regex to match against the path.</param>
+ /// <param name="url">The external URL.</param>
+ public RedirectToUrlRule(string pathRegex, string url)
+ {
+ Regex regex = new Regex(pathRegex, RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ this.ShouldRewrite = req => req.Path.HasValue && regex.IsMatch(req.Path.Value);
+ this.NewUrl = url;
+ }
+
+ /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
+ /// <param name="context">The rewrite context.</param>
+ public void ApplyRule(RewriteContext context)
+ {
+ HttpRequest request = context.HttpContext.Request;
+
+ // check condition
+ if (!this.ShouldRewrite(request))
+ return;
+
+ // redirect request
+ HttpResponse response = context.HttpContext.Response;
+ response.StatusCode = (int)HttpStatusCode.Redirect;
+ response.Headers["Location"] = this.NewUrl;
+ context.Result = RuleResult.EndResponse;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/RewriteSubdomainRule.cs b/src/SMAPI.Web/Framework/RewriteSubdomainRule.cs
deleted file mode 100644
index 5a56844f..00000000
--- a/src/SMAPI.Web/Framework/RewriteSubdomainRule.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using System;
-using Microsoft.AspNetCore.Rewrite;
-
-namespace StardewModdingAPI.Web.Framework
-{
- /// <summary>Rewrite requests to prepend the subdomain portion (if any) to the path.</summary>
- /// <remarks>Derived from <a href="https://stackoverflow.com/a/44526747/262123" />.</remarks>
- internal class RewriteSubdomainRule : IRule
- {
- /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
- /// <param name="context">The rewrite context.</param>
- public void ApplyRule(RewriteContext context)
- {
- context.Result = RuleResult.ContinueRules;
-
- // get host parts
- string host = context.HttpContext.Request.Host.Host;
- string[] parts = host.Split('.');
-
- // validate
- if (parts.Length < 2)
- return;
- if (parts.Length < 3 && !"localhost".Equals(parts[1], StringComparison.InvariantCultureIgnoreCase))
- return;
-
- // prepend to path
- context.HttpContext.Request.Path = $"/{parts[0]}{context.HttpContext.Request.Path}";
- }
- }
-}
diff --git a/src/SMAPI.Web/Properties/launchSettings.json b/src/SMAPI.Web/Properties/launchSettings.json
index a0760365..e485e4e3 100644
--- a/src/SMAPI.Web/Properties/launchSettings.json
+++ b/src/SMAPI.Web/Properties/launchSettings.json
@@ -11,19 +11,10 @@
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
- "launchUrl": "api/1.0/mods?modKeys=nexus:541,chucklefish:4228,github:Zoryn4163/SMAPI-Mods",
+ "launchUrl": "log",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
- },
- "Dewdrop": {
- "commandName": "Project",
- "launchBrowser": true,
- "launchUrl": "api/1.0/mods?modKeys=nexus:541,chucklefish:4228,github:Zoryn4163/SMAPI-Mods",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- },
- "applicationUrl": "http://localhost:59483"
}
}
}
diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs
index eaf14983..0ea9f7ee 100644
--- a/src/SMAPI.Web/Startup.cs
+++ b/src/SMAPI.Web/Startup.cs
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using StardewModdingAPI.Web.Framework;
using StardewModdingAPI.Web.Framework.ConfigModels;
+using StardewModdingAPI.Web.Framework.RewriteRules;
namespace StardewModdingAPI.Web
{
@@ -30,10 +31,9 @@ namespace StardewModdingAPI.Web
{
this.Configuration = new ConfigurationBuilder()
.SetBasePath(env.ContentRootPath)
- .AddEnvironmentVariables()
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
- .AddEnvironmentVariables()
+ .Add(new BeanstalkEnvPropsConfigProvider()) //.AddEnvironmentVariables()
.Build();
}
@@ -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()
@@ -63,7 +64,33 @@ namespace StardewModdingAPI.Web
loggerFactory.AddConsole(this.Configuration.GetSection("Logging"));
loggerFactory.AddDebug();
app
- .UseRewriter(new RewriteOptions().Add(new RewriteSubdomainRule())) // convert subdomain.smapi.io => smapi.io/subdomain for routing
+ .UseCors(policy => policy
+ .AllowAnyHeader()
+ .AllowAnyMethod()
+ .WithOrigins("https://smapi.io", "https://*.smapi.io", "https://*.edge.smapi.io")
+ .SetIsOriginAllowedToAllowWildcardSubdomains()
+ )
+ .UseRewriter(new RewriteOptions()
+ // redirect to HTTPS (except API for Linux/Mac Mono compatibility)
+ .Add(new ConditionalRedirectToHttpsRule(
+ shouldRewrite: req =>
+ req.Host.Host != "localhost"
+ && !req.Path.StartsWithSegments("/api")
+ ))
+
+ // convert subdomain.smapi.io => smapi.io/subdomain for routing
+ .Add(new ConditionalRewriteSubdomainRule(
+ shouldRewrite: req =>
+ req.Host.Host != "localhost"
+ && (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("log."))
+ && !req.Path.StartsWithSegments("/content")
+ ))
+
+ // shortcut redirects
+ .Add(new RedirectToUrlRule("^/docs$", "https://stardewvalleywiki.com/Modding:Index"))
+ .Add(new RedirectToUrlRule("^/install$", "https://stardewvalleywiki.com/Modding:Installing_SMAPI"))
+ )
+ .UseStaticFiles() // wwwroot folder
.UseMvc();
}
}
diff --git a/src/SMAPI.Web/ViewModels/LogParserModel.cs b/src/SMAPI.Web/ViewModels/LogParserModel.cs
new file mode 100644
index 00000000..b5b3b14c
--- /dev/null
+++ b/src/SMAPI.Web/ViewModels/LogParserModel.cs
@@ -0,0 +1,31 @@
+namespace StardewModdingAPI.Web.ViewModels
+{
+ /// <summary>The view model for the log parser page.</summary>
+ public class LogParserModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The root URL for the log parser controller.</summary>
+ public string SectionUrl { get; set; }
+
+ /// <summary>The paste ID.</summary>
+ public string PasteID { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public LogParserModel() { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="sectionUrl">The root URL for the log parser controller.</param>
+ /// <param name="pasteID">The paste ID.</param>
+ public LogParserModel(string sectionUrl, string pasteID)
+ {
+ this.SectionUrl = sectionUrl;
+ this.PasteID = pasteID;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml
new file mode 100644
index 00000000..49688d78
--- /dev/null
+++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml
@@ -0,0 +1,119 @@
+@{
+ ViewData["Title"] = "SMAPI log parser";
+}
+@model StardewModdingAPI.Web.ViewModels.LogParserModel
+@section Head {
+ <link rel="stylesheet" href="~/Content/css/log-parser.css" />
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js" crossorigin="anonymous"></script>
+ <script src="~/Content/js/log-parser.js"></script>
+ <style type="text/css" id="modflags"></style>
+ <script>
+ $(function() {
+ smapi.logParser('@Model.SectionUrl', '@Model.PasteID');
+ });
+ </script>
+}
+
+@*********
+** Intro
+*********@
+<p id="blurb">This page lets you upload, view, and share a SMAPI log to help troubleshoot mod issues.</p>
+<input type="button" id="upload-button" value="Share a new log" />
+
+@if (Model.PasteID != null)
+{
+ <h2>Parsed log</h2>
+ <ul id="tabs">
+ <li>TRACE</li>
+ <li>DEBUG</li>
+ <li class="active">INFO</li>
+ <li class="active">ALERT</li>
+ <li class="active">WARN</li>
+ <li class="active">ERROR</li>
+ <li class="notice">Click tabs to toggle message visibility</li>
+ </ul>
+}
+<div id="output" class="trace debug"></div>
+<script class="template" id="template-body" type="text/html">
+ <div class="always">
+ <table id="gameinfo">
+ <caption>Game info:</caption>
+ <tr>
+ <td>SMAPI Version</td>
+ <td>{0}</td>
+ </tr>
+ <tr>
+ <td>Game Version</td>
+ <td>{1}</td>
+ </tr>
+ <tr>
+ <td>Platform</td>
+ <td>{2}</td>
+ </tr>
+ <tr>
+ <td>Mods path</td>
+ <td>{4}</td>
+ </tr>
+ <tr>
+ <td>Log started</td>
+ <td>{3}</td>
+ </tr>
+ </table>
+ <br />
+ <table id="modslist">
+ <caption>Installed Mods: <span id="modlink-r" class="notice btn">Remove all mod filters</span><span class="notice txt"><i>Click any mod to filter</i></span> <span id="modlink-a" class="notice btn txt">Select all</span></caption>
+ </table>
+ </div>
+ <table id="log"></table>
+</script>
+<script class="template" id="template-css" type="text/html">
+ #output.modfilter:not(.mod-{0}) .mod-{0} { display:none; } #output.modfilter.mod-{0} #modslist tr { background:#ffeeee; } #output.modfilter.mod-{0} #modslist tr#modlink-{0} { background:#eeffee; }
+</script>
+<script class="template" id="template-modentry" type="text/html">
+ <tr id="modlink-{0}">
+ <td>{1}</td>
+ <td>{2}</td>
+ <td>{3}</td>
+ <td class={4}>{5}</td>
+ </tr>
+</script>
+<script class="template" id="template-logentry" type="text/html">
+ <tr class="{0} mod mod-{1}">
+ <td>{2}</td>
+ <td>{3}</td>
+ <td data-title="{4}">{4}</td>
+ <td>{5}</td>
+ </tr>
+</script>
+<script class="template" id="template-lognotice" type="text/html">
+ <tr class="{0} mod-repeat mod mod-{1}">
+ <td colspan="3"></td>
+ <td><i>repeats [{2}] times.</i></td>
+ </tr>
+</script>
+<div id="popup-upload" class="popup">
+ <h1>Upload log file</h1>
+ <div class="frame">
+ <ol>
+ <li><a href="https://stardewvalleywiki.com/Modding:Player_FAQs#SMAPI_log" target="_blank">Find your SMAPI log</a>.</li>
+ <li>Drag the file onto the textbox below (or paste the text in).</li>
+ <li>Click <em>Parse</em>.</li>
+ <li>Share the URL of the new page.</li>
+ </ol>
+ <textarea id="input" placeholder="Paste or drag the log here"></textarea>
+ <div class="buttons">
+ <input type="button" id="submit" value="Parse"/>
+ <input type="button" id="cancel" value="Cancel"/>
+ </div>
+ </div>
+</div>
+<div id="popup-raw" class="popup">
+ <h1>Raw log file</h1>
+ <div class="frame">
+ <textarea id="dataraw"></textarea>
+ <div class="buttons">
+ <input type="button" id="closeraw" value="Close" />
+ </div>
+ </div>
+</div>
+<div id="uploader"></div>
diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml
new file mode 100644
index 00000000..547a8178
--- /dev/null
+++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8" />
+ <title>@ViewData["Title"] - SMAPI.io</title>
+ <link rel="stylesheet" href="~/Content/css/main.css" />
+ @RenderSection("Head", required: false)
+</head>
+<body>
+ <div id="sidebar">
+ <h4>SMAPI</h4>
+ <ul>
+ <li><a href="https://stardewvalleywiki.com/Modding:Index">FAQs & guides</a></li>
+ <li><a href="https://github.com/pathoschild/SMAPI/releases">Download SMAPI</a></li>
+ <li><a href="https://discord.gg/stardewvalley">Get help on Discord</a></li>
+ </ul>
+ </div>
+ <div id="content-column">
+ <div id="content">
+ <h1>@ViewData["Title"]</h1>
+ @RenderBody()
+ </div>
+ <div id="footer">
+ <div id="license">
+ Hi! You can <a href="https://github.com/pathoschild/SMAPI" title="view source">view the source code</a> or <a href="https://github.com/pathoschild/SMAPI/issues" title="report issue">report a bug or suggestion</a>.
+ </div>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/src/SMAPI.Web/Views/_ViewStart.cshtml b/src/SMAPI.Web/Views/_ViewStart.cshtml
new file mode 100644
index 00000000..a5f10045
--- /dev/null
+++ b/src/SMAPI.Web/Views/_ViewStart.cshtml
@@ -0,0 +1,3 @@
+@{
+ Layout = "_Layout";
+}
diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json
index fa8ce71a..87c35ca9 100644
--- a/src/SMAPI.Web/appsettings.Development.json
+++ b/src/SMAPI.Web/appsettings.Development.json
@@ -1,4 +1,13 @@
-{
+/*
+
+
+ This file is committed to source control with the default settings, but added to .gitignore to
+ avoid accidentally committing login details.
+
+
+
+*/
+{
"Logging": {
"IncludeScopes": false,
"LogLevel": {
@@ -6,5 +15,14 @@
"System": "Information",
"Microsoft": "Information"
}
+ },
+ "ModUpdateCheck": {
+ "GitHubUsername": null,
+ "GitHubPassword": null
+ },
+ "LogParser": {
+ "SectionUrl": "http://localhost:59482/log/",
+ "PastebinUserKey": null,
+ "PastebinDevKey": null
}
}
diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json
index 852f6f71..eb6ecc9b 100644
--- a/src/SMAPI.Web/appsettings.json
+++ b/src/SMAPI.Web/appsettings.json
@@ -1,3 +1,11 @@
+/*
+
+
+ This contains the default settings for the web app. Login credentials and contextual settings are
+ configured via appsettings.Development.json locally, or environment properties in AWS.
+
+
+*/
{
"Logging": {
"IncludeScopes": false,
@@ -19,12 +27,19 @@
"GitHubBaseUrl": "https://api.github.com",
"GitHubReleaseUrlFormat": "repos/{0}/releases/latest",
"GitHubAcceptHeader": "application/vnd.github.v3+json",
- "GitHubUsername": null, /* set via environment properties */
- "GitHubPassword": null, /* set via environment properties */
+ "GitHubUsername": null, // see top note
+ "GitHubPassword": null, // see top note
"NexusKey": "Nexus",
"NexusUserAgent": "Nexus Client v0.63.15",
"NexusBaseUrl": "http://www.nexusmods.com/stardewvalley",
"NexusModUrlFormat": "mods/{0}"
+ },
+ "LogParser": {
+ "SectionUrl": null, // see top note
+ "PastebinBaseUrl": "https://pastebin.com/",
+ "PastebinUserAgent": "SMAPI/{0} (+https://github.com/Pathoschild/SMAPI)",
+ "PastebinUserKey": null, // see top note
+ "PastebinDevKey": null // see top note
}
}
diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
new file mode 100644
index 00000000..975e9c2e
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
@@ -0,0 +1,348 @@
+.mod-repeat {
+ font-size: 8pt;
+}
+
+.template {
+ display: none;
+}
+
+.popup, #uploader {
+ position: fixed;
+ top: 0px;
+ left: 0px;
+ right: 0px;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, .33);
+ z-index: 2;
+ display: none;
+ padding: 5px;
+}
+
+#upload-button {
+ background: #ccf;
+ border: 1px solid #000088;
+}
+
+#upload-button {
+ background: #eef;
+}
+
+
+#uploader:after {
+ content: attr(data-text);
+ display: block;
+ width: 100px;
+ height: 24px;
+ line-height: 25px;
+ border: 1px solid #000;
+ background: #fff;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: -12px -50px 0 0;
+ font-size: 18px;
+ font-weight: bold;
+ text-align: center;
+ border-radius: 5px;
+}
+
+.popup h1 {
+ position: absolute;
+ top: 10%;
+ left: 50%;
+ margin-left: -150px;
+ text-align: center;
+ width: 300px;
+ border: 1px solid #008;
+ border-radius: 5px;
+ background: #fff;
+ font-family: sans-serif;
+ font-size: 40px;
+ margin-top: -25px;
+ z-index: 10;
+ border-bottom: 0;
+}
+
+.frame {
+ margin: auto;
+ margin-top: 25px;
+ padding: 2em;
+ position: absolute;
+ top: 10%;
+ left: 10%;
+ right: 10%;
+ bottom: 10%;
+ padding-bottom: 30px;
+ background: #FFF;
+ border-radius: 5px;
+ border: 1px solid #008;
+}
+
+input[type="button"] {
+ font-size: 20px;
+ border-radius: 5px;
+ outline: none;
+ box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 0, .2);
+ cursor: pointer;
+}
+
+#input[type="button"]:hover {
+ background-color: #fee;
+}
+
+#cancel, #closeraw {
+ border: 1px solid #880000;
+ background-color: #fcc;
+}
+
+#submit {
+ border: 1px solid #008800;
+ background-color: #cfc;
+}
+
+#submit:hover {
+ background-color: #efe;
+}
+
+#input, #dataraw {
+ width: 100%;
+ height: 30em;
+ max-height: 70%;
+ margin: auto;
+ box-sizing: border-box;
+ border-radius: 5px;
+ border: 1px solid #000088;
+ outline: none;
+ box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2);
+}
+
+.color-red {
+ color: red;
+}
+
+.color-green {
+ color: green;
+}
+
+#tabs {
+ border-bottom: 0;
+ display: block;
+ margin: 0;
+ padding: 0;
+ background: linear-gradient(to bottom, rgba(255, 255, 255, 1) 0%, rgba(210, 235, 249, 1) 100%);
+}
+
+#tabs li {
+ margin: 5px 1px 0 0;
+ height: 25px;
+ display: inline-block;
+ width: 75px;
+ border: 1px solid #000000;
+ border-bottom: 0;
+ border-radius: 5px 5px 0 0;
+ text-align: center;
+ font-family: monospace;
+ font-size: 18px;
+ cursor: pointer;
+ font-weight: bold;
+ color: #000;
+ text-shadow: 0px 0px 2px #fff;
+ border-color: #880000;
+ background-color: #fcc;
+ box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 0, .2);
+}
+
+#tabs li:hover {
+ background-color: #fee;
+}
+
+#tabs li:first-child {
+ margin-left: 5px;
+}
+
+#tabs li.active {
+ background: #cfc;
+ border-color: #008800;
+}
+
+#tabs li.active:hover {
+ background: #efe;
+}
+
+#tabs li.notice {
+ color: #000000;
+ background: transparent;
+ border: 0;
+ padding-top: 1px;
+ font-size: 13px;
+ font-weight: normal;
+ width: auto;
+ margin-left: 5px;
+ cursor: default;
+ box-shadow: none;
+ font-style: italic;
+}
+
+#output {
+ border-top: 1px solid #888;
+ padding: 10px;
+ overflow: auto;
+ font-family: monospace;
+}
+
+#output > * {
+ display: block;
+}
+
+#output.trace .trace,
+#output.debug .debug,
+#output.info .info,
+#output.alert .alert,
+#output.warn .warn,
+#output.error .error {
+ display: none;
+}
+
+#output .trace {
+ color: #999;
+}
+
+#output .debug {
+ color: #595959;
+}
+
+#output .info {
+ color: #000
+}
+
+#output .alert {
+ color: #b0b;
+}
+
+#output .warn {
+ color: #f80
+}
+
+#output .error {
+ color: #f00
+}
+
+#output .always {
+ font-weight: bold;
+ border-bottom: 1px dashed #888888;
+ padding-bottom: 10px;
+ margin-bottom: 5px;
+}
+
+caption {
+ text-align: left;
+ padding-top: 2px;
+}
+
+#log {
+ border-spacing: 0;
+}
+
+#log tr {
+ background: #fff;
+}
+
+#log td {
+ padding: 0 1px;
+ background: inherit;
+ border-bottom: 1px dotted #ccc;
+ border-top: 2px solid #fff;
+ vertical-align: top;
+}
+
+#log td:not(:last-child) {
+ max-width: 175px;
+ padding: 0 4px;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+#log td[data-title]:hover {
+ font-size: 1px;
+ overflow: inherit;
+ position: relative;
+}
+
+#log td:nth-child(3):hover:after {
+ content: attr(data-title);
+ display: block;
+ position: absolute;
+ border-radius: 4px;
+ box-shadow: 1px 1px 2px #ccc;
+ background: inherit;
+ border: 1px solid #ccc;
+ background: #efefef;
+ padding: 1px 1px 0 1px;
+ font-size: 10pt;
+ top: -2px;
+ left: 2px;
+ color: #000;
+}
+
+#log td:last-child {
+ width: 100%;
+}
+
+table#gameinfo,
+table#modslist {
+ border: 1px solid #000000;
+ background: #ffffff;
+ border-radius: 5px;
+ border-spacing: 1px;
+ overflow: hidden;
+ cursor: default;
+ box-shadow: 1px 1px 1px 1px #dddddd;
+}
+
+#modslist {
+ min-width: 400px;
+}
+
+#gameinfo td:first-child {
+ padding-right: 5px;
+}
+
+#gameinfo tr,
+#modslist tr {
+ background: #eee
+}
+
+#gameinfo tr:nth-child(even),
+#modslist tr:nth-child(even) {
+ background: #fff
+}
+
+#modslist tr {
+ cursor: pointer;
+}
+
+span.notice {
+ font-weight: normal;
+ font-size: 11px;
+ position: relative;
+ top: -1px;
+ display: none;
+}
+
+span.notice.btn {
+ cursor: pointer;
+ border: 1px solid #000;
+ border-radius: 5px;
+ position: relative;
+ top: -1px;
+ padding: 0 2px;
+ background: #eee;
+}
+
+#output:not(.modfilter) span.notice.txt {
+ display: inline-block;
+}
+
+#output.modfilter span.notice.btn {
+ display: inline-block;
+}
diff --git a/src/SMAPI.Web/wwwroot/Content/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css
new file mode 100644
index 00000000..d1fa49e0
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/css/main.css
@@ -0,0 +1,107 @@
+/* tags */
+html {
+ height: 100%;
+}
+
+body {
+ height: 100%;
+ font-family: sans-serif;
+}
+
+h1, h2, h3 {
+ font-weight: bold;
+ margin: 0.2em 0 0.1em 0;
+ padding-top: .5em;
+}
+
+h1 {
+ font-size: 1.5em;
+ color: #888;
+ margin-bottom: 0;
+}
+
+h2 {
+ font-size: 1.5em;
+ border-bottom: 1px solid #AAA;
+}
+
+h3 {
+ font-size: 1.2em;
+ border-bottom: 1px solid #AAA;
+ width: 50%;
+}
+
+a {
+ color: #006;
+}
+
+/* content */
+#content-column {
+ position: absolute;
+ top: 1em;
+ left: 10em;
+}
+
+#content {
+ min-height: 140px;
+ padding: 0 1em 1em 1em;
+ border-left: 1px solid #CCC;
+ background: #FFF;
+ font-size: 0.9em;
+}
+
+#content p {
+ max-width: 55em;
+}
+
+.section {
+ border: 1px solid #CCC;
+ padding: 0.5em;
+ margin-bottom: 1em;
+}
+
+/* sidebar */
+#sidebar {
+ margin-top: 3em;
+ min-height: 75%;
+ width: 12em;
+ background: url("../images/sidebar-bg.gif") no-repeat top right;
+ color: #666;
+}
+
+#sidebar h4 {
+ margin: 0 0 0.2em 0;
+ width: 10em;
+ border-bottom: 1px solid #CCC;
+ font-size: 0.8em;
+ font-weight: normal;
+}
+
+#sidebar a {
+ color: #77B;
+ border: 0;
+}
+
+#sidebar ul, #sidebar li {
+ margin: 0;
+ padding: 0;
+ list-style: none none;
+ font-size: 0.9em;
+ color: #888;
+}
+
+#sidebar li {
+ margin-left: 1em;
+}
+
+/* footer */
+#footer {
+ margin: 1em;
+ padding: 1em;
+ font-size: 0.6em;
+ color: gray;
+}
+
+#footer a {
+ color: #669;
+}
diff --git a/src/SMAPI.Web/wwwroot/Content/images/sidebar-bg.gif b/src/SMAPI.Web/wwwroot/Content/images/sidebar-bg.gif
new file mode 100644
index 00000000..48e9af5a
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/images/sidebar-bg.gif
Binary files differ
diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
new file mode 100644
index 00000000..8e30ae12
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
@@ -0,0 +1,278 @@
+/* globals $ */
+
+var smapi = smapi || {};
+smapi.logParser = function(sectionUrl, pasteID) {
+ /*********
+ ** Initialisation
+ *********/
+ var stage,
+ flags = $("#modflags"),
+ output = $("#output"),
+ filters = 0,
+ memory = "",
+ versionInfo,
+ modInfo,
+ modMap,
+ modErrors,
+ logInfo,
+ templateBody = $("#template-body").text(),
+ templateModentry = $("#template-modentry").text(),
+ templateCss = $("#template-css").text(),
+ templateLogentry = $("#template-logentry").text(),
+ templateLognotice = $("#template-lognotice").text(),
+ regexInfo = /\[[\d\:]+ INFO SMAPI] SMAPI (.*?) with Stardew Valley (.*?) on (.*?)\n/g,
+ regexMods = /\[[^\]]+\] Loaded \d+ mods:(?:\n\[[^\]]+\] .+)+/g,
+ regexLog = /\[([\d\:]+) (TRACE|DEBUG|INFO|WARN|ALERT|ERROR) ? ([^\]]+)\] ?((?:\n|.)*?)(?=(?:\[\d\d:|$))/g,
+ regexMod = /\[(?:.*?)\] *(.*?) (\d+\.?(?:.*?))(?: by (.*?))? \|(?:.*?)$/gm,
+ regexDate = /\[\d{2}:\d{2}:\d{2} TRACE SMAPI\] Log started at (.*?) UTC/g,
+ regexPath = /\[\d{2}:\d{2}:\d{2} DEBUG SMAPI\] Mods go here: (.*?)(?:\n|$)/g;
+
+ $("#tabs li:not(.notice)").on("click", function(evt) {
+ var t = $(evt.currentTarget);
+ t.toggleClass("active");
+ $("#output").toggleClass(t.text().toLowerCase());
+ });
+ $("#upload-button").on("click", function() {
+ memory = $("#input").val() || "";
+ $("#input").val("");
+ $("#popup-upload").fadeIn();
+ });
+ $("#popup-upload").on({
+ 'dragover dragenter': function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ },
+ 'drop': function(e) {
+ $("#uploader").attr("data-text", "Reading...");
+ $("#uploader").show();
+ var dataTransfer = e.originalEvent.dataTransfer;
+ if (dataTransfer && dataTransfer.files.length) {
+ e.preventDefault();
+ e.stopPropagation();
+ var file = dataTransfer.files[0];
+ var reader = new FileReader();
+ reader.onload = $.proxy(function(file, $input, event) {
+ $input.val(event.target.result);
+ $("#uploader").fadeOut();
+ $("#submit").click();
+ }, this, file, $("#input"));
+ reader.readAsText(file);
+ }
+ }
+ });
+
+ $("#submit").on("click", function() {
+ $("#popup-upload").fadeOut();
+ var paste = $("#input").val();
+ if (paste) {
+ memory = "";
+ $("#uploader").attr("data-text", "Saving...");
+ $("#uploader").fadeIn();
+ $
+ .ajax({
+ type: "POST",
+ url: sectionUrl + "/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 /><pre>" + $("#input").val() + "</pre></div>");
+ })
+ .then(function(data) {
+ $("#uploader").fadeOut();
+ 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 = (sectionUrl.replace(/\/$/, "") + "/" + data.id);
+ });
+ } else {
+ alert("Unable to parse log, the input is empty!");
+ $("#uploader").fadeOut();
+ }
+ });
+ $("#cancel").on("click", function() {
+ $("#popup-upload").fadeOut(400, function() {
+ $("#input").val(memory);
+ memory = "";
+ });
+ });
+ $("#closeraw").on("click", function() {
+ $("#popup-raw").fadeOut(400);
+ });
+ if (pasteID) {
+ getData(pasteID);
+ }
+ else
+ $("#popup-upload").fadeIn();
+
+
+ /*********
+ ** Helpers
+ *********/
+ function modClicked(evt) {
+ var id = $(evt.currentTarget).attr("id").split("-")[1],
+ cls = "mod-" + id;
+ if (output.hasClass(cls))
+ filters--;
+ else
+ filters++;
+ output.toggleClass(cls);
+ if (filters === 0) {
+ output.removeClass("modfilter");
+ } else {
+ output.addClass("modfilter");
+ }
+ }
+
+ function removeFilter() {
+ for (var c = 0; c < modInfo.length; c++) {
+ output.removeClass("mod-" + c);
+ }
+ filters = 0;
+ output.removeClass("modfilter");
+ }
+
+ function selectAll() {
+ for (var c = 0; c < modInfo.length; c++) {
+ output.addClass("mod-" + c);
+ }
+ filters = modInfo.length;
+ output.addClass("modfilter");
+ }
+
+ function parseData() {
+ stage = "parseData.pre";
+ var data = $("#input").val();
+ if (!data) {
+ stage = "parseData.checkNullData";
+ throw new Error("Field `data` is null");
+
+ }
+ var dataInfo = regexInfo.exec(data) || regexInfo.exec(data) || regexInfo.exec(data),
+ dataMods = regexMods.exec(data) || regexMods.exec(data) || regexMods.exec(data),
+ dataDate = regexDate.exec(data) || regexDate.exec(data) || regexDate.exec(data),
+ dataPath = regexPath.exec(data) || regexPath.exec(data) || regexPath.exec(data),
+ match;
+ stage = "parseData.doNullCheck";
+ if (!dataInfo)
+ throw new Error("Field `dataInfo` is null");
+ if (!dataMods)
+ throw new Error("Field `dataMods` is null");
+ if (!dataPath)
+ throw new Error("Field `dataPath` is null");
+ dataMods = dataMods[0];
+ stage = "parseData.setupDefaults";
+ modMap = {
+ "SMAPI": 0
+ };
+ modErrors = {
+ "SMAPI": 0,
+ "Console.Out": 0
+ };
+ logInfo = [];
+ modInfo = [
+ ["SMAPI", dataInfo[1], "Zoryn, CLxS & Pathoschild"]
+ ];
+ stage = "parseData.parseInfo";
+ var date = dataDate ? new Date(dataDate[1] + "Z") : null;
+ versionInfo = [dataInfo[1], dataInfo[2], dataInfo[3], date ? date.getFullYear() + "-" + ("0" + date.getMonth().toString()).substr(-2) + "-" + ("0" + date.getDay().toString()).substr(-2) + " at " + date.getHours() + ":" + date.getMinutes() + ":" + date.getSeconds() + " " + date.toLocaleTimeString("en-us", { timeZoneName: "short" }).split(" ")[2] : "No timestamp found", dataPath[1]];
+ stage = "parseData.parseMods";
+ while ((match = regexMod.exec(dataMods))) {
+ modErrors[match[1]] = 0;
+ modMap[match[1]] = modInfo.length;
+ modInfo.push([match[1], match[2], match[3] ? ("by " + match[3]) : "Unknown author"]);
+ }
+ stage = "parseData.parseLog";
+ while ((match = regexLog.exec(data))) {
+ if (match[2] === "ERROR")
+ modErrors[match[3]]++;
+ logInfo.push([match[1], match[2], match[3], match[4]]);
+ }
+ stage = "parseData.post";
+ modMap["Console.Out"] = modInfo.length;
+ modInfo.push(["Console.Out", "", ""]);
+ }
+
+ function renderData() {
+ stage = "renderData.pre";
+ output.html(prepare(templateBody, versionInfo));
+ var modslist = $("#modslist"), log = $("#log"), modCache = [], y = 0;
+ for (; y < modInfo.length; y++) {
+ var errors = modErrors[modInfo[y][0]],
+ err, cls = "color-red";
+ if (errors === 0) {
+ err = "No Errors";
+ cls = "color-green";
+ } else if (errors === 1)
+ err = "1 Error";
+ else
+ err = errors + " Errors";
+ modCache.push(prepare(templateModentry, [y, modInfo[y][0], modInfo[y][1], modInfo[y][2], cls, err]));
+ }
+ modslist.append(modCache.join(""));
+ for (var z = 0; z < modInfo.length; z++)
+ $("#modlink-" + z).on("click", modClicked);
+ var flagCache = [];
+ for (var c = 0; c < modInfo.length; c++)
+ flagCache.push(prepare(templateCss, [c]));
+ flags.html(flagCache.join(""));
+ var logCache = [], dupeCount = 0, dupeMemory = "|||";
+ for (var x = 0; x < logInfo.length; x++) {
+ var dm = logInfo[x][1] + "|" + logInfo[x][2] + "|" + logInfo[x][3];
+ if (dupeMemory !== dm) {
+ if (dupeCount > 0)
+ logCache.push(prepare(templateLognotice, [logInfo[x - 1][1].toLowerCase(), modMap[logInfo[x - 1][2]], dupeCount]));
+ dupeCount = 0;
+ dupeMemory = dm;
+ logCache.push(prepare(templateLogentry, [logInfo[x][1].toLowerCase(), modMap[logInfo[x][2]], logInfo[x][0], logInfo[x][1], logInfo[x][2], logInfo[x][3].split(" ").join("&nbsp ").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\n/g, "<br />")]));
+ }
+ else
+ dupeCount++;
+ }
+ log.append(logCache.join(""));
+ $("#modlink-r").on("click", removeFilter);
+ $("#modlink-a").on("click", selectAll);
+ }
+
+ function prepare(str, arr) {
+ var regex = /\{(\d)\}/g,
+ match;
+ while ((match = regex.exec(str)))
+ str = str.replace(match[0], arr[match[1]]);
+ return str;
+ }
+ function loadData() {
+ try {
+ stage = "loadData.Pre";
+ var start = performance.now();
+ parseData();
+ renderData();
+ var end = performance.now();
+ $(".always").prepend("<div>Log processed in: " + (Math.round((end - start) * 100) / 100) + ' ms (<a id="viewraw" href="#">View raw</a>)</div><br />');
+ $("#viewraw").on("click", function() {
+ $("#dataraw").val($("#input").val());
+ $("#popup-raw").fadeIn();
+ });
+ stage = "loadData.Post";
+ }
+ catch (err) {
+ $("#output").html('<div id="log" class="color-red"><h1>Parsing failed!</h1>Parsing of the log failed, details follow.<br />&nbsp;<p>Stage: ' + stage + "</p>" + err + '<hr /><pre id="rawlog"></pre></div>');
+ $("#rawlog").text($("#input").val());
+ }
+ }
+ function getData(pasteID) {
+ $("#uploader").attr("data-text", "Loading...");
+ $("#uploader").fadeIn();
+ $.get(sectionUrl + "/fetch/" + pasteID, function(data) {
+ if (data.success) {
+ $("#input").val(data.content);
+ loadData();
+ } else {
+ $("#output").html('<div id="log" class="color-red"><h1>Fetching the log failed!</h1><p>' + data.error + '</p><pre id="rawlog"></pre></div>');
+ $("#rawlog").text($("#input").val());
+ }
+ $("#uploader").fadeOut();
+ });
+ }
+};
diff --git a/src/SMAPI.Web/wwwroot/favicon.ico b/src/SMAPI.Web/wwwroot/favicon.ico
new file mode 100644
index 00000000..587a6e74
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/favicon.ico
Binary files differ